Skip to main content

nym_validator_client/coconut/
mod.rs

1// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::nyxd::contract_traits::{DkgQueryClient, PagedDkgQueryClient};
5use crate::nyxd::error::NyxdError;
6use nym_coconut_dkg_common::types::{EpochId, NodeIndex};
7use nym_coconut_dkg_common::verification_key::ContractVKShare;
8use nym_compact_ecash::error::CompactEcashError;
9use nym_compact_ecash::{Base58, VerificationKeyAuth};
10use std::fmt::{Display, Formatter};
11use thiserror::Error;
12use tracing::warn;
13use url::Url;
14
15// TODO: it really doesn't feel like this should live in this crate.
16#[derive(Clone)]
17pub struct EcashApiClient {
18    pub api_client: nym_http_api_client::Client,
19    pub verification_key: VerificationKeyAuth,
20    pub node_id: NodeIndex,
21    pub cosmos_address: cosmrs::AccountId,
22}
23
24impl EcashApiClient {
25    pub fn try_construct_from_share(share: ContractVKShare) -> Result<Self, EcashApiError> {
26        if !share.verified {
27            return Err(EcashApiError::UnverifiedShare);
28        }
29
30        let url_address = Url::parse(&share.announce_address)?;
31
32        // The NymApiClient constructed here uses the default (hickory DoT/DoH) resolver because
33        // this EcashApiClient is used by both client and non-client applications.
34        //
35        // In non-client applications this resolver can cause warning logs about H2 connection
36        // failure. This indicates that the long lived https connection was closed by the remote
37        // peer and the resolver will have to reconnect. It should not impact actual functionality
38        let api_client = nym_http_api_client::Client::builder(url_address)
39            .map_err(|e| EcashApiError::ClientError(e.to_string()))?
40            .build()
41            .map_err(|e| EcashApiError::ClientError(e.to_string()))?;
42
43        Ok(EcashApiClient {
44            api_client,
45            verification_key: VerificationKeyAuth::try_from_bs58(&share.share)?,
46            node_id: share.node_index,
47            cosmos_address: share.owner.as_str().parse()?,
48        })
49    }
50}
51
52impl Display for EcashApiClient {
53    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
54        write!(
55            f,
56            "[id: {}] {} @ ({})",
57            self.node_id,
58            self.cosmos_address,
59            self.api_client
60                .base_urls()
61                .iter()
62                .map(|url| url.to_string())
63                .collect::<Vec<String>>()
64                .join(", ")
65        )
66    }
67}
68
69// TODO: this should be using the coconut error
70// (which is in different crate; perhaps this client should be moved there?)
71
72#[derive(Debug, Error)]
73pub enum EcashApiError {
74    // TODO: ask @BN whether this is a correct error message
75    #[error("the provided key share hasn't been verified")]
76    UnverifiedShare,
77
78    #[error("failed to query the contract: {source}")]
79    ContractQueryFailure {
80        #[from]
81        source: NyxdError,
82    },
83
84    #[error("the provided announce address is malformed: {source}")]
85    MalformedAnnounceAddress {
86        #[from]
87        source: url::ParseError,
88    },
89
90    #[error("the provided verification key is malformed: {source}")]
91    MalformedVerificationKey {
92        #[from]
93        source: CompactEcashError,
94    },
95
96    #[error("failed to create API client: {0}")]
97    ClientError(String),
98
99    #[error("the provided account address is malformed: {source}")]
100    MalformedAccountAddress {
101        #[from]
102        source: cosmrs::ErrorReport,
103    },
104
105    #[error("nym api error")]
106    NymApi {
107        #[from]
108        source: crate::ValidatorClientError,
109    },
110}
111
112impl TryFrom<ContractVKShare> for EcashApiClient {
113    type Error = EcashApiError;
114
115    fn try_from(share: ContractVKShare) -> Result<Self, Self::Error> {
116        if !share.verified {
117            return Err(EcashApiError::UnverifiedShare);
118        }
119
120        let url_address = Url::parse(&share.announce_address)?;
121
122        // The NymApiClient constructed here uses the default (hickory DoT/DoH) resolver because
123        // this EcashApiClient is used by both client and non-client applications.
124        //
125        // In non-client applications this resolver can cause warning logs about H2 connection
126        // failure. This indicates that the long lived https connection was closed by the remote
127        // peer and the resolver will have to reconnect. It should not impact actual functionality
128        let api_client = nym_http_api_client::Client::builder(url_address)
129            .map_err(|e| EcashApiError::ClientError(e.to_string()))?
130            .build()
131            .map_err(|e| EcashApiError::ClientError(e.to_string()))?;
132
133        Ok(EcashApiClient {
134            api_client,
135            verification_key: VerificationKeyAuth::try_from_bs58(&share.share)?,
136            node_id: share.node_index,
137            cosmos_address: share.owner.as_str().parse()?,
138        })
139    }
140}
141
142/// Turn an epoch's key shares into usable API clients, skipping any share that can't be
143/// used and logging why.
144///
145/// A single bad share must not take the epoch down with it. Beyond the obvious case of a
146/// share that was never verified, the contract stores `announce_address` verbatim and
147/// share validation never looks at it, so a share can be marked verified on chain and
148/// still fail to convert here. Failing the whole batch would then deny every caller
149/// signer discovery for that epoch, recoverable only if the offending dealer updates its
150/// own announce address.
151///
152/// Callers must check the result still meets the epoch threshold - skipping is only safe
153/// because too few usable signers is a condition they detect themselves.
154pub fn usable_hickory_ecash_api_clients(shares: Vec<ContractVKShare>) -> Vec<EcashApiClient> {
155    let mut clients = Vec::with_capacity(shares.len());
156
157    for share in shares {
158        let owner = share.owner.clone();
159        let epoch_id = share.epoch_id;
160
161        match EcashApiClient::try_construct_from_share(share) {
162            Ok(client) => clients.push(client),
163            Err(err) => {
164                warn!("ignoring the key share of {owner} for epoch {epoch_id}: {err}")
165            }
166        }
167    }
168
169    clients
170}
171
172pub async fn all_ecash_api_clients<C>(
173    client: &C,
174    epoch_id: EpochId,
175) -> Result<Vec<EcashApiClient>, EcashApiError>
176where
177    C: DkgQueryClient,
178{
179    Ok(usable_hickory_ecash_api_clients(
180        client.get_all_verification_key_shares(epoch_id).await?,
181    ))
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::nyxd::contract_traits::dkg_query_client::{DkgQueryMsg, PagedVKSharesResponse};
188    use async_trait::async_trait;
189    use cosmrs::AccountId;
190    use cosmwasm_std::Addr;
191    use nym_compact_ecash::ttp_keygen;
192    use serde::{Deserialize, Serialize};
193
194    /// Serves a fixed set of shares; every other query is out of scope for these tests.
195    struct StubDkgQueryClient {
196        shares: Vec<ContractVKShare>,
197    }
198
199    fn respond<S, T>(value: S) -> Result<T, NyxdError>
200    where
201        S: Serialize,
202        for<'a> T: Deserialize<'a>,
203    {
204        let raw = serde_json::to_vec(&value).expect("failed to serialise the stub response");
205        Ok(serde_json::from_slice(&raw).expect("the stub returned the wrong response type"))
206    }
207
208    #[async_trait]
209    impl DkgQueryClient for StubDkgQueryClient {
210        async fn query_dkg_contract<T>(&self, query: DkgQueryMsg) -> Result<T, NyxdError>
211        where
212            for<'a> T: Deserialize<'a>,
213        {
214            match query {
215                DkgQueryMsg::GetVerificationKeys { epoch_id, .. } => {
216                    respond(PagedVKSharesResponse {
217                        shares: self
218                            .shares
219                            .iter()
220                            .filter(|share| share.epoch_id == epoch_id)
221                            .cloned()
222                            .collect(),
223                        per_page: self.shares.len(),
224                        start_next_after: None,
225                    })
226                }
227                other => panic!("the stub does not serve {other:?}"),
228            }
229        }
230    }
231
232    fn share(index: NodeIndex, key: &VerificationKeyAuth, verified: bool) -> ContractVKShare {
233        let owner = AccountId::new("n", &[index as u8; 32]).unwrap();
234
235        ContractVKShare {
236            share: key.to_bs58(),
237            announce_address: format!("http://localhost:{}", 8080 + index),
238            node_index: index,
239            owner: Addr::unchecked(owner.to_string()),
240            epoch_id: 0,
241            verified,
242        }
243    }
244
245    #[tokio::test]
246    async fn unverified_shares_are_skipped_rather_than_failing_the_whole_epoch() {
247        let keys = ttp_keygen(2, 3).unwrap();
248
249        // one dealer never got its share verified on chain - it missed the finalisation
250        // window, say. the epoch still concluded, and the other two shares are usable.
251        let shares = vec![
252            share(1, &keys[0].verification_key(), true),
253            share(2, &keys[1].verification_key(), false),
254            share(3, &keys[2].verification_key(), true),
255        ];
256
257        let client = StubDkgQueryClient { shares };
258        let clients = all_ecash_api_clients(&client, 0)
259            .await
260            .expect("a single unverified share must not brick signer discovery for the epoch");
261
262        // the verified signers remain discoverable, so callers can still apply their own
263        // threshold check - today the unverified share aborts the conversion before any
264        // threshold is ever considered
265        assert_eq!(clients.len(), 2);
266        assert_eq!(
267            clients.iter().map(|c| c.node_id).collect::<Vec<_>>(),
268            vec![1, 3]
269        );
270    }
271}