Compare commits

...

6 Commits

Author SHA1 Message Date
Tommy Verrall b03626ffb2 test old runner build 2025-09-02 17:30:23 +02:00
Jędrzej Stuczyński c0f8d98b63 bugfix: return from MixTrafficController if client request channel has closed (#6002) 2025-09-02 10:23:25 +01:00
Jędrzej Stuczyński 91995da4f1 chore: use updated version of simulate endpoint (#5988) 2025-09-02 10:12:52 +01:00
Jędrzej Stuczyński 01fa1df66c feat: shared library for attempting to retrieve update mode attestation (#5954)
* feat: shared library for attempting to retrieve update mode attestation

* clippy

* add nym- prefix to the crate name

* use pure-rust impl for jwt-simple
2025-09-02 09:28:32 +01:00
Jędrzej Stuczyński baddaaac22 feat: nym signers monitor (#5933)
* initialise nym-signers-monitor

* creating nyxd client

* performing checks

* sending notifications on failure

* rate limitting on notifications + clippy
2025-09-02 09:27:09 +01:00
elsirion 2c4b5f168b fix: use WASM compatible time API in client (#5948) 2025-09-02 09:26:06 +01:00
26 changed files with 1304 additions and 48 deletions
@@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [macos-15]
platform: [macos-14]
runs-on: ${{ matrix.platform }}
outputs:
Generated
+168
View File
@@ -847,6 +847,12 @@ dependencies = [
"serde",
]
[[package]]
name = "binstring"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
[[package]]
name = "bip32"
version = "0.5.3"
@@ -942,6 +948,17 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "blake2b_simd"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99"
dependencies = [
"arrayref",
"arrayvec",
"constant_time_eq",
]
[[package]]
name = "blake3"
version = "1.8.2"
@@ -1332,6 +1349,17 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "coarsetime"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91849686042de1b41cd81490edc83afbcb0abe5a9b6f2c4114f23ce8cca1bcf4"
dependencies = [
"libc",
"wasix",
"wasm-bindgen",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -1855,6 +1883,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "ct-codecs"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
[[package]]
name = "ctr"
version = "0.9.2"
@@ -2411,6 +2445,16 @@ dependencies = [
"signature",
]
[[package]]
name = "ed25519-compact"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9b3460f44bea8cd47f45a0c70892f1eff856d97cd55358b2f73f663789f6190"
dependencies = [
"ct-codecs",
"getrandom 0.2.16",
]
[[package]]
name = "ed25519-consensus"
version = "2.1.0"
@@ -2475,6 +2519,8 @@ dependencies = [
"ff",
"generic-array 0.14.7",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
@@ -3336,6 +3382,30 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "hmac-sha1-compact"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18492c9f6f9a560e0d346369b665ad2bdbc89fa9bceca75796584e79042694c3"
[[package]]
name = "hmac-sha256"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "hmac-sha512"
version = "1.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89e8d20b3799fa526152a5301a771eaaad80857f83e01b23216ceaafb2d9280"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "home"
version = "0.5.11"
@@ -4062,6 +4132,32 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jwt-simple"
version = "0.12.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "731011e9647a71ff4f8474176ff6ce6e0d2de87a0173f15613af3a84c3e3401a"
dependencies = [
"anyhow",
"binstring",
"blake2b_simd",
"coarsetime",
"ct-codecs",
"ed25519-compact",
"hmac-sha1-compact",
"hmac-sha256",
"hmac-sha512",
"k256",
"p256",
"p384",
"rand 0.8.5",
"serde",
"serde_json",
"superboring",
"thiserror 2.0.12",
"zeroize",
]
[[package]]
name = "k256"
version = "0.13.4"
@@ -5525,6 +5621,7 @@ dependencies = [
"generic-array 0.14.7",
"hkdf",
"hmac",
"jwt-simple",
"nym-pemstore",
"nym-sphinx-types",
"rand 0.8.5",
@@ -6639,6 +6736,26 @@ dependencies = [
"tokio",
]
[[package]]
name = "nym-signers-monitor"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"humantime",
"itertools 0.14.0",
"nym-bin-common",
"nym-ecash-signer-check",
"nym-network-defaults",
"nym-task",
"nym-validator-client",
"time",
"tokio",
"tracing",
"url",
"zulip-client",
]
[[package]]
name = "nym-socks5-client"
version = "1.1.61"
@@ -7077,6 +7194,22 @@ dependencies = [
"x25519-dalek",
]
[[package]]
name = "nym-upgrade-mode-check"
version = "0.1.0"
dependencies = [
"anyhow",
"jwt-simple",
"nym-crypto",
"nym-http-api-client",
"reqwest 0.12.22",
"serde",
"serde_json",
"thiserror 2.0.12",
"time",
"tracing",
]
[[package]]
name = "nym-validator-client"
version = "0.1.0"
@@ -7645,6 +7778,18 @@ dependencies = [
"sha2 0.10.9",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "pairing"
version = "0.23.0"
@@ -8625,6 +8770,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha2 0.10.9",
"signature",
"spki",
"subtle 2.6.1",
@@ -9914,6 +10060,19 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142"
[[package]]
name = "superboring"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "515cce34a781d7250b8a65706e0f2a5b99236ea605cb235d4baed6685820478f"
dependencies = [
"getrandom 0.2.16",
"hmac-sha256",
"hmac-sha512",
"rand 0.8.5",
"rsa",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -11411,6 +11570,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasix"
version = "0.12.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1fbb4ef9bbca0c1170e0b00dd28abc9e3b68669821600cad1caaed606583c6d"
dependencies = [
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
+3 -2
View File
@@ -95,7 +95,7 @@ members = [
"common/ticketbooks-merkle",
"common/topology",
"common/tun",
"common/types",
"common/types", "common/upgrade-mode-check",
"common/verloc",
"common/wasm/client-core",
"common/wasm/storage",
@@ -122,7 +122,7 @@ members = [
"nym-node-status-api/nym-node-status-client",
"nym-node/nym-node-metrics",
"nym-node/nym-node-requests",
"nym-outfox",
"nym-outfox", "nym-signers-monitor",
"nym-statistics-api",
"nym-validator-rewarder",
"nyx-chain-watcher",
@@ -274,6 +274,7 @@ inquire = "0.6.2"
ip_network = "0.4.1"
ipnetwork = "0.20"
itertools = "0.14.0"
jwt-simple = { version = "0.12.12", default-features = false, features = ["pure-rust"] }
k256 = "0.13"
lazy_static = "1.5.0"
ledger-transport = "0.10.0"
@@ -175,6 +175,7 @@ impl MixTrafficController {
},
None => {
tracing::trace!("MixTrafficController, client request channel closed");
break
}
},
}
@@ -1,6 +1,7 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::client::helpers::get_time_now;
use crate::client::replies::{
reply_controller::ReplyControllerSender, reply_storage::SentReplyKeys,
};
@@ -22,7 +23,7 @@ use nym_statistics_common::clients::{packet_statistics::PacketStatisticsEvent, C
use nym_task::TaskClient;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use tracing::*;
// The interval at which we check for stale buffers
@@ -54,7 +55,7 @@ struct ReceivedMessagesBufferInner<R: MessageReceiver> {
stats_tx: ClientStatsSender,
// Periodically check for stale buffers to clean up
last_stale_check: Instant,
last_stale_check: crate::client::helpers::Instant,
}
impl<R: MessageReceiver> ReceivedMessagesBufferInner<R> {
@@ -154,7 +155,7 @@ impl<R: MessageReceiver> ReceivedMessagesBufferInner<R> {
}
fn cleanup_stale_buffers(&mut self) {
let now = Instant::now();
let now = get_time_now();
if now - self.last_stale_check > STALE_BUFFER_CHECK_INTERVAL {
self.last_stale_check = now;
self.message_receiver
@@ -190,7 +191,7 @@ impl<R: MessageReceiver> ReceivedMessagesBuffer<R> {
message_sender: None,
recently_reconstructed: HashSet::new(),
stats_tx,
last_stale_check: Instant::now(),
last_stale_check: get_time_now(),
})),
reply_key_storage,
reply_controller_sender,
@@ -28,7 +28,7 @@ use cosmrs::proto::cosmwasm::wasm::v1::{
QueryRawContractStateResponse, QuerySmartContractStateRequest, QuerySmartContractStateResponse,
};
use cosmrs::tendermint::{block, chain, Hash};
use cosmrs::{AccountId, Coin as CosmosCoin, Tx};
use cosmrs::{AccountId, Coin as CosmosCoin};
use prost::Message;
use serde::{Deserialize, Serialize};
@@ -556,23 +556,12 @@ pub trait CosmWasmClient: TendermintRpcClient {
Ok(serde_json::from_slice(&res.data)?)
}
// deprecation warning is due to the fact the protobuf files built were based on cosmos-sdk 0.44,
// where they prefer using tx_bytes directly. However, in 0.42, which we are using at the time
// of writing this, the option does not work
// TODO: we should really stop using the `tx` argument here and use `tx_bytes` exlusively,
// however, at the time of writing this update, while our QA and mainnet networks do support it,
// sandbox is still running old version of wasmd that lacks support for `tx_bytes`
#[allow(deprecated)]
async fn query_simulate(
&self,
tx: Option<Tx>,
tx_bytes: Vec<u8>,
) -> Result<SimulateResponse, NyxdError> {
async fn query_simulate(&self, tx_bytes: Vec<u8>) -> Result<SimulateResponse, NyxdError> {
let path = Some("/cosmos.tx.v1beta1.Service/Simulate".to_owned());
let req = SimulateRequest {
tx: tx.map(Into::into),
tx_bytes,
..Default::default()
};
let res = self
@@ -81,17 +81,14 @@ where
auth_info: single_unspecified_signer_auth(public_key, sequence_response.sequence),
signatures: vec![Vec::new()],
};
self.query_simulate(Some(partial_tx), Vec::new()).await
// for completion sake, once we're able to transition into using `tx_bytes`,
// we might want to use something like this instead:
// let tx_raw: tx::Raw = cosmrs::proto::cosmos::tx::v1beta1::TxRaw {
// body_bytes: partial_tx.body.into_bytes().unwrap(),
// auth_info_bytes: partial_tx.auth_info.into_bytes().unwrap(),
// signatures: partial_tx.signatures,
// }
// .into();
// self.query_simulate(None, tx_raw.to_bytes().unwrap()).await
let tx_raw: tx::Raw = cosmrs::proto::cosmos::tx::v1beta1::TxRaw {
body_bytes: partial_tx.body.into_bytes()?,
auth_info_bytes: partial_tx.auth_info.into_bytes()?,
signatures: partial_tx.signatures,
}
.into();
self.query_simulate(tx_raw.to_bytes()?).await
}
async fn upload(
+2
View File
@@ -18,6 +18,7 @@ digest = { workspace = true, optional = true }
generic-array = { workspace = true, optional = true }
hkdf = { workspace = true, optional = true }
hmac = { workspace = true, optional = true }
jwt-simple = { workspace = true, optional = true }
cipher = { workspace = true, optional = true }
x25519-dalek = { workspace = true, features = ["static_secrets"], optional = true }
ed25519-dalek = { workspace = true, features = ["rand_core"], optional = true }
@@ -39,6 +40,7 @@ rand_chacha = { workspace = true }
[features]
default = []
aead = ["dep:aead", "aead/std", "aes-gcm-siv", "generic-array"]
naive_jwt = ["asymmetric", "jwt-simple"]
serde = ["dep:serde", "serde_bytes", "ed25519-dalek/serde", "x25519-dalek/serde"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "zeroize"]
hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2"]
+82 -5
View File
@@ -2,8 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
pub use ed25519_dalek::SignatureError;
use ed25519_dalek::{SecretKey, Signer, SigningKey};
pub use ed25519_dalek::{Verifier, PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH};
use ed25519_dalek::Signer;
use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair};
use std::fmt::{self, Debug, Display, Formatter};
use std::str::FromStr;
@@ -13,6 +14,9 @@ use zeroize::{Zeroize, ZeroizeOnDrop};
#[cfg(feature = "serde")]
pub mod serde_helpers;
#[cfg(feature = "serde")]
pub use serde_helpers::*;
#[cfg(feature = "sphinx")]
use nym_sphinx_types::{DestinationAddressBytes, DESTINATION_ADDRESS_LENGTH};
@@ -81,8 +85,8 @@ impl KeyPair {
}
}
pub fn from_secret(secret: SecretKey, index: u32) -> Self {
let ed25519_signing_key = SigningKey::from(secret);
pub fn from_secret(secret: ed25519_dalek::SecretKey, index: u32) -> Self {
let ed25519_signing_key = ed25519_dalek::SigningKey::from(secret);
KeyPair {
private_key: PrivateKey(ed25519_signing_key.to_bytes()),
@@ -276,7 +280,7 @@ impl Display for PrivateKey {
impl<'a> From<&'a PrivateKey> for PublicKey {
fn from(pk: &'a PrivateKey) -> Self {
PublicKey(SigningKey::from_bytes(&pk.0).verifying_key())
PublicKey(ed25519_dalek::SigningKey::from_bytes(&pk.0).verifying_key())
}
}
@@ -320,7 +324,7 @@ impl PrivateKey {
}
pub fn sign<M: AsRef<[u8]>>(&self, message: M) -> Signature {
let signing_key: SigningKey = self.0.into();
let signing_key: ed25519_dalek::SigningKey = self.0.into();
let sig = signing_key.sign(message.as_ref());
Signature(sig)
}
@@ -425,9 +429,57 @@ impl<'d> Deserialize<'d> for Signature {
}
}
#[cfg(feature = "naive_jwt")]
impl PublicKey {
pub fn to_jwt_compatible_key(&self) -> jwt_simple::algorithms::Ed25519PublicKey {
(*self).into()
}
}
#[cfg(feature = "naive_jwt")]
impl From<PublicKey> for jwt_simple::algorithms::Ed25519PublicKey {
fn from(value: PublicKey) -> Self {
// SAFETY: we have a valid ed25519 pubkey, we're just changing to a different library wrapper
#[allow(clippy::unwrap_used)]
jwt_simple::algorithms::Ed25519PublicKey::from_bytes(&value.to_bytes()).unwrap()
}
}
#[cfg(feature = "naive_jwt")]
impl PrivateKey {
pub fn to_jwt_compatible_keys(&self) -> jwt_simple::algorithms::Ed25519KeyPair {
let pub_key = self.public_key();
let mut bytes = zeroize::Zeroizing::new([0u8; 64]);
bytes[..SECRET_KEY_LENGTH]
.copy_from_slice(zeroize::Zeroizing::new(self.to_bytes()).as_ref());
bytes[SECRET_KEY_LENGTH..].copy_from_slice(&pub_key.to_bytes());
// SAFETY: we have a valid ed25519 keys, we're just changing to a different library wrapper
#[allow(clippy::unwrap_used)]
jwt_simple::algorithms::Ed25519KeyPair::from_bytes(bytes.as_ref()).unwrap()
}
}
#[cfg(feature = "naive_jwt")]
impl KeyPair {
pub fn to_jwt_compatible_keys(&self) -> jwt_simple::algorithms::Ed25519KeyPair {
let mut bytes = zeroize::Zeroizing::new([0u8; 64]);
bytes[..SECRET_KEY_LENGTH]
.copy_from_slice(zeroize::Zeroizing::new(self.private_key.to_bytes()).as_ref());
bytes[SECRET_KEY_LENGTH..].copy_from_slice(&self.public_key.to_bytes());
// SAFETY: we have a valid ed25519 keys, we're just changing to a different library wrapper
#[allow(clippy::unwrap_used)]
jwt_simple::algorithms::Ed25519KeyPair::from_bytes(bytes.as_ref()).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::thread_rng;
fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
@@ -438,4 +490,29 @@ mod tests {
assert_zeroize::<PrivateKey>();
assert_zeroize_on_drop::<PrivateKey>();
}
#[test]
#[cfg(all(feature = "naive_jwt", feature = "rand"))]
fn check_jwt_key_compat_conversion() {
let mut rng = thread_rng();
let keys = KeyPair::new(&mut rng);
let jwt_keys = keys.to_jwt_compatible_keys();
// internally they're represented by hidden `Edwards25519KeyPair` (plus key_id)
// which has way nicer API for assertions
let jwt_keys_inner =
jwt_simple::algorithms::Edwards25519KeyPair::from_bytes(&jwt_keys.to_bytes()).unwrap();
let compact_ed25519 = jwt_keys_inner.as_ref();
assert!(compact_ed25519
.sk
.validate_public_key(&compact_ed25519.pk)
.is_ok());
let dummy_message = "hello world";
let sig1 = keys.private_key.sign(dummy_message).to_bytes();
let sig2 = compact_ed25519.sk.sign(dummy_message, None).to_vec();
assert_eq!(sig1.to_vec(), sig2);
}
}
@@ -157,6 +157,14 @@ impl<LS, TS, LC, TC> SignerResult<LS, TS, LC, TC> {
pub fn malformed_details(&self) -> bool {
self.information.parse().is_err()
}
pub fn try_get_test_result(&self) -> Option<&SignerTestResult<LS, TS, LC, TC>> {
if let SignerStatus::Tested { result } = &self.status {
Some(result)
} else {
None
}
}
}
impl<LS, TS, LC, TC> SignerResult<LS, TS, LC, TC>
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "nym-upgrade-mode-check"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
jwt-simple = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
time = { workspace = true, features = ["serde"] }
thiserror = { workspace = true }
tracing = { workspace = true }
nym-http-api-client = { path = "../http-api-client", default-features = false }
nym-crypto = { path = "../crypto", features = ["asymmetric", "serde", "naive_jwt"] }
[dev-dependencies]
anyhow = { workspace = true }
time = { workspace = true, features = ["macros"] }
[lints]
workspace = true
@@ -0,0 +1,123 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::UpgradeModeCheckError;
use nym_crypto::asymmetric::ed25519;
use nym_http_api_client::generate_user_agent;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use time::OffsetDateTime;
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
pub struct UpgradeModeAttestation {
#[serde(flatten)]
pub content: UpgradeModeAttestationContent,
#[serde(with = "ed25519::bs58_ed25519_signature")]
pub signature: ed25519::Signature,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
#[serde(tag = "type")]
#[serde(rename = "upgrade_mode")]
pub struct UpgradeModeAttestationContent {
#[serde(with = "time::serde::timestamp")]
pub starting_time: OffsetDateTime,
#[serde(with = "ed25519::bs58_ed25519_pubkey")]
pub attester_public_key: ed25519::PublicKey,
}
impl UpgradeModeAttestation {
pub fn verify(&self) -> bool {
self.content
.attester_public_key
.verify(self.content.as_json(), &self.signature)
.is_ok()
}
}
impl UpgradeModeAttestationContent {
pub fn as_json(&self) -> String {
// SAFETY: Serialize impl is valid and we have no non-string map keys
#[allow(clippy::unwrap_used)]
serde_json::to_string(&self).unwrap()
}
}
pub fn generate_new_attestation(key: &ed25519::PrivateKey) -> UpgradeModeAttestation {
generate_new_attestation_with_starting_time(key, OffsetDateTime::now_utc())
}
pub fn generate_new_attestation_with_starting_time(
key: &ed25519::PrivateKey,
starting_time: OffsetDateTime,
) -> UpgradeModeAttestation {
let content = UpgradeModeAttestationContent {
starting_time,
attester_public_key: key.into(),
};
UpgradeModeAttestation {
signature: key.sign(content.as_json()),
content,
}
}
pub async fn attempt_retrieve(
url: &str,
) -> Result<Option<UpgradeModeAttestation>, UpgradeModeCheckError> {
let retrieval_failure = |source| UpgradeModeCheckError::AttestationRetrievalFailure {
url: url.to_string(),
source,
};
let attestation = reqwest::ClientBuilder::new()
.user_agent(generate_user_agent!())
.timeout(Duration::from_secs(5))
.build()
.map_err(retrieval_failure)?
.get(url)
.send()
.await
.map_err(retrieval_failure)?
.json::<Option<UpgradeModeAttestation>>()
.await
.map_err(retrieval_failure)?;
Ok(attestation)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upgrade_mode_attestation_serde_json() -> anyhow::Result<()> {
// unix timestamp: 1629720000
let starting_time = time::macros::datetime!(2021-08-23 12:00 UTC);
let key = ed25519::PrivateKey::from_bytes(&[
108, 49, 193, 21, 126, 161, 249, 85, 242, 207, 74, 195, 238, 6, 64, 149, 201, 140, 248,
163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161,
])?;
let attestation = generate_new_attestation_with_starting_time(&key, starting_time);
let attestation_json = serde_json::to_string(&attestation)?;
let attestation_content_json = attestation.content.as_json();
let expected_attestation = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","signature":"5rWUr2ypaDTtrMKegMP3tQkkZGFAuhNTnEVCVe5Azv6QqvLzoGdQiMkFmeyhDd1XSfoXpL9fFM58rsdA1kf4GYMM"}"#;
let expected_content = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt"}"#;
assert_eq!(attestation_content_json, expected_content);
assert_eq!(attestation_json, expected_attestation);
let recovered_attestation = serde_json::from_str(&attestation_json)?;
assert_eq!(attestation, recovered_attestation);
let recovered_content = serde_json::from_str(&attestation_content_json)?;
assert_eq!(attestation.content, recovered_content);
Ok(())
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UpgradeModeCheckError {
#[error("failed to decode jwt metadata")]
TokenMetadataDecodeFailure { source: jwt_simple::Error },
#[error("the jwt metadata didn't contain explicit public key")]
MissingTokenPublicKey,
#[error("the attached public key was not valid ed25519 public key")]
MalformedEd25519PublicKey { source: Ed25519RecoveryError },
#[error("failed to verify the jwt: {source}")]
JwtVerificationFailure { source: jwt_simple::Error },
#[error("failed to retrieve attestation from {url}:{source}")]
AttestationRetrievalFailure { url: String, source: reqwest::Error },
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{UpgradeModeAttestation, UpgradeModeCheckError};
use jwt_simple::claims::Claims;
use jwt_simple::common::{KeyMetadata, VerificationOptions};
use jwt_simple::prelude::{EdDSAKeyPairLike, EdDSAPublicKeyLike};
use jwt_simple::token::Token;
use nym_crypto::asymmetric::ed25519;
use std::collections::HashSet;
use std::time::Duration;
// for now use static issuer such as "nym-credential-proxy"
pub fn generate_jwt_for_upgrade_mode_attestation(
attestation: UpgradeModeAttestation,
validity: Duration,
keys: &ed25519::KeyPair,
issuer: Option<&'static str>,
) -> String {
let claim = Claims::with_custom_claims(attestation, validity.into());
let mut claim = if let Some(issuer) = issuer {
claim.with_issuer(issuer)
} else {
claim
};
claim.create_nonce();
let md = KeyMetadata::default().with_public_key(keys.public_key().to_base58_string());
let mut jwt_keys = keys.to_jwt_compatible_keys();
// SAFETY: trait impl for EdDSA is infallible
#[allow(clippy::unwrap_used)]
jwt_keys.attach_metadata(md).unwrap();
// SAFETY: our construction of the jwt is valid
#[allow(clippy::unwrap_used)]
jwt_keys.sign(claim).unwrap()
}
pub fn validate_upgrade_mode_jwt(
token: &str,
expected_issuer: Option<&'static str>,
) -> Result<UpgradeModeAttestation, UpgradeModeCheckError> {
// for now, we completely ignore the validity of the pubkey (I know, I know).
// that will be changed later on
// so as a bypass we have to extract the claimed issuer from the jwt to verify against it
let metadata = Token::decode_metadata(token)
.map_err(|source| UpgradeModeCheckError::TokenMetadataDecodeFailure { source })?;
let pub_key = metadata
.public_key()
.ok_or(UpgradeModeCheckError::MissingTokenPublicKey)?;
let ed25519_pub_key = ed25519::PublicKey::from_base58_string(pub_key)
.map_err(|source| UpgradeModeCheckError::MalformedEd25519PublicKey { source })?;
let mut opts = VerificationOptions::default();
if let Some(issuer) = expected_issuer {
opts.allowed_issuers = Some(HashSet::from_iter(vec![issuer.to_string()]));
}
let attestation = ed25519_pub_key
.to_jwt_compatible_key()
.verify_token::<UpgradeModeAttestation>(token, Some(opts))
.map_err(|source| UpgradeModeCheckError::JwtVerificationFailure { source })?
.custom;
Ok(attestation)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate_new_attestation;
use nym_crypto::asymmetric::ed25519;
#[test]
fn generate_and_validate_jwt() {
let attestation_key = ed25519::PrivateKey::from_bytes(&[
108, 49, 193, 21, 126, 161, 249, 85, 242, 207, 74, 195, 238, 6, 64, 149, 201, 140, 248,
163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161,
])
.unwrap();
let jwt_key = ed25519::PrivateKey::from_bytes(&[
152, 17, 144, 255, 213, 219, 246, 208, 109, 33, 100, 73, 1, 141, 32, 63, 141, 89, 167,
2, 52, 215, 241, 219, 200, 18, 159, 241, 76, 111, 42, 32,
])
.unwrap();
let keys = ed25519::KeyPair::from(jwt_key);
let attestation = generate_new_attestation(&attestation_key);
let jwt_issuer = generate_jwt_for_upgrade_mode_attestation(
attestation,
Duration::from_secs(60 * 60),
&keys,
Some("nym-credential-proxy"),
);
// we expect 'nym-credential-proxy' issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("nym-credential-proxy")).is_ok());
// we don't care about issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, None).is_ok());
// we expect another-issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("another-issuer")).is_err());
let jwt_no_issuer = generate_jwt_for_upgrade_mode_attestation(
attestation,
Duration::from_secs(60 * 60),
&keys,
None,
);
// we expect 'nym-credential-proxy' issuer
assert!(validate_upgrade_mode_jwt(&jwt_no_issuer, Some("nym-credential-proxy")).is_err());
// we don't care about issuer
assert!(validate_upgrade_mode_jwt(&jwt_no_issuer, None).is_ok());
}
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub(crate) mod attestation;
pub(crate) mod error;
pub(crate) mod jwt;
pub use attestation::{
attempt_retrieve, generate_new_attestation, generate_new_attestation_with_starting_time,
UpgradeModeAttestation,
};
pub use error::UpgradeModeCheckError;
pub use jwt::{generate_jwt_for_upgrade_mode_attestation, validate_upgrade_mode_jwt};
+15 -1
View File
@@ -25,7 +25,7 @@
//! ```
use crate::error::ZulipClientError;
use crate::message::{SendMessageResponse, SendableMessage};
use crate::message::{DirectMessage, SendMessageResponse, SendableMessage, StreamMessage};
use nym_bin_common::bin_info;
use nym_http_api_client::UserAgent;
use reqwest::{header, Method, RequestBuilder};
@@ -92,6 +92,20 @@ impl Client {
.map_err(|source| ZulipClientError::RequestDecodeFailure { source })
}
pub async fn send_direct_message(
&self,
msg: impl Into<DirectMessage>,
) -> Result<SendMessageResponse, ZulipClientError> {
self.send_message(msg.into()).await
}
pub async fn send_channel_message(
&self,
msg: impl Into<StreamMessage>,
) -> Result<SendMessageResponse, ZulipClientError> {
self.send_message(msg.into()).await
}
fn build_request(&self, method: Method, endpoint: &'static str) -> RequestBuilder {
let url = format!("{}{endpoint}", self.server_url);
trace!("posting to {url}");
+62 -10
View File
@@ -22,7 +22,7 @@ pub enum SendMessageResponse {
},
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
#[serde(tag = "type")]
pub enum SendableMessageContent {
@@ -40,7 +40,7 @@ pub enum SendableMessageContent {
},
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub struct SendableMessage {
#[serde(flatten)]
@@ -117,17 +117,17 @@ impl StreamMessage {
pub fn new(
to: impl Into<ToChannel>,
content: impl Into<String>,
topic: Option<String>,
topic: impl IntoMaybeTopic,
) -> Self {
StreamMessage {
to: to.into().to_string(),
topic,
topic: topic.into_maybe_topic(),
content: content.into(),
}
}
pub fn no_topic(to: impl Into<ToChannel>, content: impl Into<String>) -> Self {
Self::new(to, content, None)
Self::new(to, content, None::<String>)
}
#[must_use]
@@ -194,22 +194,74 @@ impl From<StreamMessage> for SendableMessageContent {
}
}
impl<T, S> From<(T, S, Option<S>)> for StreamMessage
impl<T, S, U> From<(T, S, U)> for StreamMessage
where
T: Into<ToChannel>,
S: Into<String>,
U: IntoMaybeTopic,
{
fn from((to, content, topic): (T, S, Option<S>)) -> Self {
StreamMessage::new(to, content, topic.map(Into::into))
fn from((to, content, topic): (T, S, U)) -> Self {
StreamMessage::new(to, content, topic)
}
}
impl<T, S> From<(T, S, Option<S>)> for SendableMessage
impl<T, S> From<(T, S)> for StreamMessage
where
T: Into<ToChannel>,
S: Into<String>,
{
fn from(inner: (T, S, Option<S>)) -> Self {
fn from((to, content): (T, S)) -> Self {
StreamMessage::no_topic(to, content)
}
}
impl<T, S, U> From<(T, S, U)> for SendableMessage
where
T: Into<ToChannel>,
S: Into<String>,
U: IntoMaybeTopic,
{
fn from(inner: (T, S, U)) -> Self {
StreamMessage::from(inner).into()
}
}
pub trait IntoMaybeTopic {
fn into_maybe_topic(self) -> Option<String>;
}
impl<S> IntoMaybeTopic for &Option<S>
where
S: Into<String> + Clone,
{
fn into_maybe_topic(self) -> Option<String> {
self.clone().map(|s| s.into())
}
}
impl<S> IntoMaybeTopic for Option<S>
where
S: Into<String>,
{
fn into_maybe_topic(self) -> Option<String> {
self.map(Into::into)
}
}
impl IntoMaybeTopic for String {
fn into_maybe_topic(self) -> Option<String> {
Some(self)
}
}
impl IntoMaybeTopic for &String {
fn into_maybe_topic(self) -> Option<String> {
Some(self.clone())
}
}
impl IntoMaybeTopic for &str {
fn into_maybe_topic(self) -> Option<String> {
Some(self.to_string())
}
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "nym-signers-monitor"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["cargo", "derive", "env", "string"] }
humantime = { workspace = true }
itertools = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tracing = { workspace = true }
url = { workspace = true }
nym-bin-common = { path = "../common/bin-common", features = ["output_format", "basic_tracing"] }
nym-ecash-signer-check = { path = "../common/ecash-signer-check" }
nym-network-defaults = { path = "../common/network-defaults" }
nym-task = { path = "../common/task" }
nym-validator-client = { path = "../common/client-libs/validator-client" }
zulip-client = { path = "../common/zulip-client" }
[lints]
workspace = true
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_bin_common::bin_info_owned;
use nym_bin_common::output_format::OutputFormat;
#[derive(clap::Args, Debug)]
pub(crate) struct Args {
#[arg(short, long, default_value_t = OutputFormat::default())]
output: OutputFormat,
}
pub(crate) fn execute(args: Args) {
println!("{}", args.output.format(&bin_info_owned!()))
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
pub(crate) mod vars {
pub(crate) const ZULIP_BOT_EMAIL_ARG: &str = "ZULIP_BOT_EMAIL";
pub(crate) const ZULIP_BOT_API_KEY_ARG: &str = "ZULIP_BOT_API_KEY";
pub(crate) const ZULIP_SERVER_URL_ARG: &str = "ZULIP_SERVER_URL";
pub(crate) const ZULIP_NOTIFICATION_CHANNEL_ID_ARG: &str = "ZULIP_NOTIFICATION_CHANNEL_ID";
pub(crate) const ZULIP_NOTIFICATION_CHANNEL_TOPIC_ARG: &str =
"ZULIP_NOTIFICATION_CHANNEL_TOPIC";
pub(crate) const SIGNERS_MONITOR_CHECK_INTERVAL_ARG: &str = "SIGNERS_MONITOR_CHECK_INTERVAL";
pub(crate) const SIGNERS_MONITOR_MIN_NOTIFICATION_DELAY_ARG: &str =
"SIGNERS_MONITOR_MIN_NOTIFICATION_DELAY";
pub(crate) const KNOWN_NETWORK_NAME_ARG: &str = "KNOWN_NETWORK_NAME";
pub(crate) const NYXD_CLIENT_CONFIG_ENV_FILE_ARG: &str = "NYXD_CLIENT_CONFIG_ENV_FILE";
pub(crate) const NYXD_RPC_ENDPOINT_ARG: &str = "NYXD_RPC_ENDPOINT";
pub(crate) const NYXD_DKG_CONTRACT_ADDRESS_ARG: &str = "NYXD_DKG_CONTRACT_ADDRESS";
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use clap::{Parser, Subcommand};
use nym_bin_common::bin_info;
use std::sync::OnceLock;
pub(crate) mod build_info;
pub(crate) mod env;
pub(crate) mod run;
// Helper for passing LONG_VERSION to clap
fn pretty_build_info_static() -> &'static str {
static PRETTY_BUILD_INFORMATION: OnceLock<String> = OnceLock::new();
PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print())
}
#[derive(Parser, Debug)]
#[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)]
pub(crate) struct Cli {
#[clap(subcommand)]
command: Commands,
}
impl Cli {
pub async fn execute(self) -> anyhow::Result<()> {
match self.command {
Commands::BuildInfo(args) => build_info::execute(args),
Commands::Run(args) => run::execute(*args).await?,
}
Ok(())
}
}
#[derive(Subcommand, Debug)]
pub(crate) enum Commands {
/// Show build information of this binary
BuildInfo(build_info::Args),
/// Start signers monitor and send notifications on any failures
Run(Box<run::Args>),
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::cli::env::vars::*;
use crate::monitor::SignersMonitor;
use anyhow::{bail, Context};
use clap::ArgGroup;
use nym_network_defaults::{setup_env, NymNetworkDetails};
use nym_validator_client::nyxd::AccountId;
use nym_validator_client::QueryHttpRpcNyxdClient;
use std::time::Duration;
use url::Url;
#[derive(Debug, clap::Args)]
pub(crate) struct NyxdConnectionArgs {
// for well-known networks, such mainnet, we can use hardcoded values
/// Name of a well known network (such as 'mainnet') that has well-known
/// pre-configured setup values
#[clap(long, env = KNOWN_NETWORK_NAME_ARG)]
pub(crate) known_network_name: Option<String>,
/// Path pointing to an env file that configures the nyxd client.
#[clap(
short,
long,
env = NYXD_CLIENT_CONFIG_ENV_FILE_ARG
)]
pub(crate) config_env_file: Option<std::path::PathBuf>,
/// For unknown networks (or if one wishes to override defaults),
/// specify the RPC endpoint of a node from which signer information should be retrieved
#[clap(long, env = NYXD_RPC_ENDPOINT_ARG)]
pub(crate) nyxd_rpc_endpoint: Option<Url>,
/// For unknown networks, specify address of the DKG contract to pull signer information from.
#[clap(
long,
requires("nyxd_rpc_endpoint"),
env = NYXD_DKG_CONTRACT_ADDRESS_ARG
)]
pub(crate) dkg_contract_address: Option<AccountId>,
// if needed down the line (not sure why), we could define additional args
// for specifying denoms, etc.
// #[clap(long, requires("dkg_contract_address"))]
// pub(crate) mix_denom: Option<String>,
}
impl NyxdConnectionArgs {
fn get_minimal_nym_network_details(&self) -> anyhow::Result<NymNetworkDetails> {
if let Some(known_network_name) = &self.known_network_name {
match known_network_name.as_str() {
"mainnet" => return Ok(NymNetworkDetails::new_mainnet()),
other => bail!("{other} is not a known network name - please use another method of setting up chain connection"),
}
}
if let Some(config_env_file) = &self.config_env_file {
setup_env(Some(config_env_file));
return Ok(NymNetworkDetails::new_from_env());
}
// SAFETY: clap ensures at least one of the fields is set
#[allow(clippy::unwrap_used)]
let dkg_contract = self.dkg_contract_address.as_ref().unwrap();
// use mainnet's chain details (i.e. prefixes, denoms, etc)
let mainnet_chain_details = NymNetworkDetails::new_mainnet().chain_details;
Ok(NymNetworkDetails::new_empty()
.with_chain_details(mainnet_chain_details)
.with_coconut_dkg_contract(Some(dkg_contract.to_string())))
}
pub(crate) fn try_create_nyxd_client(&self) -> anyhow::Result<QueryHttpRpcNyxdClient> {
let network_details = self.get_minimal_nym_network_details()?;
let nyxd_endpoint = match &self.nyxd_rpc_endpoint {
Some(nyxd_rpc_endpoint) => nyxd_rpc_endpoint.clone(),
None => network_details
.endpoints
.first()
.context("no nyxd endpoints provided")?
.nyxd_url
.parse()?,
};
Ok(QueryHttpRpcNyxdClient::connect_with_network_details(
nyxd_endpoint.as_str(),
network_details,
)?)
}
}
#[derive(clap::Args, Debug)]
#[command(group(
ArgGroup::new("nyxd_connection")
.multiple(true)
.required(true)
.args([
"known_network_name",
"config_env_file",
"nyxd_rpc_endpoint"
])
))]
pub(crate) struct Args {
/// Specify email address for the bot responsible for sending notifications to the zulip server
/// in case 'upgrade' mode is detected
#[clap(
long,
env = ZULIP_BOT_EMAIL_ARG
)]
pub(crate) zulip_bot_email: String,
/// Specify the API key for the bot responsible for sending notifications to the zulip server
/// in case 'upgrade' mode is detected
#[clap(
long,
env = ZULIP_BOT_API_KEY_ARG
)]
pub(crate) zulip_bot_api_key: String,
/// Specify the sever endpoint for the bot responsible for sending notifications
/// in case 'upgrade' mode is detected
#[clap(
long,
env = ZULIP_SERVER_URL_ARG
)]
pub(crate) zulip_server_url: Url,
/// Specify the channel id for where the notification is going to be sent
#[clap(
long,
env = ZULIP_NOTIFICATION_CHANNEL_ID_ARG
)]
pub(crate) zulip_notification_channel_id: u32,
/// Optionally specify the channel topic for where the notification is going to be sent
#[clap(
long,
env = ZULIP_NOTIFICATION_CHANNEL_TOPIC_ARG
)]
pub(crate) zulip_notification_topic: Option<String>,
/// Specify the delay between subsequent signers checks
#[clap(
long,
env = SIGNERS_MONITOR_CHECK_INTERVAL_ARG,
value_parser = humantime::parse_duration,
default_value = "15m"
)]
pub(crate) signers_check_interval: Duration,
/// Specify the minimum delay between two subsequent notifications
#[clap(
long,
env = SIGNERS_MONITOR_MIN_NOTIFICATION_DELAY_ARG,
value_parser = humantime::parse_duration,
default_value = "1h"
)]
pub(crate) minimum_notification_delay: Duration,
#[clap(flatten)]
pub(crate) nyxd_connection: NyxdConnectionArgs,
}
pub(crate) async fn execute(args: Args) -> anyhow::Result<()> {
SignersMonitor::new(args)?.run().await
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::cli::Cli;
use clap::Parser;
use nym_bin_common::bin_info_owned;
use nym_bin_common::logging::setup_tracing_logger;
use tracing::{info, trace};
mod cli;
mod monitor;
pub(crate) mod test_result;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
setup_tracing_logger();
let cli = Cli::parse();
trace!("args: {cli:#?}");
let bin_info = bin_info_owned!();
info!("using the following version: {bin_info}");
cli.execute().await
}
+223
View File
@@ -0,0 +1,223 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::cli::run;
use crate::test_result::{DisplayableSignerResult, Summary, TestResult};
use nym_ecash_signer_check::check_signers_with_client;
use nym_task::ShutdownManager;
use nym_validator_client::QueryHttpRpcNyxdClient;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::time::interval;
use tracing::{error, info};
const LOST_QUORUM_MSG: &str = r#"
# 🔥🔥🔥 LOST SIGNING QUORUM 🔥🔥🔥
We seem to have lost the signing quorum - check if we should enable the 'upgrade' mode!
"#;
const UNKNOWN_QUORUM_MSG: &str = r#"
# ❓❓❓ UNKNOWN SIGNING QUORUM ❓❓❓
We can't determine the signing quroum - if we're not undergoing DKG exchange check if we should enable the 'upgrade' mode!
"#;
pub(crate) struct SignersMonitor {
zulip_client: zulip_client::Client,
nyxd_client: QueryHttpRpcNyxdClient,
notification_channel_id: u32,
notification_topic: Option<String>,
check_interval: Duration,
min_notification_delay: Duration,
last_notification_sent: Option<OffsetDateTime>,
}
impl SignersMonitor {
pub(crate) fn new(args: run::Args) -> anyhow::Result<Self> {
let zulip_client = zulip_client::Client::builder(
args.zulip_bot_email,
args.zulip_bot_api_key,
args.zulip_server_url,
)?
.build()?;
let nyxd_client = args.nyxd_connection.try_create_nyxd_client()?;
Ok(SignersMonitor {
zulip_client,
nyxd_client,
notification_channel_id: args.zulip_notification_channel_id,
notification_topic: args.zulip_notification_topic,
check_interval: args.signers_check_interval,
min_notification_delay: args.minimum_notification_delay,
last_notification_sent: None,
})
}
async fn check_signers(&mut self) -> anyhow::Result<TestResult> {
info!("starting signer check...");
let check_result = check_signers_with_client(&self.nyxd_client).await?;
let mut unreachable_signers = 0;
let mut unknown_local_chain_status = 0;
let mut stalled_local_chain = 0;
let mut working_local_chain = 0;
let mut unknown_credential_issuance_status = 0;
let mut working_credential_issuance = 0;
let mut unavailable_credential_issuance = 0;
let mut fully_working = 0;
let mut signers = Vec::new();
for result in &check_result.results {
if result.signer_unreachable() {
unreachable_signers += 1;
}
if result.unknown_chain_status() {
unknown_local_chain_status += 1;
}
if result.chain_available() {
working_local_chain += 1;
}
if result.chain_provably_stalled() || result.chain_unprovably_stalled() {
stalled_local_chain += 1;
}
if result.unknown_signing_status() {
unknown_credential_issuance_status += 1;
}
if result.signing_available() {
working_credential_issuance += 1;
}
if result.signing_provably_unavailable() || result.signing_unprovably_unavailable() {
unavailable_credential_issuance += 1;
}
let signing_available = if result.unknown_signing_status() {
None
} else {
Some(result.signing_available())
};
let chain_not_stalled = if result.unknown_chain_status() {
None
} else {
Some(result.chain_available())
};
if (result.chain_available()) && (result.signing_available()) {
fully_working += 1;
}
signers.push(DisplayableSignerResult {
version: result
.try_get_test_result()
.map(|r| r.reported_version.clone()),
url: result.information.announce_address.clone(),
signing_available,
chain_not_stalled,
})
}
let signing_quorum_available = check_result.threshold.map(|threshold| {
(working_local_chain as u64) >= threshold
&& (working_credential_issuance as u64) >= threshold
});
signers.sort_by_key(|s| s.version.clone());
let summary = Summary {
signing_quorum_available,
fully_working,
unreachable_signers,
registered_signers: check_result.results.len(),
unknown_local_chain_status,
stalled_local_chain,
working_local_chain,
unknown_credential_issuance_status,
working_credential_issuance,
unavailable_credential_issuance,
threshold: check_result.threshold,
};
Ok(TestResult { summary, signers })
}
async fn perform_signer_check(&mut self) -> anyhow::Result<()> {
let result = self.check_signers().await?;
let result_md = result.results_to_markdown_message();
let msg = if result.quorum_unavailable() {
Some(format!("{LOST_QUORUM_MSG}\n\n{result_md}",))
} else if result.quorum_unknown() {
Some(format!("{UNKNOWN_QUORUM_MSG}\n\n{result_md}",))
} else {
None
};
if let Some(msg) = msg {
self.maybe_notify_about_failure(&msg).await?
}
Ok(())
}
async fn maybe_notify_about_failure(&mut self, message: &String) -> anyhow::Result<()> {
if let Some(last_notification_sent) = self.last_notification_sent {
if last_notification_sent + self.min_notification_delay > OffsetDateTime::now_utc() {
info!("too soon to send another notification");
return Ok(());
}
}
self.send_zulip_notification(message).await?;
self.last_notification_sent = Some(OffsetDateTime::now_utc());
Ok(())
}
async fn send_zulip_notification(&self, message: &String) -> anyhow::Result<()> {
self.zulip_client
.send_channel_message((
self.notification_channel_id,
message,
&self.notification_topic,
))
.await?;
Ok(())
}
async fn send_shutdown_notification(&self) -> anyhow::Result<()> {
println!("here be sending shutdown notification");
Ok(())
}
pub(crate) async fn run(&mut self) -> anyhow::Result<()> {
let shutdown_manager =
ShutdownManager::new("nym-signers-monitor").with_default_shutdown_signals()?;
let mut check_interval = interval(self.check_interval);
while !shutdown_manager.root_token.is_cancelled() {
tokio::select! {
biased;
_ = shutdown_manager.root_token.cancelled() => {
info!("received shutdown");
break;
}
_ = check_interval.tick() => {
if let Err(err) = self.perform_signer_check().await {
error!("failed to check signers: {err}");
}
}
}
}
shutdown_manager.close();
shutdown_manager.run_until_shutdown().await;
if let Err(err) = self.send_shutdown_notification().await {
error!("failed to send shutdown notification: {err}");
}
Ok(())
}
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use itertools::Itertools;
fn maybe_bool_to_emoji_string(maybe_bool: Option<bool>) -> String {
match maybe_bool {
None => "⚠️ unknown".into(),
Some(true) => "✅ yes".into(),
Some(false) => "❌ no".into(),
}
}
pub(crate) struct DisplayableSignerResult {
pub(crate) url: String,
pub(crate) version: Option<String>,
pub(crate) signing_available: Option<bool>,
pub(crate) chain_not_stalled: Option<bool>,
}
impl DisplayableSignerResult {
fn to_markdown_table_row(&self) -> String {
format!(
"| {} | {} | {} | {} |",
self.url,
self.version.as_deref().unwrap_or("unknown"),
maybe_bool_to_emoji_string(self.signing_available),
maybe_bool_to_emoji_string(self.chain_not_stalled)
)
}
}
pub(crate) struct TestResult {
pub(crate) summary: Summary,
pub(crate) signers: Vec<DisplayableSignerResult>,
}
impl TestResult {
pub(crate) fn quorum_unavailable(&self) -> bool {
self.summary.signing_quorum_available.unwrap_or(false)
}
pub(crate) fn quorum_unknown(&self) -> bool {
self.summary.signing_quorum_available.is_none()
}
pub(crate) fn results_to_markdown_message(&self) -> String {
let p_available = format!(
"{:.2}",
(self.summary.fully_working as f32 / self.summary.registered_signers as f32) * 100.
);
format!(
r#"
## Summary
- quorum available: {} ({p_available}% of signers fully available)
- signers fully working: {}
- signing threshold: {}
- registered signers: {}
- unreachable signers: {}
### Chain Status
- unknown status: {}
- working chain: {}
- stalled chain: {}
### Credential Issuance Status
(note: signers below 1.1.64 do not return fully reliable results)
- unknown status: {}
- working issuance: {}
- unavailable issuance: {}
## Detailed Results
| address | version | chain working | issuance (maybe) available |
| - | - | - | - |
{}
"#,
maybe_bool_to_emoji_string(self.summary.signing_quorum_available),
self.summary.fully_working,
self.summary
.threshold
.map(|threshold| threshold.to_string())
.unwrap_or("???".to_string()),
self.summary.registered_signers,
self.summary.unreachable_signers,
self.summary.unknown_local_chain_status,
self.summary.working_local_chain,
self.summary.stalled_local_chain,
self.summary.unknown_credential_issuance_status,
self.summary.working_credential_issuance,
self.summary.unavailable_credential_issuance,
self.signers
.iter()
.map(|r| r.to_markdown_table_row())
.join("\n")
)
}
}
pub(crate) struct Summary {
pub(crate) signing_quorum_available: Option<bool>,
pub(crate) fully_working: usize,
pub(crate) threshold: Option<u64>,
pub(crate) registered_signers: usize,
pub(crate) unreachable_signers: usize,
pub(crate) unknown_local_chain_status: usize,
pub(crate) stalled_local_chain: usize,
pub(crate) working_local_chain: usize,
pub(crate) unknown_credential_issuance_status: usize,
pub(crate) working_credential_issuance: usize,
pub(crate) unavailable_credential_issuance: usize,
}
+1 -1
View File
@@ -20,7 +20,7 @@
"longDescription": "",
"macOS": {
"frameworks": [],
"minimumSystemVersion": "15.3.2",
"minimumSystemVersion": "14.4.1",
"exceptionDomain": "",
"signingIdentity": "Developer ID Application: Nym Technologies SA (VW5DZLFHM5)",
"entitlements": null