use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::str::FromStr;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::error::{Error, Result};
use crate::query::RestClient;
pub const HYBRID_SIGN_BYTES_DOMAIN: &str = "qorechain-pqc-hybrid-v2";
pub const MIGRATION_SIGN_BYTES_DOMAIN: &str = "qorechain-key-migration-v2";
pub const BRIDGE_ATTESTATION_SIGN_BYTES_DOMAIN: &str = "qorechain-bridge-attestation-v2";
pub const SIGN_BYTES_V2_UPGRADE: &str = "v3.2.0";
pub const SIGN_BYTES_V2_UPGRADES: &[&str] = &["v3.2.0", "v3.1.98"];
pub const LEGACY_SIGN_BYTES_CHAINS: &[&str] = &["qorechain-vladi", "qorechain-diana"];
pub const DEFAULT_SIGN_BYTES_CACHE_TTL: Duration = Duration::from_secs(60);
pub const PQC_CODESPACE: &str = "pqc";
pub const PQC_HYBRID_VERIFY_FAILED_CODE: u32 = 21;
pub const PQC_HYBRID_VERIFY_FAILED_MESSAGE: &str = "hybrid PQC signature verification failed";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SignBytesVersion {
V1,
V2,
}
impl SignBytesVersion {
pub fn number(self) -> u8 {
match self {
SignBytesVersion::V1 => 1,
SignBytesVersion::V2 => 2,
}
}
pub fn as_str(self) -> &'static str {
match self {
SignBytesVersion::V1 => "v1",
SignBytesVersion::V2 => "v2",
}
}
}
impl fmt::Display for SignBytesVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for SignBytesVersion {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"v1" => Ok(SignBytesVersion::V1),
"v2" => Ok(SignBytesVersion::V2),
other => Err(Error::SignBytes(format!(
"sign-bytes version must be v1 or v2, got {other:?}"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SignBytesMode {
#[default]
Auto,
V1,
V2,
}
impl SignBytesMode {
pub fn fixed(self) -> Option<SignBytesVersion> {
match self {
SignBytesMode::Auto => None,
SignBytesMode::V1 => Some(SignBytesVersion::V1),
SignBytesMode::V2 => Some(SignBytesVersion::V2),
}
}
}
impl From<SignBytesVersion> for SignBytesMode {
fn from(v: SignBytesVersion) -> Self {
match v {
SignBytesVersion::V1 => SignBytesMode::V1,
SignBytesVersion::V2 => SignBytesMode::V2,
}
}
}
impl fmt::Display for SignBytesMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
SignBytesMode::Auto => "auto",
SignBytesMode::V1 => "v1",
SignBytesMode::V2 => "v2",
})
}
}
impl FromStr for SignBytesMode {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"" | "auto" => Ok(SignBytesMode::Auto),
"v1" => Ok(SignBytesMode::V1),
"v2" => Ok(SignBytesMode::V2),
other => Err(Error::SignBytes(format!(
"sign-bytes mode must be auto, v1 or v2, got {other:?}"
))),
}
}
}
pub fn is_legacy_sign_bytes_chain(chain_id: &str) -> bool {
LEGACY_SIGN_BYTES_CHAINS.contains(&chain_id)
}
pub fn sign_bytes_version_for(chain_id: &str, v2_applied_height: i64) -> SignBytesVersion {
if v2_applied_height > 0 || !is_legacy_sign_bytes_chain(chain_id) {
SignBytesVersion::V2
} else {
SignBytesVersion::V1
}
}
pub fn require_sign_bytes_version(
chain_id: &str,
version: Option<SignBytesVersion>,
) -> Result<SignBytesVersion> {
match version {
Some(v) => Ok(v),
None if !is_legacy_sign_bytes_chain(chain_id) => Ok(SignBytesVersion::V2),
None => Err(Error::SignBytes(format!(
"chain {chain_id:?} verifies hybrid sign-bytes v1 until upgrade {} is applied \
and v2 after it, so the version cannot be chosen offline: pass an explicit \
sign-bytes version (v1 or v2), or resolve one with SignBytesResolver / an async \
sign-and-broadcast path given a REST URL",
upgrade_names()
))),
}
}
pub fn hybrid_sign_bytes_v1(body_without_pqc_ext: &[u8], auth_info: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + body_without_pqc_ext.len() + auth_info.len());
push_be32_prefixed(&mut out, body_without_pqc_ext);
push_be32_prefixed(&mut out, auth_info);
out
}
pub fn hybrid_sign_bytes_v2(
chain_id: &str,
body_without_pqc_ext: &[u8],
auth_info: &[u8],
) -> Vec<u8> {
let mut out = Vec::with_capacity(
HYBRID_SIGN_BYTES_DOMAIN.len()
+ 8
+ chain_id.len()
+ 8
+ body_without_pqc_ext.len()
+ auth_info.len(),
);
out.extend_from_slice(HYBRID_SIGN_BYTES_DOMAIN.as_bytes());
push_be64_prefixed(&mut out, chain_id.as_bytes());
push_be32_prefixed(&mut out, body_without_pqc_ext);
push_be32_prefixed(&mut out, auth_info);
out
}
pub fn hybrid_sign_bytes(
version: SignBytesVersion,
chain_id: &str,
body_without_pqc_ext: &[u8],
auth_info: &[u8],
) -> Vec<u8> {
match version {
SignBytesVersion::V1 => hybrid_sign_bytes_v1(body_without_pqc_ext, auth_info),
SignBytesVersion::V2 => hybrid_sign_bytes_v2(chain_id, body_without_pqc_ext, auth_info),
}
}
#[derive(Debug, Clone, Copy)]
pub struct MigrationSignFields<'a> {
pub chain_id: &'a str,
pub account: &'a str,
pub from_algorithm_id: u32,
pub to_algorithm_id: u32,
pub execution_height: i64,
pub old_public_key: &'a [u8],
pub new_public_key: &'a [u8],
}
pub fn migration_sign_bytes_v1(f: &MigrationSignFields<'_>) -> Vec<u8> {
format!(
"qorechain-key-migration:chain={}:from={}:to={}:account={}:height={}",
f.chain_id, f.from_algorithm_id, f.to_algorithm_id, f.account, f.execution_height
)
.into_bytes()
}
pub fn migration_sign_bytes_v2(f: &MigrationSignFields<'_>) -> Vec<u8> {
let mut out = Vec::with_capacity(
MIGRATION_SIGN_BYTES_DOMAIN.len()
+ 8
+ f.chain_id.len()
+ 8
+ f.account.len()
+ 16
+ 8
+ f.old_public_key.len()
+ f.new_public_key.len(),
);
out.extend_from_slice(MIGRATION_SIGN_BYTES_DOMAIN.as_bytes());
push_be64_prefixed(&mut out, f.chain_id.as_bytes());
push_be64_prefixed(&mut out, f.account.as_bytes());
out.extend_from_slice(&f.from_algorithm_id.to_be_bytes());
out.extend_from_slice(&f.to_algorithm_id.to_be_bytes());
out.extend_from_slice(&(f.execution_height as u64).to_be_bytes());
push_be32_prefixed(&mut out, f.old_public_key);
push_be32_prefixed(&mut out, f.new_public_key);
out
}
pub fn migration_sign_bytes(version: SignBytesVersion, f: &MigrationSignFields<'_>) -> Vec<u8> {
match version {
SignBytesVersion::V1 => migration_sign_bytes_v1(f),
SignBytesVersion::V2 => migration_sign_bytes_v2(f),
}
}
#[derive(Debug, Clone, Copy)]
pub struct BridgeAttestationSignFields<'a> {
pub chain: &'a str,
pub event_type: &'a str,
pub operation_id: &'a str,
pub tx_hash: &'a str,
pub amount: &'a str,
pub asset: &'a str,
}
pub fn bridge_attestation_sign_bytes_v1(f: &BridgeAttestationSignFields<'_>) -> Vec<u8> {
format!(
"{}|{}|{}|{}|{}|{}",
f.chain, f.event_type, f.operation_id, f.tx_hash, f.amount, f.asset
)
.into_bytes()
}
pub fn bridge_attestation_sign_bytes_v2(
chain_id: &str,
f: &BridgeAttestationSignFields<'_>,
) -> Vec<u8> {
let fields = [
chain_id,
f.chain,
f.event_type,
f.operation_id,
f.tx_hash,
f.amount,
f.asset,
];
let mut out = Vec::with_capacity(
BRIDGE_ATTESTATION_SIGN_BYTES_DOMAIN.len()
+ fields.iter().map(|s| 8 + s.len()).sum::<usize>(),
);
out.extend_from_slice(BRIDGE_ATTESTATION_SIGN_BYTES_DOMAIN.as_bytes());
for field in fields {
push_be64_prefixed(&mut out, field.as_bytes());
}
out
}
pub fn bridge_attestation_sign_bytes(
version: SignBytesVersion,
chain_id: &str,
f: &BridgeAttestationSignFields<'_>,
) -> Vec<u8> {
match version {
SignBytesVersion::V1 => bridge_attestation_sign_bytes_v1(f),
SignBytesVersion::V2 => bridge_attestation_sign_bytes_v2(chain_id, f),
}
}
#[derive(Debug)]
pub struct SignBytesResolver {
http: reqwest::Client,
ttl: Duration,
cache: Mutex<HashMap<(String, String), (SignBytesVersion, Instant)>>,
}
impl Default for SignBytesResolver {
fn default() -> Self {
Self::new()
}
}
impl SignBytesResolver {
pub fn new() -> Self {
Self::with_client(reqwest::Client::new())
}
pub fn with_client(http: reqwest::Client) -> Self {
Self {
http,
ttl: DEFAULT_SIGN_BYTES_CACHE_TTL,
cache: Mutex::new(HashMap::new()),
}
}
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
pub fn ttl(&self) -> Duration {
self.ttl
}
pub async fn resolve(
&self,
mode: SignBytesMode,
chain_id: &str,
rest_url: Option<&str>,
) -> Result<SignBytesVersion> {
if let Some(v) = mode.fixed() {
return Ok(v);
}
if !is_legacy_sign_bytes_chain(chain_id) {
return Ok(SignBytesVersion::V2);
}
let rest_url = require_rest_url(chain_id, rest_url)?;
if let Some(v) = self.cached(rest_url, chain_id) {
return Ok(v);
}
self.fetch_and_store(chain_id, rest_url).await
}
pub async fn force_refresh(
&self,
chain_id: &str,
rest_url: Option<&str>,
) -> Result<SignBytesVersion> {
if !is_legacy_sign_bytes_chain(chain_id) {
return Ok(SignBytesVersion::V2);
}
let rest_url = require_rest_url(chain_id, rest_url)?;
self.invalidate(rest_url, chain_id);
self.fetch_and_store(chain_id, rest_url).await
}
pub fn invalidate(&self, rest_url: &str, chain_id: &str) {
self.lock_cache().remove(&cache_key(rest_url, chain_id));
}
pub fn clear_cache(&self) {
self.lock_cache().clear();
}
pub async fn fetch_v2_applied_height(
&self,
rest_url: &str,
plan_name: Option<&str>,
) -> Result<i64> {
let plan_name = plan_name.unwrap_or(SIGN_BYTES_V2_UPGRADE);
let rest = RestClient::with_client(rest_url, self.http.clone());
let path = format!("/cosmos/upgrade/v1beta1/applied_plan/{plan_name}");
let body = rest.get(&path, &[]).await.map_err(|e| {
Error::SignBytes(format!(
"cannot ask {rest_url} whether upgrade {plan_name} is applied (the v2 \
sign-bytes upgrade ships as {}) ({e}); pass an explicit sign-bytes version \
(v1 or v2)",
upgrade_names()
))
})?;
parse_applied_height(&body)
}
pub async fn fetch_v2_applied_height_any(&self, rest_url: &str) -> Result<i64> {
for plan_name in SIGN_BYTES_V2_UPGRADES {
let height = self
.fetch_v2_applied_height(rest_url, Some(plan_name))
.await?;
if height > 0 {
return Ok(height);
}
}
Ok(0)
}
async fn fetch_and_store(&self, chain_id: &str, rest_url: &str) -> Result<SignBytesVersion> {
let height = self.fetch_v2_applied_height_any(rest_url).await?;
let v = sign_bytes_version_for(chain_id, height);
if !self.ttl.is_zero() {
self.lock_cache()
.insert(cache_key(rest_url, chain_id), (v, Instant::now()));
}
Ok(v)
}
fn cached(&self, rest_url: &str, chain_id: &str) -> Option<SignBytesVersion> {
let key = cache_key(rest_url, chain_id);
let mut cache = self.lock_cache();
match cache.get(&key) {
Some((v, at)) if at.elapsed() < self.ttl => Some(*v),
Some(_) => {
cache.remove(&key);
None
}
None => None,
}
}
fn lock_cache(
&self,
) -> std::sync::MutexGuard<'_, HashMap<(String, String), (SignBytesVersion, Instant)>> {
self.cache.lock().unwrap_or_else(|p| p.into_inner())
}
}
pub fn default_sign_bytes_resolver() -> &'static SignBytesResolver {
static RESOLVER: OnceLock<SignBytesResolver> = OnceLock::new();
RESOLVER.get_or_init(SignBytesResolver::new)
}
pub async fn resolve_sign_bytes_version(
mode: SignBytesMode,
chain_id: &str,
rest_url: Option<&str>,
) -> Result<SignBytesVersion> {
default_sign_bytes_resolver()
.resolve(mode, chain_id, rest_url)
.await
}
pub fn clear_sign_bytes_cache() {
default_sign_bytes_resolver().clear_cache();
}
pub fn parse_applied_height(body: &Value) -> Result<i64> {
match body.get("height") {
None | Some(Value::Null) => Ok(0),
Some(Value::String(s)) if s.is_empty() => Ok(0),
Some(Value::String(s)) => s
.trim()
.parse::<i64>()
.map_err(|_| Error::SignBytes(format!("applied_plan height is not an integer: {s:?}"))),
Some(Value::Number(n)) => n
.as_i64()
.ok_or_else(|| Error::SignBytes(format!("applied_plan height is not an integer: {n}"))),
Some(other) => Err(Error::SignBytes(format!(
"applied_plan height has an unexpected type: {other}"
))),
}
}
pub fn is_hybrid_sign_bytes_rejection(codespace: &str, code: u32, log: &str) -> bool {
(codespace == PQC_CODESPACE && code == PQC_HYBRID_VERIFY_FAILED_CODE)
|| log.contains(PQC_HYBRID_VERIFY_FAILED_MESSAGE)
}
pub fn is_hybrid_sign_bytes_rejection_response(resp: &Value) -> bool {
[resp.get("tx_response"), Some(resp)]
.into_iter()
.flatten()
.any(|obj| {
let code = obj
.get("code")
.and_then(Value::as_u64)
.and_then(|c| u32::try_from(c).ok())
.unwrap_or(0);
let codespace = obj.get("codespace").and_then(Value::as_str).unwrap_or("");
let log = ["raw_log", "log", "message"]
.iter()
.filter_map(|k| obj.get(*k).and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
is_hybrid_sign_bytes_rejection(codespace, code, &log)
})
}
pub fn is_hybrid_sign_bytes_rejection_error(err: &Error) -> bool {
match err {
Error::Tx(e) => is_hybrid_sign_bytes_rejection(
&e.codespace,
e.code,
&format!("{}\n{}", e.raw_log, e.reason),
),
Error::Http { body, .. } => match serde_json::from_str::<Value>(body) {
Ok(v) => is_hybrid_sign_bytes_rejection_response(&v),
Err(_) => body.contains(PQC_HYBRID_VERIFY_FAILED_MESSAGE),
},
Error::JsonRpc { message, .. } => message.contains(PQC_HYBRID_VERIFY_FAILED_MESSAGE),
_ => false,
}
}
#[derive(Debug, Clone)]
pub struct HybridBroadcast {
pub response: Value,
pub sign_bytes_version: SignBytesVersion,
pub retried: bool,
}
pub async fn broadcast_with_sign_bytes_retry<B, S, Fut>(
resolver: &SignBytesResolver,
mode: SignBytesMode,
chain_id: &str,
rest_url: Option<&str>,
mut build: B,
mut send: S,
) -> Result<HybridBroadcast>
where
B: FnMut(SignBytesVersion) -> Result<Vec<u8>>,
S: FnMut(Vec<u8>) -> Fut,
Fut: Future<Output = Result<Value>>,
{
let version = resolver.resolve(mode, chain_id, rest_url).await?;
let first = send(build(version)?).await;
let refused = match &first {
Ok(resp) => is_hybrid_sign_bytes_rejection_response(resp),
Err(e) => is_hybrid_sign_bytes_rejection_error(e),
};
if mode != SignBytesMode::Auto || !refused {
return first.map(|response| HybridBroadcast {
response,
sign_bytes_version: version,
retried: false,
});
}
let version = resolver.force_refresh(chain_id, rest_url).await?;
let response = send(build(version)?).await?;
Ok(HybridBroadcast {
response,
sign_bytes_version: version,
retried: true,
})
}
fn push_be32_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
fn push_be64_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
out.extend_from_slice(bytes);
}
fn cache_key(rest_url: &str, chain_id: &str) -> (String, String) {
(
rest_url.trim_end_matches('/').to_string(),
chain_id.to_string(),
)
}
fn require_rest_url<'a>(chain_id: &str, rest_url: Option<&'a str>) -> Result<&'a str> {
match rest_url {
Some(u) if !u.trim().is_empty() => Ok(u),
_ => Err(Error::SignBytes(format!(
"chain {chain_id:?} verifies hybrid sign-bytes v1 until upgrade {} is applied \
and v2 after it; to choose, the SDK must ask a node — pass a REST URL, or an \
explicit sign-bytes version (v1 or v2)",
upgrade_names()
))),
}
}
fn upgrade_names() -> String {
SIGN_BYTES_V2_UPGRADES.join(" or ")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn mode_and_version_parse() {
assert_eq!(
"auto".parse::<SignBytesMode>().unwrap(),
SignBytesMode::Auto
);
assert_eq!("".parse::<SignBytesMode>().unwrap(), SignBytesMode::Auto);
assert_eq!("v1".parse::<SignBytesMode>().unwrap(), SignBytesMode::V1);
assert_eq!("v2".parse::<SignBytesMode>().unwrap(), SignBytesMode::V2);
assert!("v3".parse::<SignBytesMode>().is_err());
assert_eq!(
"v2".parse::<SignBytesVersion>().unwrap(),
SignBytesVersion::V2
);
assert!("auto".parse::<SignBytesVersion>().is_err());
assert_eq!(SignBytesMode::default(), SignBytesMode::Auto);
assert_eq!(SignBytesVersion::V1.number(), 1);
assert_eq!(SignBytesVersion::V2.to_string(), "v2");
}
#[test]
fn applied_height_parsing() {
assert_eq!(
parse_applied_height(&json!({"height": "5746000"})).unwrap(),
5_746_000
);
assert_eq!(parse_applied_height(&json!({"height": "0"})).unwrap(), 0);
assert_eq!(parse_applied_height(&json!({})).unwrap(), 0);
assert_eq!(parse_applied_height(&json!({"height": 12})).unwrap(), 12);
assert!(parse_applied_height(&json!({"height": "abc"})).is_err());
}
#[test]
fn require_version_fails_loudly_on_legacy_chain() {
assert_eq!(
require_sign_bytes_version("qorechain-new", None).unwrap(),
SignBytesVersion::V2
);
assert_eq!(
require_sign_bytes_version("qorechain-vladi", Some(SignBytesVersion::V1)).unwrap(),
SignBytesVersion::V1
);
let err = require_sign_bytes_version("qorechain-vladi", None).unwrap_err();
assert!(matches!(err, Error::SignBytes(_)), "{err:?}");
}
}