use crate::nyxd::contract_traits::{DkgQueryClient, PagedDkgQueryClient};
use crate::nyxd::error::NyxdError;
use nym_coconut_dkg_common::types::{EpochId, NodeIndex};
use nym_coconut_dkg_common::verification_key::ContractVKShare;
use nym_compact_ecash::error::CompactEcashError;
use nym_compact_ecash::{Base58, VerificationKeyAuth};
use std::fmt::{Display, Formatter};
use thiserror::Error;
use tracing::warn;
use url::Url;
#[derive(Clone)]
pub struct EcashApiClient {
pub api_client: nym_http_api_client::Client,
pub verification_key: VerificationKeyAuth,
pub node_id: NodeIndex,
pub cosmos_address: cosmrs::AccountId,
}
impl EcashApiClient {
pub fn try_construct_from_share(share: ContractVKShare) -> Result<Self, EcashApiError> {
if !share.verified {
return Err(EcashApiError::UnverifiedShare);
}
let url_address = Url::parse(&share.announce_address)?;
let api_client = nym_http_api_client::Client::builder(url_address)
.map_err(|e| EcashApiError::ClientError(e.to_string()))?
.build()
.map_err(|e| EcashApiError::ClientError(e.to_string()))?;
Ok(EcashApiClient {
api_client,
verification_key: VerificationKeyAuth::try_from_bs58(&share.share)?,
node_id: share.node_index,
cosmos_address: share.owner.as_str().parse()?,
})
}
}
impl Display for EcashApiClient {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[id: {}] {} @ ({})",
self.node_id,
self.cosmos_address,
self.api_client
.base_urls()
.iter()
.map(|url| url.to_string())
.collect::<Vec<String>>()
.join(", ")
)
}
}
#[derive(Debug, Error)]
pub enum EcashApiError {
#[error("the provided key share hasn't been verified")]
UnverifiedShare,
#[error("failed to query the contract: {source}")]
ContractQueryFailure {
#[from]
source: NyxdError,
},
#[error("the provided announce address is malformed: {source}")]
MalformedAnnounceAddress {
#[from]
source: url::ParseError,
},
#[error("the provided verification key is malformed: {source}")]
MalformedVerificationKey {
#[from]
source: CompactEcashError,
},
#[error("failed to create API client: {0}")]
ClientError(String),
#[error("the provided account address is malformed: {source}")]
MalformedAccountAddress {
#[from]
source: cosmrs::ErrorReport,
},
#[error("nym api error")]
NymApi {
#[from]
source: crate::ValidatorClientError,
},
}
impl TryFrom<ContractVKShare> for EcashApiClient {
type Error = EcashApiError;
fn try_from(share: ContractVKShare) -> Result<Self, Self::Error> {
if !share.verified {
return Err(EcashApiError::UnverifiedShare);
}
let url_address = Url::parse(&share.announce_address)?;
let api_client = nym_http_api_client::Client::builder(url_address)
.map_err(|e| EcashApiError::ClientError(e.to_string()))?
.build()
.map_err(|e| EcashApiError::ClientError(e.to_string()))?;
Ok(EcashApiClient {
api_client,
verification_key: VerificationKeyAuth::try_from_bs58(&share.share)?,
node_id: share.node_index,
cosmos_address: share.owner.as_str().parse()?,
})
}
}
pub fn usable_hickory_ecash_api_clients(shares: Vec<ContractVKShare>) -> Vec<EcashApiClient> {
let mut clients = Vec::with_capacity(shares.len());
for share in shares {
let owner = share.owner.clone();
let epoch_id = share.epoch_id;
match EcashApiClient::try_construct_from_share(share) {
Ok(client) => clients.push(client),
Err(err) => {
warn!("ignoring the key share of {owner} for epoch {epoch_id}: {err}")
}
}
}
clients
}
pub async fn all_ecash_api_clients<C>(
client: &C,
epoch_id: EpochId,
) -> Result<Vec<EcashApiClient>, EcashApiError>
where
C: DkgQueryClient,
{
Ok(usable_hickory_ecash_api_clients(
client.get_all_verification_key_shares(epoch_id).await?,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nyxd::contract_traits::dkg_query_client::{DkgQueryMsg, PagedVKSharesResponse};
use async_trait::async_trait;
use cosmrs::AccountId;
use cosmwasm_std::Addr;
use nym_compact_ecash::ttp_keygen;
use serde::{Deserialize, Serialize};
struct StubDkgQueryClient {
shares: Vec<ContractVKShare>,
}
fn respond<S, T>(value: S) -> Result<T, NyxdError>
where
S: Serialize,
for<'a> T: Deserialize<'a>,
{
let raw = serde_json::to_vec(&value).expect("failed to serialise the stub response");
Ok(serde_json::from_slice(&raw).expect("the stub returned the wrong response type"))
}
#[async_trait]
impl DkgQueryClient for StubDkgQueryClient {
async fn query_dkg_contract<T>(&self, query: DkgQueryMsg) -> Result<T, NyxdError>
where
for<'a> T: Deserialize<'a>,
{
match query {
DkgQueryMsg::GetVerificationKeys { epoch_id, .. } => {
respond(PagedVKSharesResponse {
shares: self
.shares
.iter()
.filter(|share| share.epoch_id == epoch_id)
.cloned()
.collect(),
per_page: self.shares.len(),
start_next_after: None,
})
}
other => panic!("the stub does not serve {other:?}"),
}
}
}
fn share(index: NodeIndex, key: &VerificationKeyAuth, verified: bool) -> ContractVKShare {
let owner = AccountId::new("n", &[index as u8; 32]).unwrap();
ContractVKShare {
share: key.to_bs58(),
announce_address: format!("http://localhost:{}", 8080 + index),
node_index: index,
owner: Addr::unchecked(owner.to_string()),
epoch_id: 0,
verified,
}
}
#[tokio::test]
async fn unverified_shares_are_skipped_rather_than_failing_the_whole_epoch() {
let keys = ttp_keygen(2, 3).unwrap();
let shares = vec![
share(1, &keys[0].verification_key(), true),
share(2, &keys[1].verification_key(), false),
share(3, &keys[2].verification_key(), true),
];
let client = StubDkgQueryClient { shares };
let clients = all_ecash_api_clients(&client, 0)
.await
.expect("a single unverified share must not brick signer discovery for the epoch");
assert_eq!(clients.len(), 2);
assert_eq!(
clients.iter().map(|c| c.node_id).collect::<Vec<_>>(),
vec![1, 3]
);
}
}