Skip to main content

qorechain/tx/
mod.rs

1//! Native + hybrid (classical + post-quantum) transaction building, signing, and
2//! broadcast for QoreChain.
3//!
4//! A native transaction carries a classical secp256k1 signature in
5//! `TxRaw.signatures`. A hybrid transaction additionally attaches an ML-DSA-87
6//! (Dilithium-5) signature to the `TxBody` as a `PQCHybridSignature` extension.
7//! The chain's ante handler verifies BOTH, so a hybrid account stays
8//! interoperable with classical verification while gaining quantum safety.
9//!
10//! ─────────────────────────────────────────────────────────────────────────────
11//!  The wallet ↔ chain hybrid contract (enforced by the chain; matches the other SDKs)
12//! ─────────────────────────────────────────────────────────────────────────────
13//! The chain verifies the ML-DSA-87 signature over the tx body WITH the PQC
14//! extension REMOVED:
15//!
16//!   - `B0` = protobuf bytes of the `TxBody` containing the messages/memo/timeout
17//!     but NOT the `PQCHybridSignature` extension.
18//!   - `A`  = the `AuthInfo` bytes (signer secp256k1 pubkey, `SIGN_MODE_DIRECT`,
19//!     sequence, fee) — the exact bytes that are broadcast.
20//!   - PQC signed message = `BE32(len(B0)) || B0 || BE32(len(A)) || A` (4-byte
21//!     big-endian length prefixes; NO hashing, NO domain prefix).
22//!   - PQC signature = `pqc_sign(pqc_secret, message)` — pure ML-DSA-87 (4627
23//!     bytes for Dilithium-5).
24//!   - The `PQCHybridSignature` extension is then added to
25//!     `TxBody.extension_options` (the CRITICAL extension-options slot) as an
26//!     `Any` whose `type_url` is `"/qorechain.pqc.v1.PQCHybridSignature"` and
27//!     whose `value` is the UTF-8 bytes of the JSON
28//!     `{"algorithm_id","pqc_signature","pqc_public_key"?}` (standard padded
29//!     base64; `pqc_public_key` omitted when not supplied) → the final body bytes.
30//!   - The CLASSICAL secp256k1 `SIGN_MODE_DIRECT` signature is computed over
31//!     `SignDoc(finalBody, A, chainId, accountNumber)` and goes in
32//!     `TxRaw.signatures` (outside the body). The classical signature never signs
33//!     itself.
34//!
35//! The signer's PQC key must be registered on-chain (via `MsgRegisterPQCKey`)
36//! before hybrid txs PQC-verify — unless `include_pqc_public_key` is set, which
37//! embeds the key for auto-registration on first use. Registering the key is the
38//! caller's responsibility.
39//!
40//! Determinism note (same caveat as the other SDKs): the `BE32` framing is
41//! byte-for-byte deterministic on the wallet side. Cross-implementation
42//! determinism (this `prost` encoding vs. the chain's re-marshal of the same
43//! `TxBody`) is confirmed for the default bank message types; callers using
44//! custom message types with non-canonical field ordering must ensure their
45//! encoding is canonical.
46
47pub mod errors;
48pub mod gas;
49pub mod search;
50pub mod track;
51
52pub use errors::{decode_tx_error, QoreTxError};
53pub use gas::{
54    calculate_fee, estimate_fee, estimate_gas, GasPrice, DEFAULT_GAS_MULTIPLIER, DEFAULT_GAS_PRICE,
55    GAS_AUTO,
56};
57pub use search::{
58    build_event_query, get_block, get_latest_block, get_tx, search_txs, TxSearchResult,
59};
60pub use track::{broadcast_and_wait, wait_for_tx, with_retry, TxResult, WaitOptions};
61
62use crate::error::{Error, Result};
63use crate::pqc::{
64    build_hybrid_signature_extension, pqc_sign, ALGORITHM_DILITHIUM5, HYBRID_SIG_TYPE_URL,
65};
66
67use base64::engine::general_purpose::STANDARD as BASE64;
68use base64::Engine;
69use cosmrs::crypto::secp256k1::SigningKey;
70use cosmrs::proto::cosmos::bank::v1beta1::MsgSend;
71use cosmrs::proto::cosmos::base::v1beta1::Coin as ProtoCoin;
72use cosmrs::proto::cosmos::tx::signing::v1beta1::SignMode;
73use cosmrs::proto::cosmos::tx::v1beta1::{
74    mode_info::{Single, Sum},
75    AuthInfo, Fee as ProtoFee, ModeInfo, SignDoc, SignerInfo, TxBody, TxRaw,
76};
77use cosmrs::proto::traits::Message as ProstMessage;
78use cosmrs::Any;
79use serde::Serialize;
80use serde_json::Value;
81
82/// The `/cosmos.bank.v1beta1.MsgSend` type URL.
83pub const MSG_SEND_TYPE_URL: &str = "/cosmos.bank.v1beta1.MsgSend";
84
85/// A Cosmos coin amount: a denom plus an integer base amount (as a string).
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Coin {
88    /// The coin denomination (e.g. `"uqor"`).
89    pub denom: String,
90    /// The integer base amount, as a decimal string (e.g. `"1000"`).
91    pub amount: String,
92}
93
94/// The transaction fee: a coin amount plus a gas limit (as a string).
95#[derive(Debug, Clone, Default)]
96pub struct Fee {
97    /// The coins paid as a fee (e.g. uqor).
98    pub amount: Vec<Coin>,
99    /// The gas limit, as a decimal string (e.g. `"200000"`).
100    pub gas: String,
101    /// Optionally pays the fee via a fee grant.
102    pub granter: String,
103    /// Optionally identifies the fee payer.
104    pub payer: String,
105}
106
107/// A transaction message: a type URL plus its protobuf-encoded value bytes.
108#[derive(Debug, Clone)]
109pub struct Message {
110    /// The message type URL (e.g. `"/cosmos.bank.v1beta1.MsgSend"`).
111    pub type_url: String,
112    /// The protobuf-encoded message value.
113    pub value: Vec<u8>,
114}
115
116/// A built, signed transaction plus the intermediate artifacts.
117///
118/// For a plain [`bank_send`] the PQC fields are empty; for [`build_hybrid_tx`]
119/// they expose the exact bytes signed by ML-DSA-87 so the contract can be
120/// asserted/audited.
121#[derive(Debug, Clone)]
122pub struct BuiltTx {
123    /// Encoded `TxRaw`, ready to broadcast.
124    pub tx_raw_bytes: Vec<u8>,
125    /// `A` — the `AuthInfo` bytes (identical in the PQC framing and the SignDoc).
126    pub auth_info_bytes: Vec<u8>,
127    /// The final `TxBody` bytes (WITH the PQC extension, for a hybrid tx).
128    pub body_bytes: Vec<u8>,
129    /// The exact bytes the ML-DSA-87 signature covered (empty for a non-hybrid tx).
130    pub pqc_signed_message: Vec<u8>,
131    /// The raw ML-DSA-87 signature (Dilithium-5: 4627 bytes; empty for non-hybrid).
132    pub pqc_signature: Vec<u8>,
133}
134
135/// The REST `/cosmos/tx/v1beta1/txs` broadcast behavior.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum BroadcastMode {
138    /// Submit and return after `CheckTx`.
139    Sync,
140    /// Submit and return immediately.
141    Async,
142    /// Submit and wait until the tx is committed in a block.
143    Block,
144}
145
146impl BroadcastMode {
147    /// The proto3 enum string the REST endpoint expects.
148    fn wire(self) -> &'static str {
149        match self {
150            BroadcastMode::Sync => "BROADCAST_MODE_SYNC",
151            BroadcastMode::Async => "BROADCAST_MODE_ASYNC",
152            BroadcastMode::Block => "BROADCAST_MODE_BLOCK",
153        }
154    }
155}
156
157/// Builds a [`Fee`] from a RestClient fee-estimate JSON body.
158///
159/// The estimate provides only the suggested fee amount (in uqor); the gas limit
160/// is chosen by the caller. The `suggested_fee_uqor` field is accepted as either
161/// a JSON string (`"1234"`) or a JSON number (`1234`). An empty/zero/missing fee
162/// returns an error so callers can fall back to a static fee.
163pub fn fee_from_estimate(estimate: &Value, gas: &str) -> Result<Fee> {
164    let raw = &estimate["suggested_fee_uqor"];
165    let amount = match raw {
166        Value::String(s) => s.clone(),
167        Value::Number(n) => n.to_string(),
168        Value::Null => {
169            return Err(Error::InvalidResponse(
170                "fee estimate has no suggested_fee_uqor".into(),
171            ))
172        }
173        other => {
174            return Err(Error::InvalidResponse(format!(
175                "fee estimate suggested_fee_uqor has unexpected type: {other}"
176            )))
177        }
178    };
179    if amount.is_empty() || amount == "0" {
180        return Err(Error::InvalidResponse(
181            "fee estimate suggested_fee_uqor is empty/zero".into(),
182        ));
183    }
184    if amount.contains(['.', 'e', 'E']) {
185        return Err(Error::InvalidResponse(format!(
186            "fee estimate suggested_fee_uqor is not an integer: {amount}"
187        )));
188    }
189    Ok(Fee {
190        amount: vec![Coin {
191            denom: "uqor".into(),
192            amount,
193        }],
194        gas: gas.into(),
195        granter: String::new(),
196        payer: String::new(),
197    })
198}
199
200/// The inputs to [`bank_send`].
201#[derive(Debug, Clone)]
202pub struct BankSendParams {
203    /// The signer's 32-byte secp256k1 private key (from `derive_native_account`).
204    pub private_key: Vec<u8>,
205    /// The signer's 33-byte compressed secp256k1 public key.
206    pub public_key: Vec<u8>,
207    /// The bech32 sender address.
208    pub from_address: String,
209    /// The bech32 recipient address.
210    pub to_address: String,
211    /// The coins to send.
212    pub amount: Vec<Coin>,
213    /// The chain id (e.g. `"qorechain-diana"`).
214    pub chain_id: String,
215    /// The signer's on-chain account number.
216    pub account_number: u64,
217    /// The signer's current account sequence (nonce).
218    pub sequence: u64,
219    /// The fee to pay.
220    pub fee: Fee,
221    /// An optional tx memo.
222    pub memo: String,
223    /// An optional tx timeout height (`0` = none).
224    pub timeout_height: u64,
225}
226
227/// Builds and signs a bank `MsgSend` into a broadcast-ready `TxRaw`.
228///
229/// Constructs `/cosmos.bank.v1beta1.MsgSend` from `from_address` to `to_address`,
230/// builds the `SIGN_MODE_DIRECT` `AuthInfo` from the compressed secp256k1 pubkey,
231/// signs the `SignDoc`, and assembles the `TxRaw`. This does not broadcast — pass
232/// [`BuiltTx::tx_raw_bytes`] to [`broadcast`].
233pub fn bank_send(params: BankSendParams) -> Result<BuiltTx> {
234    let msg = MsgSend {
235        from_address: params.from_address,
236        to_address: params.to_address,
237        amount: to_proto_coins(&params.amount)?,
238    };
239    let messages = vec![Any {
240        type_url: MSG_SEND_TYPE_URL.to_string(),
241        value: msg.encode_to_vec(),
242    }];
243
244    let body = TxBody {
245        messages,
246        memo: params.memo,
247        timeout_height: params.timeout_height,
248        extension_options: vec![],
249        non_critical_extension_options: vec![],
250    };
251    let body_bytes = body.encode_to_vec();
252
253    let auth_info_bytes = build_auth_info_bytes(&params.public_key, params.sequence, &params.fee)?;
254    let sig = sign_direct(
255        &params.private_key,
256        &body_bytes,
257        &auth_info_bytes,
258        &params.chain_id,
259        params.account_number,
260    )?;
261
262    let tx_raw = TxRaw {
263        body_bytes: body_bytes.clone(),
264        auth_info_bytes: auth_info_bytes.clone(),
265        signatures: vec![sig],
266    };
267    Ok(BuiltTx {
268        tx_raw_bytes: tx_raw.encode_to_vec(),
269        auth_info_bytes,
270        body_bytes,
271        pqc_signed_message: vec![],
272        pqc_signature: vec![],
273    })
274}
275
276/// The inputs to [`send_messages`].
277#[derive(Debug, Clone)]
278pub struct SendMessagesParams {
279    /// The signer's 32-byte secp256k1 private key.
280    pub private_key: Vec<u8>,
281    /// The signer's 33-byte compressed secp256k1 public key.
282    pub public_key: Vec<u8>,
283    /// The tx messages, already packed as `cosmrs::Any` (e.g. from the `msg`
284    /// composers).
285    pub messages: Vec<Any>,
286    /// The chain id (e.g. `"qorechain-diana"`).
287    pub chain_id: String,
288    /// The signer's on-chain account number.
289    pub account_number: u64,
290    /// The signer's current account sequence (nonce).
291    pub sequence: u64,
292    /// The fee to pay.
293    pub fee: Fee,
294    /// An optional tx memo.
295    pub memo: String,
296    /// An optional tx timeout height (`0` = none).
297    pub timeout_height: u64,
298}
299
300/// Builds and signs an arbitrary set of messages into a broadcast-ready `TxRaw`,
301/// carrying a single classical secp256k1 `SIGN_MODE_DIRECT` signature.
302///
303/// This is the generic counterpart of [`bank_send`]: pass any messages produced
304/// by the [`crate::msg`] composers (custom QoreChain modules or standard Cosmos
305/// modules). Use [`build_hybrid_tx`] when a post-quantum signature is also
306/// required. This does not broadcast — pass [`BuiltTx::tx_raw_bytes`] to
307/// [`broadcast`].
308pub fn send_messages(params: SendMessagesParams) -> Result<BuiltTx> {
309    let body = TxBody {
310        messages: params.messages,
311        memo: params.memo,
312        timeout_height: params.timeout_height,
313        extension_options: vec![],
314        non_critical_extension_options: vec![],
315    };
316    let body_bytes = body.encode_to_vec();
317
318    let auth_info_bytes = build_auth_info_bytes(&params.public_key, params.sequence, &params.fee)?;
319    let sig = sign_direct(
320        &params.private_key,
321        &body_bytes,
322        &auth_info_bytes,
323        &params.chain_id,
324        params.account_number,
325    )?;
326
327    let tx_raw = TxRaw {
328        body_bytes: body_bytes.clone(),
329        auth_info_bytes: auth_info_bytes.clone(),
330        signatures: vec![sig],
331    };
332    Ok(BuiltTx {
333        tx_raw_bytes: tx_raw.encode_to_vec(),
334        auth_info_bytes,
335        body_bytes,
336        pqc_signed_message: vec![],
337        pqc_signature: vec![],
338    })
339}
340
341/// The inputs to [`build_hybrid_tx`].
342#[derive(Debug, Clone)]
343pub struct BuildHybridTxParams {
344    /// The signer's 32-byte secp256k1 private key (the classical half).
345    pub private_key: Vec<u8>,
346    /// The signer's 33-byte compressed secp256k1 public key.
347    pub public_key: Vec<u8>,
348    /// The ML-DSA-87 (Dilithium-5) secret key (the post-quantum half).
349    pub pqc_secret_key: Vec<u8>,
350    /// The ML-DSA-87 public key (embedded only when `include_pqc_public_key`).
351    pub pqc_public_key: Vec<u8>,
352    /// The tx messages as `{type_url, value}` pairs (value = encoded proto bytes).
353    pub messages: Vec<Message>,
354    /// The fee to pay.
355    pub fee: Fee,
356    /// The chain id.
357    pub chain_id: String,
358    /// The signer's on-chain account number.
359    pub account_number: u64,
360    /// The signer's current account sequence.
361    pub sequence: u64,
362    /// An optional tx memo.
363    pub memo: String,
364    /// An optional tx timeout height (`0` = none).
365    pub timeout_height: u64,
366    /// Embeds the 2592-byte ML-DSA-87 public key in the extension for
367    /// auto-registration on first use. Defaults to `false` (the key is expected
368    /// to be registered already via `MsgRegisterPQCKey`).
369    pub include_pqc_public_key: bool,
370}
371
372/// Builds a fully signed hybrid (classical + PQC) transaction following the chain
373/// contract documented in the module header.
374///
375/// The build sequence:
376///  1. Encode `B0` — the `TxBody` WITHOUT the PQC extension.
377///  2. Encode `A`  — the single-signer `SIGN_MODE_DIRECT` `AuthInfo`.
378///  3. `message = BE32(len B0) || B0 || BE32(len A) || A`; ML-DSA-87 sign it.
379///  4. Build the `PQCHybridSignature` extension `Any` and attach it to a new body
380///     identical to step 1 but with `extension_options = [ext]` → final body bytes.
381///  5. Classical `SIGN_MODE_DIRECT` signature over `SignDoc(finalBody, A, chainId,
382///     accountNumber)`.
383///  6. Assemble `TxRaw(finalBody, A, [classicalSig])`.
384///
385/// The returned [`BuiltTx`] exposes `pqc_signed_message` and `pqc_signature` so
386/// the contract can be asserted/audited.
387///
388/// On-chain prerequisite: the signer's PQC key must already be registered via
389/// `MsgRegisterPQCKey` for the chain to PQC-verify the tx, unless
390/// `include_pqc_public_key` is set to embed the key for auto-registration.
391pub fn build_hybrid_tx(params: BuildHybridTxParams) -> Result<BuiltTx> {
392    let messages = encode_messages(&params.messages);
393
394    // 1. B0 — body WITHOUT the PQC extension.
395    let base_body = TxBody {
396        messages: messages.clone(),
397        memo: params.memo.clone(),
398        timeout_height: params.timeout_height,
399        extension_options: vec![],
400        non_critical_extension_options: vec![],
401    };
402    let b0 = base_body.encode_to_vec();
403
404    // 2. A — single-signer AuthInfo (SIGN_MODE_DIRECT).
405    let auth_info_bytes = build_auth_info_bytes(&params.public_key, params.sequence, &params.fee)?;
406
407    // 3. PQC framing + ML-DSA-87 signature over B0 + A (NOT the final body).
408    let pqc_signed_message = frame_sign_bytes(&b0, &auth_info_bytes);
409    let pqc_signature = pqc_sign(&params.pqc_secret_key, &pqc_signed_message)?;
410
411    // 4. Build the PQC extension Any (JSON value) and attach it to the FINAL body
412    //    as a CRITICAL extension option.
413    let public_key: Option<&[u8]> = if params.include_pqc_public_key {
414        Some(params.pqc_public_key.as_slice())
415    } else {
416        None
417    };
418    let ext = build_hybrid_signature_extension(ALGORITHM_DILITHIUM5, &pqc_signature, public_key)?;
419    let ext_value = to_canonical_json(&ext)?;
420    let ext_any = Any {
421        type_url: HYBRID_SIG_TYPE_URL.to_string(),
422        value: ext_value,
423    };
424    let final_body = TxBody {
425        messages,
426        memo: params.memo,
427        timeout_height: params.timeout_height,
428        extension_options: vec![ext_any],
429        non_critical_extension_options: vec![],
430    };
431    let body_bytes = final_body.encode_to_vec();
432
433    // 5. Classical SIGN_MODE_DIRECT signature over the FINAL body + A.
434    let classical_sig = sign_direct(
435        &params.private_key,
436        &body_bytes,
437        &auth_info_bytes,
438        &params.chain_id,
439        params.account_number,
440    )?;
441
442    // 6. Assemble TxRaw.
443    let tx_raw = TxRaw {
444        body_bytes: body_bytes.clone(),
445        auth_info_bytes: auth_info_bytes.clone(),
446        signatures: vec![classical_sig],
447    };
448    Ok(BuiltTx {
449        tx_raw_bytes: tx_raw.encode_to_vec(),
450        auth_info_bytes,
451        body_bytes,
452        pqc_signed_message,
453        pqc_signature,
454    })
455}
456
457/// POSTs signed `TxRaw` bytes to the REST `/cosmos/tx/v1beta1/txs` endpoint.
458///
459/// Sends `{"tx_bytes": <base64>, "mode": "BROADCAST_MODE_*"}` and returns the
460/// parsed JSON response. Broadcasting requires a live node; unit tests mock this
461/// POST against a local server.
462pub async fn broadcast(rest_url: &str, tx_bytes: &[u8], mode: BroadcastMode) -> Result<Value> {
463    let url = format!("{}/cosmos/tx/v1beta1/txs", rest_url.trim_end_matches('/'));
464    let payload = serde_json::json!({
465        "tx_bytes": BASE64.encode(tx_bytes),
466        "mode": mode.wire(),
467    });
468    let resp = reqwest::Client::new()
469        .post(&url)
470        .header("Content-Type", "application/json")
471        .header("Accept", "application/json")
472        .json(&payload)
473        .send()
474        .await?;
475    let status = resp.status();
476    let body = resp.text().await?;
477    if !status.is_success() {
478        return Err(Error::Http {
479            status: status.as_u16(),
480            url,
481            body,
482        });
483    }
484    serde_json::from_str(&body).map_err(|e| Error::InvalidResponse(e.to_string()))
485}
486
487// --- internal helpers ---
488
489/// A big-endian 4-byte length prefix, matching the chain contract framing.
490fn be32(n: u32) -> [u8; 4] {
491    n.to_be_bytes()
492}
493
494/// Frames the PQC sign-bytes as `BE32(len(b0)) || b0 || BE32(len(a)) || a`.
495fn frame_sign_bytes(b0: &[u8], a: &[u8]) -> Vec<u8> {
496    let mut out = Vec::with_capacity(8 + b0.len() + a.len());
497    out.extend_from_slice(&be32(b0.len() as u32));
498    out.extend_from_slice(b0);
499    out.extend_from_slice(&be32(a.len() as u32));
500    out.extend_from_slice(a);
501    out
502}
503
504/// Serializes the hybrid extension to canonical JSON: field order
505/// `algorithm_id`, `pqc_signature`, then `pqc_public_key` (omitted when absent),
506/// with no extra whitespace — matching the other SDKs' wire bytes.
507fn to_canonical_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
508    serde_json::to_vec(value).map_err(|e| Error::Pqc(format!("serialize hybrid extension: {e}")))
509}
510
511fn to_proto_coins(coins: &[Coin]) -> Result<Vec<ProtoCoin>> {
512    for c in coins {
513        validate_amount(&c.amount)?;
514    }
515    Ok(coins
516        .iter()
517        .map(|c| ProtoCoin {
518            denom: c.denom.clone(),
519            amount: c.amount.clone(),
520        })
521        .collect())
522}
523
524fn validate_amount(amount: &str) -> Result<()> {
525    if amount.is_empty() || !amount.bytes().all(|b| b.is_ascii_digit()) {
526        return Err(Error::Denom(format!("invalid coin amount: {amount:?}")));
527    }
528    Ok(())
529}
530
531fn encode_messages(messages: &[Message]) -> Vec<Any> {
532    messages
533        .iter()
534        .map(|m| Any {
535            type_url: m.type_url.clone(),
536            value: m.value.clone(),
537        })
538        .collect()
539}
540
541fn fee_to_proto(fee: &Fee) -> Result<ProtoFee> {
542    let amount = to_proto_coins(&fee.amount)?;
543    let gas_limit = if fee.gas.is_empty() {
544        0
545    } else {
546        fee.gas
547            .parse::<u64>()
548            .map_err(|_| Error::Denom(format!("invalid gas: {:?}", fee.gas)))?
549    };
550    Ok(ProtoFee {
551        amount,
552        gas_limit,
553        payer: fee.payer.clone(),
554        granter: fee.granter.clone(),
555    })
556}
557
558fn build_auth_info_bytes(public_key: &[u8], sequence: u64, fee: &Fee) -> Result<Vec<u8>> {
559    let pubkey_any = secp256k1_pubkey_any(public_key)?;
560    let auth_info = AuthInfo {
561        signer_infos: vec![SignerInfo {
562            public_key: Some(pubkey_any),
563            mode_info: Some(ModeInfo {
564                sum: Some(Sum::Single(Single {
565                    mode: SignMode::Direct as i32,
566                })),
567            }),
568            sequence,
569        }],
570        fee: Some(fee_to_proto(fee)?),
571        // `tip` is deprecated upstream; default (None) keeps it off the wire.
572        ..Default::default()
573    };
574    Ok(auth_info.encode_to_vec())
575}
576
577/// Builds the `/cosmos.crypto.secp256k1.PubKey` `Any` from a 33-byte compressed
578/// public key.
579fn secp256k1_pubkey_any(compressed: &[u8]) -> Result<Any> {
580    let pubkey = cosmrs::proto::cosmos::crypto::secp256k1::PubKey {
581        key: compressed.to_vec(),
582    };
583    Ok(Any {
584        type_url: "/cosmos.crypto.secp256k1.PubKey".to_string(),
585        value: pubkey.encode_to_vec(),
586    })
587}
588
589/// Produces a canonical 64-byte secp256k1 `SIGN_MODE_DIRECT` signature over the
590/// serialized `SignDoc`.
591fn sign_direct(
592    private_key: &[u8],
593    body_bytes: &[u8],
594    auth_info_bytes: &[u8],
595    chain_id: &str,
596    account_number: u64,
597) -> Result<Vec<u8>> {
598    let sign_doc = SignDoc {
599        body_bytes: body_bytes.to_vec(),
600        auth_info_bytes: auth_info_bytes.to_vec(),
601        chain_id: chain_id.to_string(),
602        account_number,
603    };
604    let sign_bytes = sign_doc.encode_to_vec();
605    let signing = SigningKey::from_slice(private_key)
606        .map_err(|e| Error::Derivation(format!("invalid signing key: {e}")))?;
607    let sig = signing
608        .sign(&sign_bytes)
609        .map_err(|e| Error::Derivation(format!("secp256k1 sign: {e}")))?;
610    // k256 normalizes to low-S; the compact 64-byte form is the Cosmos wire form.
611    Ok(sig.to_bytes().to_vec())
612}