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(¶ms.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(¶ms.public_key, params.sequence, ¶ms.fee)?;
254 let sig = sign_direct(
255 ¶ms.private_key,
256 &body_bytes,
257 &auth_info_bytes,
258 ¶ms.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(¶ms.public_key, params.sequence, ¶ms.fee)?;
319 let sig = sign_direct(
320 ¶ms.private_key,
321 &body_bytes,
322 &auth_info_bytes,
323 ¶ms.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(¶ms.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(¶ms.public_key, params.sequence, ¶ms.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(¶ms.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 ¶ms.private_key,
436 &body_bytes,
437 &auth_info_bytes,
438 ¶ms.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}