Skip to main content

qorechain/
signbytes.rs

1//! Per-network post-quantum sign-bytes: the v1 / v2 forms, the rule that picks
2//! one for a chain, and a cached resolver that asks the node.
3//!
4//! Chain release `v3.2.0` (taken by the testnet under the earlier name
5//! `v3.1.98`) changed three payloads an ML-DSA key signs:
6//!
7//! | payload | v1 (legacy) | v2 |
8//! |---|---|---|
9//! | hybrid tx | `BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A` | `"qorechain-pqc-hybrid-v2" ‖ BE64(len chainID) ‖ chainID ‖ BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A` |
10//! | key migration | ASCII `qorechain-key-migration:chain=…:from=…:to=…:account=…:height=…` | `"qorechain-key-migration-v2" ‖ BE64(len chainID) ‖ chainID ‖ BE64(len account) ‖ account ‖ BE32(from) ‖ BE32(to) ‖ BE64(height) ‖ BE32(len oldPub) ‖ oldPub ‖ BE32(len newPub) ‖ newPub` |
11//! | bridge attestation | ASCII `chain\|eventType\|operationID\|txHash\|amount\|asset` | `"qorechain-bridge-attestation-v2"` then `BE64(len f) ‖ f` for `f` in `[chainID, chain, eventType, operationID, txHash, amount, asset]` |
12//!
13//! All lengths are big-endian, strings are UTF-8, nothing is terminated. `B0`
14//! is the `TxBody` WITHOUT the PQC extension; `A` is the `AuthInfo` bytes.
15//!
16//! A network verifies exactly ONE form. The networks that existed before v2
17//! (`qorechain-vladi`, `qorechain-diana`) keep verifying v1 until the v2
18//! upgrade plan is applied on them; any other chain verifies v2 from its first
19//! block. Today the testnet has applied the upgrade (v2) and the mainnet has
20//! not (v1). [`sign_bytes_version_for`] is the client-side mirror of the
21//! chain's switch.
22//!
23//! The switch ships under TWO plan names ([`SIGN_BYTES_V2_UPGRADES`]): the
24//! testnet took it as `v3.1.98` and keeps that record forever, while mainnet
25//! takes it as `v3.2.0`. Both names run the same handler, so
26//! [`SignBytesResolver`] asks a node's REST endpoint
27//! (`/cosmos/upgrade/v1beta1/applied_plan/{name}`) for EVERY name, in order,
28//! stopping at the first height above zero — asking one name only would read
29//! height 0 on the other network, sign v1, and have every hybrid transaction
30//! refused with `pqc` code 21. The answer is cached briefly, because a network
31//! can upgrade while a wallet is open.
32//!
33//! The resolver never guesses: a legacy chain with no REST URL, or a failed
34//! query, is an error that asks for a REST URL or an explicit
35//! [`SignBytesMode::V1`] / [`SignBytesMode::V2`].
36
37use std::collections::HashMap;
38use std::fmt;
39use std::future::Future;
40use std::str::FromStr;
41use std::sync::{Mutex, OnceLock};
42use std::time::{Duration, Instant};
43
44use serde_json::Value;
45
46use crate::error::{Error, Result};
47use crate::query::RestClient;
48
49/// Domain tag that opens the v2 hybrid-tx sign-bytes.
50pub const HYBRID_SIGN_BYTES_DOMAIN: &str = "qorechain-pqc-hybrid-v2";
51
52/// Domain tag that opens the v2 key-migration sign-bytes.
53pub const MIGRATION_SIGN_BYTES_DOMAIN: &str = "qorechain-key-migration-v2";
54
55/// Domain tag that opens the v2 bridge-attestation sign-bytes.
56pub const BRIDGE_ATTESTATION_SIGN_BYTES_DOMAIN: &str = "qorechain-bridge-attestation-v2";
57
58/// The upgrade plan whose application switches a legacy chain from v1 to v2,
59/// under its primary (mainnet) name. See [`SIGN_BYTES_V2_UPGRADES`].
60pub const SIGN_BYTES_V2_UPGRADE: &str = "v3.2.0";
61
62/// EVERY upgrade name that switches a network to v2, in query order. The same
63/// handler ships under both: mainnet applies `v3.2.0`, the testnet already
64/// applied `v3.1.98` and keeps that record forever. A client must ask
65/// `applied_plan` for each name and use v2 if ANY of them answers a height
66/// above zero.
67pub const SIGN_BYTES_V2_UPGRADES: &[&str] = &["v3.2.0", "v3.1.98"];
68
69/// The chains that were running before v2 existed; they switch to v2 only once
70/// one of [`SIGN_BYTES_V2_UPGRADES`] is applied on them.
71pub const LEGACY_SIGN_BYTES_CHAINS: &[&str] = &["qorechain-vladi", "qorechain-diana"];
72
73/// How long a resolved version is cached per `(rest_url, chain_id)` by default.
74pub const DEFAULT_SIGN_BYTES_CACHE_TTL: Duration = Duration::from_secs(60);
75
76/// The codespace of the chain's PQC module.
77pub const PQC_CODESPACE: &str = "pqc";
78
79/// The PQC module's error code for a hybrid signature that does not verify.
80pub const PQC_HYBRID_VERIFY_FAILED_CODE: u32 = 21;
81
82/// The chain's message for [`PQC_HYBRID_VERIFY_FAILED_CODE`].
83pub const PQC_HYBRID_VERIFY_FAILED_MESSAGE: &str = "hybrid PQC signature verification failed";
84
85/// A concrete sign-bytes form.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum SignBytesVersion {
88    /// The legacy form (no domain tag, no chain id).
89    V1,
90    /// The domain-tagged, chain-bound form introduced by the v2 upgrade.
91    V2,
92}
93
94impl SignBytesVersion {
95    /// The version number (`1` or `2`), as the chain's CLI reports it.
96    pub fn number(self) -> u8 {
97        match self {
98            SignBytesVersion::V1 => 1,
99            SignBytesVersion::V2 => 2,
100        }
101    }
102
103    /// The option spelling (`"v1"` / `"v2"`).
104    pub fn as_str(self) -> &'static str {
105        match self {
106            SignBytesVersion::V1 => "v1",
107            SignBytesVersion::V2 => "v2",
108        }
109    }
110}
111
112impl fmt::Display for SignBytesVersion {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118impl FromStr for SignBytesVersion {
119    type Err = Error;
120
121    fn from_str(s: &str) -> Result<Self> {
122        match s {
123            "v1" => Ok(SignBytesVersion::V1),
124            "v2" => Ok(SignBytesVersion::V2),
125            other => Err(Error::SignBytes(format!(
126                "sign-bytes version must be v1 or v2, got {other:?}"
127            ))),
128        }
129    }
130}
131
132/// The caller's choice of sign-bytes: a fixed version, or `Auto` (ask the chain).
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
134pub enum SignBytesMode {
135    /// Resolve per network (see [`SignBytesResolver::resolve`]). The default.
136    #[default]
137    Auto,
138    /// Always sign v1. No network call.
139    V1,
140    /// Always sign v2. No network call.
141    V2,
142}
143
144impl SignBytesMode {
145    /// The fixed version this mode names, or `None` for `Auto`.
146    pub fn fixed(self) -> Option<SignBytesVersion> {
147        match self {
148            SignBytesMode::Auto => None,
149            SignBytesMode::V1 => Some(SignBytesVersion::V1),
150            SignBytesMode::V2 => Some(SignBytesVersion::V2),
151        }
152    }
153}
154
155impl From<SignBytesVersion> for SignBytesMode {
156    fn from(v: SignBytesVersion) -> Self {
157        match v {
158            SignBytesVersion::V1 => SignBytesMode::V1,
159            SignBytesVersion::V2 => SignBytesMode::V2,
160        }
161    }
162}
163
164impl fmt::Display for SignBytesMode {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.write_str(match self {
167            SignBytesMode::Auto => "auto",
168            SignBytesMode::V1 => "v1",
169            SignBytesMode::V2 => "v2",
170        })
171    }
172}
173
174impl FromStr for SignBytesMode {
175    type Err = Error;
176
177    /// Parses `"auto"`, `"v1"` or `"v2"` (the empty string means `"auto"`, as in
178    /// the chain CLI's `--sign-bytes` flag).
179    fn from_str(s: &str) -> Result<Self> {
180        match s {
181            "" | "auto" => Ok(SignBytesMode::Auto),
182            "v1" => Ok(SignBytesMode::V1),
183            "v2" => Ok(SignBytesMode::V2),
184            other => Err(Error::SignBytes(format!(
185                "sign-bytes mode must be auto, v1 or v2, got {other:?}"
186            ))),
187        }
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Version selection
193// ---------------------------------------------------------------------------
194
195/// Whether `chain_id` is one of the [`LEGACY_SIGN_BYTES_CHAINS`].
196pub fn is_legacy_sign_bytes_chain(chain_id: &str) -> bool {
197    LEGACY_SIGN_BYTES_CHAINS.contains(&chain_id)
198}
199
200/// The form a client must sign for `chain_id`, given the height at which the v2
201/// upgrade was applied there (`0` when it has not been under any of
202/// [`SIGN_BYTES_V2_UPGRADES`]).
203///
204/// Mirrors the chain's `SignBytesVersionFor`: v2 when the upgrade is applied or
205/// the chain is not a legacy chain, v1 otherwise.
206pub fn sign_bytes_version_for(chain_id: &str, v2_applied_height: i64) -> SignBytesVersion {
207    if v2_applied_height > 0 || !is_legacy_sign_bytes_chain(chain_id) {
208        SignBytesVersion::V2
209    } else {
210        SignBytesVersion::V1
211    }
212}
213
214/// The version a synchronous (network-free) builder uses: `version` when given;
215/// otherwise v2 for a non-legacy chain. A legacy chain with no version is an
216/// error — the builder cannot know whether the upgrade is applied, and it never
217/// silently falls back to v1.
218pub fn require_sign_bytes_version(
219    chain_id: &str,
220    version: Option<SignBytesVersion>,
221) -> Result<SignBytesVersion> {
222    match version {
223        Some(v) => Ok(v),
224        None if !is_legacy_sign_bytes_chain(chain_id) => Ok(SignBytesVersion::V2),
225        None => Err(Error::SignBytes(format!(
226            "chain {chain_id:?} verifies hybrid sign-bytes v1 until upgrade {} is applied \
227             and v2 after it, so the version cannot be chosen offline: pass an explicit \
228             sign-bytes version (v1 or v2), or resolve one with SignBytesResolver / an async \
229             sign-and-broadcast path given a REST URL",
230            upgrade_names()
231        ))),
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Hybrid tx sign-bytes
237// ---------------------------------------------------------------------------
238
239/// v1 hybrid sign-bytes: `BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A`.
240pub fn hybrid_sign_bytes_v1(body_without_pqc_ext: &[u8], auth_info: &[u8]) -> Vec<u8> {
241    let mut out = Vec::with_capacity(8 + body_without_pqc_ext.len() + auth_info.len());
242    push_be32_prefixed(&mut out, body_without_pqc_ext);
243    push_be32_prefixed(&mut out, auth_info);
244    out
245}
246
247/// v2 hybrid sign-bytes: `"qorechain-pqc-hybrid-v2" ‖ BE64(len chainID) ‖
248/// chainID ‖ BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A`.
249pub fn hybrid_sign_bytes_v2(
250    chain_id: &str,
251    body_without_pqc_ext: &[u8],
252    auth_info: &[u8],
253) -> Vec<u8> {
254    let mut out = Vec::with_capacity(
255        HYBRID_SIGN_BYTES_DOMAIN.len()
256            + 8
257            + chain_id.len()
258            + 8
259            + body_without_pqc_ext.len()
260            + auth_info.len(),
261    );
262    out.extend_from_slice(HYBRID_SIGN_BYTES_DOMAIN.as_bytes());
263    push_be64_prefixed(&mut out, chain_id.as_bytes());
264    push_be32_prefixed(&mut out, body_without_pqc_ext);
265    push_be32_prefixed(&mut out, auth_info);
266    out
267}
268
269/// The hybrid sign-bytes in the given form (`chain_id` is ignored by v1).
270pub fn hybrid_sign_bytes(
271    version: SignBytesVersion,
272    chain_id: &str,
273    body_without_pqc_ext: &[u8],
274    auth_info: &[u8],
275) -> Vec<u8> {
276    match version {
277        SignBytesVersion::V1 => hybrid_sign_bytes_v1(body_without_pqc_ext, auth_info),
278        SignBytesVersion::V2 => hybrid_sign_bytes_v2(chain_id, body_without_pqc_ext, auth_info),
279    }
280}
281
282// ---------------------------------------------------------------------------
283// Key-migration sign-bytes
284// ---------------------------------------------------------------------------
285
286/// The fields both keys sign in an algorithm migration (`MsgMigratePQCKey`).
287#[derive(Debug, Clone, Copy)]
288pub struct MigrationSignFields<'a> {
289    /// The chain id.
290    pub chain_id: &'a str,
291    /// The bech32 account being migrated.
292    pub account: &'a str,
293    /// The algorithm id migrated from.
294    pub from_algorithm_id: u32,
295    /// The algorithm id migrated to.
296    pub to_algorithm_id: u32,
297    /// The execution height (learned from the chain; stops later replay).
298    pub execution_height: i64,
299    /// The old public key (bound by v2 only).
300    pub old_public_key: &'a [u8],
301    /// The new public key (bound by v2 only).
302    pub new_public_key: &'a [u8],
303}
304
305/// v1 (legacy) migration sign-bytes: the ASCII string
306/// `qorechain-key-migration:chain=<chain>:from=<from>:to=<to>:account=<account>:height=<height>`.
307/// The public keys are NOT bound by this form.
308pub fn migration_sign_bytes_v1(f: &MigrationSignFields<'_>) -> Vec<u8> {
309    format!(
310        "qorechain-key-migration:chain={}:from={}:to={}:account={}:height={}",
311        f.chain_id, f.from_algorithm_id, f.to_algorithm_id, f.account, f.execution_height
312    )
313    .into_bytes()
314}
315
316/// v2 migration sign-bytes: `"qorechain-key-migration-v2" ‖ BE64(len chainID) ‖
317/// chainID ‖ BE64(len account) ‖ account ‖ BE32(from) ‖ BE32(to) ‖ BE64(height) ‖
318/// BE32(len oldPub) ‖ oldPub ‖ BE32(len newPub) ‖ newPub`.
319pub fn migration_sign_bytes_v2(f: &MigrationSignFields<'_>) -> Vec<u8> {
320    let mut out = Vec::with_capacity(
321        MIGRATION_SIGN_BYTES_DOMAIN.len()
322            + 8
323            + f.chain_id.len()
324            + 8
325            + f.account.len()
326            + 16
327            + 8
328            + f.old_public_key.len()
329            + f.new_public_key.len(),
330    );
331    out.extend_from_slice(MIGRATION_SIGN_BYTES_DOMAIN.as_bytes());
332    push_be64_prefixed(&mut out, f.chain_id.as_bytes());
333    push_be64_prefixed(&mut out, f.account.as_bytes());
334    out.extend_from_slice(&f.from_algorithm_id.to_be_bytes());
335    out.extend_from_slice(&f.to_algorithm_id.to_be_bytes());
336    // The chain encodes the int64 height as uint64 (two's complement).
337    out.extend_from_slice(&(f.execution_height as u64).to_be_bytes());
338    push_be32_prefixed(&mut out, f.old_public_key);
339    push_be32_prefixed(&mut out, f.new_public_key);
340    out
341}
342
343/// The migration sign-bytes in the given form.
344pub fn migration_sign_bytes(version: SignBytesVersion, f: &MigrationSignFields<'_>) -> Vec<u8> {
345    match version {
346        SignBytesVersion::V1 => migration_sign_bytes_v1(f),
347        SignBytesVersion::V2 => migration_sign_bytes_v2(f),
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Bridge-attestation sign-bytes
353// ---------------------------------------------------------------------------
354
355/// The operation fields a bridge validator attests (`MsgBridgeAttestation`).
356#[derive(Debug, Clone, Copy)]
357pub struct BridgeAttestationSignFields<'a> {
358    /// The external chain name (e.g. `"ethereum"`).
359    pub chain: &'a str,
360    /// The event type (e.g. `"deposit"`).
361    pub event_type: &'a str,
362    /// The bridge operation id.
363    pub operation_id: &'a str,
364    /// The external tx hash.
365    pub tx_hash: &'a str,
366    /// The amount, as the chain's integer string (e.g. `"1000000"`).
367    pub amount: &'a str,
368    /// The asset denom.
369    pub asset: &'a str,
370}
371
372/// v1 (legacy) attestation sign-bytes: the pipe-joined ASCII
373/// `chain|eventType|operationID|txHash|amount|asset` (no chain id bound).
374pub fn bridge_attestation_sign_bytes_v1(f: &BridgeAttestationSignFields<'_>) -> Vec<u8> {
375    format!(
376        "{}|{}|{}|{}|{}|{}",
377        f.chain, f.event_type, f.operation_id, f.tx_hash, f.amount, f.asset
378    )
379    .into_bytes()
380}
381
382/// v2 attestation sign-bytes: `"qorechain-bridge-attestation-v2"` then
383/// `BE64(len f) ‖ f` for each of `[chainID, chain, eventType, operationID,
384/// txHash, amount, asset]`.
385pub fn bridge_attestation_sign_bytes_v2(
386    chain_id: &str,
387    f: &BridgeAttestationSignFields<'_>,
388) -> Vec<u8> {
389    let fields = [
390        chain_id,
391        f.chain,
392        f.event_type,
393        f.operation_id,
394        f.tx_hash,
395        f.amount,
396        f.asset,
397    ];
398    let mut out = Vec::with_capacity(
399        BRIDGE_ATTESTATION_SIGN_BYTES_DOMAIN.len()
400            + fields.iter().map(|s| 8 + s.len()).sum::<usize>(),
401    );
402    out.extend_from_slice(BRIDGE_ATTESTATION_SIGN_BYTES_DOMAIN.as_bytes());
403    for field in fields {
404        push_be64_prefixed(&mut out, field.as_bytes());
405    }
406    out
407}
408
409/// The attestation sign-bytes in the given form (`chain_id` is ignored by v1).
410pub fn bridge_attestation_sign_bytes(
411    version: SignBytesVersion,
412    chain_id: &str,
413    f: &BridgeAttestationSignFields<'_>,
414) -> Vec<u8> {
415    match version {
416        SignBytesVersion::V1 => bridge_attestation_sign_bytes_v1(f),
417        SignBytesVersion::V2 => bridge_attestation_sign_bytes_v2(chain_id, f),
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Resolver
423// ---------------------------------------------------------------------------
424
425/// Resolves [`SignBytesMode::Auto`] to a concrete version per network, caching
426/// each answer per `(rest_url, chain_id)` for a TTL (default
427/// [`DEFAULT_SIGN_BYTES_CACHE_TTL`]).
428///
429/// For a non-legacy chain the answer is v2 with no network call. For a legacy
430/// chain it asks `GET {rest_url}/cosmos/upgrade/v1beta1/applied_plan/{name}`
431/// (`{"height":"<n>"}`; a missing height means `0`) for every name in
432/// [`SIGN_BYTES_V2_UPGRADES`], stopping at the first positive height, and
433/// applies [`sign_bytes_version_for`]. Mainnet therefore costs one request once
434/// it has upgraded, and the testnet two.
435#[derive(Debug)]
436pub struct SignBytesResolver {
437    http: reqwest::Client,
438    ttl: Duration,
439    cache: Mutex<HashMap<(String, String), (SignBytesVersion, Instant)>>,
440}
441
442impl Default for SignBytesResolver {
443    fn default() -> Self {
444        Self::new()
445    }
446}
447
448impl SignBytesResolver {
449    /// A resolver with a fresh HTTP client and the default TTL.
450    pub fn new() -> Self {
451        Self::with_client(reqwest::Client::new())
452    }
453
454    /// A resolver using the supplied HTTP client and the default TTL.
455    pub fn with_client(http: reqwest::Client) -> Self {
456        Self {
457            http,
458            ttl: DEFAULT_SIGN_BYTES_CACHE_TTL,
459            cache: Mutex::new(HashMap::new()),
460        }
461    }
462
463    /// Sets the cache TTL (`Duration::ZERO` disables caching).
464    pub fn with_ttl(mut self, ttl: Duration) -> Self {
465        self.ttl = ttl;
466        self
467    }
468
469    /// The configured cache TTL.
470    pub fn ttl(&self) -> Duration {
471        self.ttl
472    }
473
474    /// Resolves `mode` for `chain_id`.
475    ///
476    /// - `V1` / `V2` → returned as-is, no network.
477    /// - `Auto` on a non-legacy chain → v2, no network.
478    /// - `Auto` on a legacy chain → the cached answer for `(rest_url, chain_id)`
479    ///   when fresh, else the node is asked. No `rest_url`, or a failed query, is
480    ///   an error (never a guess).
481    pub async fn resolve(
482        &self,
483        mode: SignBytesMode,
484        chain_id: &str,
485        rest_url: Option<&str>,
486    ) -> Result<SignBytesVersion> {
487        if let Some(v) = mode.fixed() {
488            return Ok(v);
489        }
490        if !is_legacy_sign_bytes_chain(chain_id) {
491            return Ok(SignBytesVersion::V2);
492        }
493        let rest_url = require_rest_url(chain_id, rest_url)?;
494        if let Some(v) = self.cached(rest_url, chain_id) {
495            return Ok(v);
496        }
497        self.fetch_and_store(chain_id, rest_url).await
498    }
499
500    /// Re-asks the node for `chain_id`, bypassing (and then refreshing) the
501    /// cache. A non-legacy chain still resolves to v2 without a network call.
502    pub async fn force_refresh(
503        &self,
504        chain_id: &str,
505        rest_url: Option<&str>,
506    ) -> Result<SignBytesVersion> {
507        if !is_legacy_sign_bytes_chain(chain_id) {
508            return Ok(SignBytesVersion::V2);
509        }
510        let rest_url = require_rest_url(chain_id, rest_url)?;
511        self.invalidate(rest_url, chain_id);
512        self.fetch_and_store(chain_id, rest_url).await
513    }
514
515    /// Drops the cached answer for `(rest_url, chain_id)`.
516    pub fn invalidate(&self, rest_url: &str, chain_id: &str) {
517        self.lock_cache().remove(&cache_key(rest_url, chain_id));
518    }
519
520    /// Drops every cached answer.
521    pub fn clear_cache(&self) {
522        self.lock_cache().clear();
523    }
524
525    /// Asks `rest_url` for the height at which ONE upgrade name was applied
526    /// (`0` when it has not been, or when the node answers `{}`).
527    ///
528    /// `plan_name` defaults to [`SIGN_BYTES_V2_UPGRADE`] when `None`. The
529    /// resolver itself uses [`SignBytesResolver::fetch_v2_applied_height_any`],
530    /// which covers every name the switch shipped under.
531    pub async fn fetch_v2_applied_height(
532        &self,
533        rest_url: &str,
534        plan_name: Option<&str>,
535    ) -> Result<i64> {
536        let plan_name = plan_name.unwrap_or(SIGN_BYTES_V2_UPGRADE);
537        let rest = RestClient::with_client(rest_url, self.http.clone());
538        let path = format!("/cosmos/upgrade/v1beta1/applied_plan/{plan_name}");
539        let body = rest.get(&path, &[]).await.map_err(|e| {
540            Error::SignBytes(format!(
541                "cannot ask {rest_url} whether upgrade {plan_name} is applied (the v2 \
542                 sign-bytes upgrade ships as {}) ({e}); pass an explicit sign-bytes version \
543                 (v1 or v2)",
544                upgrade_names()
545            ))
546        })?;
547        parse_applied_height(&body)
548    }
549
550    /// The height at which the v2 switch was applied on `rest_url`'s chain,
551    /// under ANY of [`SIGN_BYTES_V2_UPGRADES`] (`0` when under none of them).
552    ///
553    /// The names are asked in order and the first positive height is returned
554    /// without asking the rest, so a network that upgraded under the primary
555    /// name costs a single request. Any failed query is an error, never a guess.
556    pub async fn fetch_v2_applied_height_any(&self, rest_url: &str) -> Result<i64> {
557        for plan_name in SIGN_BYTES_V2_UPGRADES {
558            let height = self
559                .fetch_v2_applied_height(rest_url, Some(plan_name))
560                .await?;
561            if height > 0 {
562                return Ok(height);
563            }
564        }
565        Ok(0)
566    }
567
568    async fn fetch_and_store(&self, chain_id: &str, rest_url: &str) -> Result<SignBytesVersion> {
569        let height = self.fetch_v2_applied_height_any(rest_url).await?;
570        let v = sign_bytes_version_for(chain_id, height);
571        if !self.ttl.is_zero() {
572            self.lock_cache()
573                .insert(cache_key(rest_url, chain_id), (v, Instant::now()));
574        }
575        Ok(v)
576    }
577
578    fn cached(&self, rest_url: &str, chain_id: &str) -> Option<SignBytesVersion> {
579        let key = cache_key(rest_url, chain_id);
580        let mut cache = self.lock_cache();
581        match cache.get(&key) {
582            Some((v, at)) if at.elapsed() < self.ttl => Some(*v),
583            Some(_) => {
584                cache.remove(&key);
585                None
586            }
587            None => None,
588        }
589    }
590
591    fn lock_cache(
592        &self,
593    ) -> std::sync::MutexGuard<'_, HashMap<(String, String), (SignBytesVersion, Instant)>> {
594        // A poisoned lock only means another thread panicked mid-insert; the map
595        // itself is still a valid cache.
596        self.cache.lock().unwrap_or_else(|p| p.into_inner())
597    }
598}
599
600/// The process-wide resolver used by the high-level sign-and-broadcast paths.
601pub fn default_sign_bytes_resolver() -> &'static SignBytesResolver {
602    static RESOLVER: OnceLock<SignBytesResolver> = OnceLock::new();
603    RESOLVER.get_or_init(SignBytesResolver::new)
604}
605
606/// Resolves `mode` with [`default_sign_bytes_resolver`].
607pub async fn resolve_sign_bytes_version(
608    mode: SignBytesMode,
609    chain_id: &str,
610    rest_url: Option<&str>,
611) -> Result<SignBytesVersion> {
612    default_sign_bytes_resolver()
613        .resolve(mode, chain_id, rest_url)
614        .await
615}
616
617/// Clears the cache of [`default_sign_bytes_resolver`].
618pub fn clear_sign_bytes_cache() {
619    default_sign_bytes_resolver().clear_cache();
620}
621
622/// Parses the `applied_plan` response: `{"height":"<n>"}` (string or number);
623/// a missing / null height is `0`.
624pub fn parse_applied_height(body: &Value) -> Result<i64> {
625    match body.get("height") {
626        None | Some(Value::Null) => Ok(0),
627        Some(Value::String(s)) if s.is_empty() => Ok(0),
628        Some(Value::String(s)) => s
629            .trim()
630            .parse::<i64>()
631            .map_err(|_| Error::SignBytes(format!("applied_plan height is not an integer: {s:?}"))),
632        Some(Value::Number(n)) => n
633            .as_i64()
634            .ok_or_else(|| Error::SignBytes(format!("applied_plan height is not an integer: {n}"))),
635        Some(other) => Err(Error::SignBytes(format!(
636            "applied_plan height has an unexpected type: {other}"
637        ))),
638    }
639}
640
641// ---------------------------------------------------------------------------
642// Rejection detection + one-shot retry
643// ---------------------------------------------------------------------------
644
645/// Whether an ABCI `(codespace, code, log)` is the chain refusing a hybrid PQC
646/// signature — `pqc` code 21, or a log carrying the chain's message for it.
647/// Code 21 from any other codespace is NOT this case.
648pub fn is_hybrid_sign_bytes_rejection(codespace: &str, code: u32, log: &str) -> bool {
649    (codespace == PQC_CODESPACE && code == PQC_HYBRID_VERIFY_FAILED_CODE)
650        || log.contains(PQC_HYBRID_VERIFY_FAILED_MESSAGE)
651}
652
653/// [`is_hybrid_sign_bytes_rejection`] over a REST broadcast response (the
654/// `tx_response` object, or a top-level `{code, codespace, raw_log|message}`).
655pub fn is_hybrid_sign_bytes_rejection_response(resp: &Value) -> bool {
656    [resp.get("tx_response"), Some(resp)]
657        .into_iter()
658        .flatten()
659        .any(|obj| {
660            let code = obj
661                .get("code")
662                .and_then(Value::as_u64)
663                .and_then(|c| u32::try_from(c).ok())
664                .unwrap_or(0);
665            let codespace = obj.get("codespace").and_then(Value::as_str).unwrap_or("");
666            let log = ["raw_log", "log", "message"]
667                .iter()
668                .filter_map(|k| obj.get(*k).and_then(Value::as_str))
669                .collect::<Vec<_>>()
670                .join("\n");
671            is_hybrid_sign_bytes_rejection(codespace, code, &log)
672        })
673}
674
675/// [`is_hybrid_sign_bytes_rejection`] over an SDK [`Error`].
676pub fn is_hybrid_sign_bytes_rejection_error(err: &Error) -> bool {
677    match err {
678        Error::Tx(e) => is_hybrid_sign_bytes_rejection(
679            &e.codespace,
680            e.code,
681            &format!("{}\n{}", e.raw_log, e.reason),
682        ),
683        Error::Http { body, .. } => match serde_json::from_str::<Value>(body) {
684            Ok(v) => is_hybrid_sign_bytes_rejection_response(&v),
685            Err(_) => body.contains(PQC_HYBRID_VERIFY_FAILED_MESSAGE),
686        },
687        Error::JsonRpc { message, .. } => message.contains(PQC_HYBRID_VERIFY_FAILED_MESSAGE),
688        _ => false,
689    }
690}
691
692/// The outcome of a hybrid sign-and-broadcast.
693#[derive(Debug, Clone)]
694pub struct HybridBroadcast {
695    /// The broadcast response JSON of the last attempt.
696    pub response: Value,
697    /// The sign-bytes version the last attempt was signed with.
698    pub sign_bytes_version: SignBytesVersion,
699    /// Whether the first attempt was refused with `pqc` code 21 and re-signed.
700    pub retried: bool,
701}
702
703/// Resolves the version, builds with `build`, sends with `send`, and — only when
704/// `mode` is [`SignBytesMode::Auto`] and the send is refused as a hybrid PQC
705/// signature failure (see [`is_hybrid_sign_bytes_rejection`]) — force-refreshes
706/// the version, rebuilds and sends exactly once more. The second attempt's
707/// outcome is returned as-is.
708///
709/// `build` returns the tx bytes for a given version; `send` broadcasts them
710/// (e.g. [`crate::tx::broadcast`]). This is the transport-agnostic core of the
711/// high-level hybrid send paths.
712pub async fn broadcast_with_sign_bytes_retry<B, S, Fut>(
713    resolver: &SignBytesResolver,
714    mode: SignBytesMode,
715    chain_id: &str,
716    rest_url: Option<&str>,
717    mut build: B,
718    mut send: S,
719) -> Result<HybridBroadcast>
720where
721    B: FnMut(SignBytesVersion) -> Result<Vec<u8>>,
722    S: FnMut(Vec<u8>) -> Fut,
723    Fut: Future<Output = Result<Value>>,
724{
725    let version = resolver.resolve(mode, chain_id, rest_url).await?;
726    let first = send(build(version)?).await;
727    let refused = match &first {
728        Ok(resp) => is_hybrid_sign_bytes_rejection_response(resp),
729        Err(e) => is_hybrid_sign_bytes_rejection_error(e),
730    };
731    if mode != SignBytesMode::Auto || !refused {
732        return first.map(|response| HybridBroadcast {
733            response,
734            sign_bytes_version: version,
735            retried: false,
736        });
737    }
738    let version = resolver.force_refresh(chain_id, rest_url).await?;
739    let response = send(build(version)?).await?;
740    Ok(HybridBroadcast {
741        response,
742        sign_bytes_version: version,
743        retried: true,
744    })
745}
746
747// ---------------------------------------------------------------------------
748// internal helpers
749// ---------------------------------------------------------------------------
750
751fn push_be32_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
752    out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
753    out.extend_from_slice(bytes);
754}
755
756fn push_be64_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
757    out.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
758    out.extend_from_slice(bytes);
759}
760
761fn cache_key(rest_url: &str, chain_id: &str) -> (String, String) {
762    (
763        rest_url.trim_end_matches('/').to_string(),
764        chain_id.to_string(),
765    )
766}
767
768fn require_rest_url<'a>(chain_id: &str, rest_url: Option<&'a str>) -> Result<&'a str> {
769    match rest_url {
770        Some(u) if !u.trim().is_empty() => Ok(u),
771        _ => Err(Error::SignBytes(format!(
772            "chain {chain_id:?} verifies hybrid sign-bytes v1 until upgrade {} is applied \
773             and v2 after it; to choose, the SDK must ask a node — pass a REST URL, or an \
774             explicit sign-bytes version (v1 or v2)",
775            upgrade_names()
776        ))),
777    }
778}
779
780/// [`SIGN_BYTES_V2_UPGRADES`] as `"v3.2.0 or v3.1.98"`, for error messages.
781fn upgrade_names() -> String {
782    SIGN_BYTES_V2_UPGRADES.join(" or ")
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use serde_json::json;
789
790    #[test]
791    fn mode_and_version_parse() {
792        assert_eq!(
793            "auto".parse::<SignBytesMode>().unwrap(),
794            SignBytesMode::Auto
795        );
796        assert_eq!("".parse::<SignBytesMode>().unwrap(), SignBytesMode::Auto);
797        assert_eq!("v1".parse::<SignBytesMode>().unwrap(), SignBytesMode::V1);
798        assert_eq!("v2".parse::<SignBytesMode>().unwrap(), SignBytesMode::V2);
799        assert!("v3".parse::<SignBytesMode>().is_err());
800        assert_eq!(
801            "v2".parse::<SignBytesVersion>().unwrap(),
802            SignBytesVersion::V2
803        );
804        assert!("auto".parse::<SignBytesVersion>().is_err());
805        assert_eq!(SignBytesMode::default(), SignBytesMode::Auto);
806        assert_eq!(SignBytesVersion::V1.number(), 1);
807        assert_eq!(SignBytesVersion::V2.to_string(), "v2");
808    }
809
810    #[test]
811    fn applied_height_parsing() {
812        assert_eq!(
813            parse_applied_height(&json!({"height": "5746000"})).unwrap(),
814            5_746_000
815        );
816        assert_eq!(parse_applied_height(&json!({"height": "0"})).unwrap(), 0);
817        assert_eq!(parse_applied_height(&json!({})).unwrap(), 0);
818        assert_eq!(parse_applied_height(&json!({"height": 12})).unwrap(), 12);
819        assert!(parse_applied_height(&json!({"height": "abc"})).is_err());
820    }
821
822    #[test]
823    fn require_version_fails_loudly_on_legacy_chain() {
824        assert_eq!(
825            require_sign_bytes_version("qorechain-new", None).unwrap(),
826            SignBytesVersion::V2
827        );
828        assert_eq!(
829            require_sign_bytes_version("qorechain-vladi", Some(SignBytesVersion::V1)).unwrap(),
830            SignBytesVersion::V1
831        );
832        let err = require_sign_bytes_version("qorechain-vladi", None).unwrap_err();
833        assert!(matches!(err, Error::SignBytes(_)), "{err:?}");
834    }
835}