feature: rewarding for ticketbook issuance (#5112)

* fixed pagination for querying for validators

* wip: decoupling block signing from ticketbook issuance

* added ecash contract query for latest deposit

* parking the branch: wrappers for merkle tree for issued ticketbooks

* make nym-api store merkle trees of issued ticketbooks

* nym-api route for returning all deposits alongside merkle root

* return index alongside deposit id

* persisting merkle index alongside issued ticketbook details

* wip

* responses for issued deposit challenges

* nym-api cleanup

* verification of issued partial ticketbooks

* cleanup of rewarder code

* make the rest of codebase compile

* updated config file

* improved logging

* fixed division by zero if there were no ticketbooks issued in a day

* using correct budget when rewarding operators

* fixed routes for issued data

* fixed ecash test fixture

* fixed incorrect deserialisation of expiration_date param

* additional bugfixes for ticketbook issuance

* more fixes and updated tests

* fixed formatting after rebasing

* updated schema

* fixed edge case unit test
This commit is contained in:
Jędrzej Stuczyński
2024-11-14 16:55:02 +00:00
committed by GitHub
parent bea4eb5cb0
commit a348ff43b0
72 changed files with 3768 additions and 2811 deletions
Generated
+34 -1
View File
@@ -4504,6 +4504,7 @@ dependencies = [
"nym-sphinx",
"nym-statistics-common",
"nym-task",
"nym-ticketbooks-merkle",
"nym-topology",
"nym-types",
"nym-validator-client",
@@ -4552,6 +4553,8 @@ dependencies = [
"nym-network-defaults",
"nym-node-requests",
"nym-serde-helpers",
"nym-ticketbooks-merkle",
"rand_chacha",
"schemars",
"serde",
"serde_json",
@@ -6646,6 +6649,22 @@ dependencies = [
"wasmtimer",
]
[[package]]
name = "nym-ticketbooks-merkle"
version = "0.1.0"
dependencies = [
"nym-credentials-interface",
"nym-serde-helpers",
"rand",
"rand_chacha",
"rs_merkle",
"schemars",
"serde",
"serde_json",
"sha2 0.10.8",
"time",
]
[[package]]
name = "nym-topology"
version = "0.1.0"
@@ -6765,7 +6784,7 @@ dependencies = [
[[package]]
name = "nym-validator-rewarder"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"bip39",
@@ -6779,16 +6798,21 @@ dependencies = [
"nym-coconut-dkg-common",
"nym-compact-ecash",
"nym-config",
"nym-contracts-common",
"nym-credentials",
"nym-credentials-interface",
"nym-crypto",
"nym-ecash-time",
"nym-network-defaults",
"nym-serde-helpers",
"nym-task",
"nym-ticketbooks-merkle",
"nym-validator-client",
"nyxd-scraper",
"rand",
"rand_chacha",
"serde",
"serde_json",
"serde_with",
"sha2 0.10.8",
"sqlx",
@@ -8176,6 +8200,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rs_merkle"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b241d2e59b74ef9e98d94c78c47623d04c8392abaf82014dfd372a16041128f"
dependencies = [
"sha2 0.10.8",
]
[[package]]
name = "rsa"
version = "0.9.6"
+2
View File
@@ -90,6 +90,7 @@ members = [
"common/statistics",
"common/store-cipher",
"common/task",
"common/ticketbooks-merkle",
"common/topology",
"common/tun",
"common/types",
@@ -277,6 +278,7 @@ ledger-transport = "0.10.0"
ledger-transport-hid = "0.10.0"
log = "0.4"
maxminddb = "0.23.0"
rs_merkle = "1.4.2"
mime = "0.3.17"
moka = { version = "0.12", features = ["future"] }
nix = "0.27.1"
@@ -1,6 +1,7 @@
// Copyright 2021-2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub use crate::nym_api::NymApiClientExt;
use crate::nyxd::{self, NyxdClient};
use crate::signing::direct_wallet::DirectSecp256k1HdWallet;
use crate::signing::signer::{NoSigner, OfflineSigner};
@@ -11,7 +12,8 @@ use crate::{
use nym_api_requests::ecash::models::{
AggregatedCoinIndicesSignatureResponse, AggregatedExpirationDateSignatureResponse,
BatchRedeemTicketsBody, EcashBatchTicketRedemptionResponse, EcashTicketVerificationResponse,
SpentCredentialsResponse, VerifyEcashTicketBody,
IssuedTicketbooksChallengeResponse, IssuedTicketbooksForResponse, SpentCredentialsResponse,
VerifyEcashTicketBody,
};
use nym_api_requests::ecash::{
BlindSignRequestBody, BlindedSignatureResponse, PartialCoinIndicesSignatureResponse,
@@ -24,16 +26,16 @@ use nym_api_requests::models::{
use nym_api_requests::models::{LegacyDescribedGateway, MixNodeBondAnnotated};
use nym_api_requests::nym_nodes::SkimmedNode;
use nym_coconut_dkg_common::types::EpochId;
use nym_ecash_contract_common::deposit::DepositId;
use nym_http_api_client::UserAgent;
use nym_network_defaults::NymNetworkDetails;
use time::Date;
use url::Url;
pub use crate::nym_api::NymApiClientExt;
use nym_mixnet_contract_common::NymNodeDetails;
pub use nym_mixnet_contract_common::{
mixnode::MixNodeDetails, GatewayBond, IdentityKey, IdentityKeyRef, NodeId,
mixnode::MixNodeDetails, GatewayBond, IdentityKey, IdentityKeyRef, NodeId, NymNodeDetails,
};
// re-export the type to not break existing imports
pub use crate::coconut::EcashApiClient;
@@ -696,4 +698,22 @@ impl NymApiClient {
) -> Result<VerificationKeyResponse, ValidatorClientError> {
Ok(self.nym_api.master_verification_key(epoch_id).await?)
}
pub async fn issued_ticketbooks_for(
&self,
expiration_date: Date,
) -> Result<IssuedTicketbooksForResponse, ValidatorClientError> {
Ok(self.nym_api.issued_ticketbooks_for(expiration_date).await?)
}
pub async fn issued_ticketbooks_challenge(
&self,
expiration_date: Date,
deposits: Vec<DepositId>,
) -> Result<IssuedTicketbooksChallengeResponse, ValidatorClientError> {
Ok(self
.nym_api
.issued_ticketbooks_challenge(expiration_date, deposits)
.await?)
}
}
@@ -7,7 +7,8 @@ use async_trait::async_trait;
use nym_api_requests::ecash::models::{
AggregatedCoinIndicesSignatureResponse, AggregatedExpirationDateSignatureResponse,
BatchRedeemTicketsBody, EcashBatchTicketRedemptionResponse, EcashTicketVerificationResponse,
VerifyEcashTicketBody,
IssuedTicketbooksChallengeRequest, IssuedTicketbooksChallengeResponse,
IssuedTicketbooksForResponse, VerifyEcashTicketBody,
};
use nym_api_requests::ecash::VerificationKeyResponse;
use nym_api_requests::models::{
@@ -18,11 +19,7 @@ use nym_api_requests::nym_nodes::PaginatedCachedNodesResponse;
use nym_api_requests::pagination::PaginatedResponse;
pub use nym_api_requests::{
ecash::{
models::{
EpochCredentialsResponse, IssuedCredentialResponse, IssuedCredentialsResponse,
IssuedTicketbook, IssuedTicketbookBody, SpentCredentialsResponse,
},
BlindSignRequestBody, BlindedSignatureResponse, CredentialsRequestBody,
models::SpentCredentialsResponse, BlindSignRequestBody, BlindedSignatureResponse,
PartialCoinIndicesSignatureResponse, PartialExpirationDateSignatureResponse,
VerifyEcashCredentialBody,
},
@@ -37,6 +34,7 @@ pub use nym_api_requests::{
};
pub use nym_coconut_dkg_common::types::EpochId;
use nym_contracts_common::IdentityKey;
use nym_ecash_contract_common::deposit::DepositId;
pub use nym_http_api_client::Client;
use nym_http_api_client::{ApiClient, NO_PARAMS};
use nym_mixnet_contract_common::mixnode::MixNodeDetails;
@@ -912,62 +910,44 @@ pub trait NymApiClientExt: ApiClient {
&[
routes::API_VERSION,
routes::ECASH_ROUTES,
routes::ecash::MASTER_VERIFICATION_KEY,
ecash::MASTER_VERIFICATION_KEY,
],
&params,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn epoch_credentials(
async fn issued_ticketbooks_for(
&self,
dkg_epoch: EpochId,
) -> Result<EpochCredentialsResponse, NymAPIError> {
expiration_date: Date,
) -> Result<IssuedTicketbooksForResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_EPOCH_CREDENTIALS,
&dkg_epoch.to_string(),
routes::ECASH_ISSUED_TICKETBOOKS_FOR,
&expiration_date.to_string(),
],
NO_PARAMS,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn issued_credential(
async fn issued_ticketbooks_challenge(
&self,
credential_id: i64,
) -> Result<IssuedCredentialResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_CREDENTIAL,
&credential_id.to_string(),
],
NO_PARAMS,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn issued_credentials(
&self,
credential_ids: Vec<i64>,
) -> Result<IssuedCredentialsResponse, NymAPIError> {
expiration_date: Date,
deposits: Vec<DepositId>,
) -> Result<IssuedTicketbooksChallengeResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_CREDENTIALS,
routes::ECASH_ISSUED_TICKETBOOKS_CHALLENGE,
],
NO_PARAMS,
&CredentialsRequestBody {
credential_ids,
pagination: None,
&IssuedTicketbooksChallengeRequest {
expiration_date,
deposits,
},
)
.await
@@ -27,9 +27,8 @@ pub mod ecash {
pub const PARTIAL_COIN_INDICES_SIGNATURES: &str = "partial-coin-indices-signatures";
pub const GLOBAL_COIN_INDICES_SIGNATURES: &str = "aggregated-coin-indices-signatures";
pub const MASTER_VERIFICATION_KEY: &str = "master-verification-key";
pub const ECASH_EPOCH_CREDENTIALS: &str = "epoch-credentials";
pub const ECASH_ISSUED_CREDENTIAL: &str = "issued-credential";
pub const ECASH_ISSUED_CREDENTIALS: &str = "issued-credentials";
pub const ECASH_ISSUED_TICKETBOOKS_FOR: &str = "issued-ticketbooks-for";
pub const ECASH_ISSUED_TICKETBOOKS_CHALLENGE: &str = "issued-ticketbooks-challenge";
pub const EXPIRATION_DATE_PARAM: &str = "expiration_date";
pub const EPOCH_ID_PARAM: &str = "epoch_id";
@@ -7,6 +7,7 @@ use crate::nyxd::error::NyxdError;
use crate::nyxd::CosmWasmClient;
use async_trait::async_trait;
use cosmwasm_std::Coin;
use nym_ecash_contract_common::deposit::LatestDepositResponse;
use nym_ecash_contract_common::msg::QueryMsg as EcashQueryMsg;
use serde::Deserialize;
@@ -51,6 +52,11 @@ pub trait EcashQueryClient {
.await
}
async fn get_latest_deposit(&self) -> Result<LatestDepositResponse, NyxdError> {
self.query_ecash_contract(EcashQueryMsg::GetLatestDeposit {})
.await
}
async fn get_deposits_paged(
&self,
start_after: Option<u32>,
@@ -98,7 +104,6 @@ where
mod tests {
use super::*;
use crate::nyxd::contract_traits::tests::IgnoreValue;
use nym_ecash_contract_common::msg::QueryMsg;
// it's enough that this compiles and clippy is happy about it
#[allow(dead_code)]
@@ -110,14 +115,17 @@ mod tests {
EcashQueryMsg::GetBlacklistedAccount { public_key } => {
client.get_blacklisted_account(public_key).ignore()
}
QueryMsg::GetBlacklistPaged { limit, start_after } => {
EcashQueryMsg::GetBlacklistPaged { limit, start_after } => {
client.get_blacklist_paged(start_after, limit).ignore()
}
QueryMsg::GetDeposit { deposit_id } => client.get_deposit(deposit_id).ignore(),
QueryMsg::GetDepositsPaged { limit, start_after } => {
EcashQueryMsg::GetDeposit { deposit_id } => client.get_deposit(deposit_id).ignore(),
EcashQueryMsg::GetDepositsPaged { limit, start_after } => {
client.get_deposits_paged(start_after, limit).ignore()
}
QueryMsg::GetRequiredDepositAmount {} => client.get_required_deposit_amount().ignore(),
EcashQueryMsg::GetRequiredDepositAmount {} => {
client.get_required_deposit_amount().ignore()
}
EcashQueryMsg::GetLatestDeposit {} => client.get_latest_deposit().ignore(),
};
}
}
@@ -303,7 +303,7 @@ where
feature = "tendermint-rpc-http-client",
feature = "tendermint-rpc-websocket-client"
))]
async fn wait_until_healthy<T>(&self, timeout: T) -> Result<(), Error>
async fn wait_until_healthy<T>(&self, timeout: T) -> Result<(), TendermintRpcError>
where
T: Into<core::time::Duration> + Send,
{
@@ -823,7 +823,7 @@ where
feature = "tendermint-rpc-http-client",
feature = "tendermint-rpc-websocket-client"
))]
async fn wait_until_healthy<T>(&self, timeout: T) -> Result<(), Error>
async fn wait_until_healthy<T>(&self, timeout: T) -> Result<(), TendermintRpcError>
where
T: Into<core::time::Duration> + Send,
{
@@ -523,7 +523,7 @@ mod non_wasm {
))]
async fn wait_until_healthy<T>(&self, timeout: T) -> Result<(), Error>
where
T: Into<Duration> + Send,
T: Into<core::time::Duration> + Send,
{
self.wait_until_healthy(timeout).await
}
@@ -6,6 +6,7 @@ use crate::utils::CommonConfigsWrapper;
use anyhow::{anyhow, bail};
use clap::ArgGroup;
use clap::Parser;
use log::info;
use nym_credential_storage::initialise_persistent_storage;
use nym_credential_storage::storage::Storage;
use nym_credential_utils::utils;
@@ -150,6 +151,7 @@ async fn issue_to_file(args: Args, client: SigningClient) -> anyhow::Result<()>
exported = exported.with_master_verification_key(&EpochVerificationKey { epoch_id, key });
}
info!("the issued ticketbook has expiration of {expiration_date}");
let data = exported.pack().data;
if args.bs58_output {
@@ -47,6 +47,12 @@ impl Deposit {
}
}
#[cw_serde]
#[derive(Default)]
pub struct LatestDepositResponse {
pub deposit: Option<DepositData>,
}
#[cw_serde]
pub struct DepositResponse {
pub id: DepositId,
@@ -1,4 +1,4 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use cosmwasm_schema::cw_serde;
@@ -7,7 +7,7 @@ use cosmwasm_std::Coin;
#[cfg(feature = "schema")]
use crate::blacklist::{BlacklistedAccountResponse, PagedBlacklistedAccountResponse};
#[cfg(feature = "schema")]
use crate::deposit::{DepositResponse, PagedDepositsResponse};
use crate::deposit::{DepositResponse, LatestDepositResponse, PagedDepositsResponse};
#[cfg(feature = "schema")]
use cosmwasm_schema::QueryResponses;
@@ -73,6 +73,9 @@ pub enum QueryMsg {
#[cfg_attr(feature = "schema", returns(DepositResponse))]
GetDeposit { deposit_id: u32 },
#[cfg_attr(feature = "schema", returns(LatestDepositResponse))]
GetLatestDeposit {},
#[cfg_attr(feature = "schema", returns(PagedDepositsResponse))]
GetDepositsPaged {
limit: Option<u32>,
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "nym-ticketbooks-merkle"
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]
sha2 = { workspace = true }
rs_merkle = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
time = { workspace = true }
nym-credentials-interface = { path = "../credentials-interface" }
nym-serde-helpers = { path = "../serde-helpers", features = ["date", "base64", "hex"] }
[dev-dependencies]
rand_chacha = { workspace = true }
rand = { workspace = true }
serde_json = { workspace = true }
+366
View File
@@ -0,0 +1,366 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#![warn(clippy::expect_used)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::todo)]
#![warn(clippy::dbg_macro)]
use nym_credentials_interface::TicketType;
use rs_merkle::algorithms::Sha256;
use rs_merkle::{MerkleProof, MerkleTree};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::fmt::{Debug, Formatter};
use time::Date;
// no point in importing the entire contract commons just for this one type
pub type DepositId = u32;
pub type DKGEpochId = u64;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssuedTicketbook {
pub deposit_id: DepositId,
pub epoch_id: DKGEpochId,
// 96 bytes serialised 'BlindedSignature'
#[schemars(with = "String")]
#[serde(with = "nym_serde_helpers::base64")]
pub blinded_partial_credential: Vec<u8>,
// concatenated bytes for the commitments to the private attributes
#[schemars(with = "String")]
#[serde(with = "nym_serde_helpers::base64")]
pub joined_encoded_private_attributes_commitments: Vec<u8>,
#[schemars(with = "String")]
#[serde(with = "nym_serde_helpers::date")]
pub expiration_date: Date,
#[schemars(with = "String")]
pub ticketbook_type: TicketType,
}
impl IssuedTicketbook {
pub fn hash_to_merkle_leaf(&self) -> [u8; 32] {
let mut hasher = sha2::Sha256::new();
hasher.update(self.deposit_id.to_be_bytes());
hasher.update(self.epoch_id.to_be_bytes());
hasher.update(&self.blinded_partial_credential);
hasher.update(&self.joined_encoded_private_attributes_commitments);
hasher.update(self.expiration_date.to_julian_day().to_be_bytes());
hasher.update(self.ticketbook_type.encode().to_be_bytes());
hasher.finalize().into()
}
pub fn signable_plaintext(&self) -> Vec<u8> {
self.deposit_id
.to_be_bytes()
.into_iter()
.chain(self.epoch_id.to_be_bytes())
.chain(self.blinded_partial_credential.iter().copied())
.chain(
self.joined_encoded_private_attributes_commitments
.iter()
.copied(),
)
.chain(self.expiration_date.to_julian_day().to_be_bytes())
.chain(self.ticketbook_type.encode().to_be_bytes())
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsertedMerkleLeaf {
#[serde(with = "nym_serde_helpers::hex")]
pub new_root: Vec<u8>,
pub leaf: MerkleLeaf,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialOrd, PartialEq, Eq)]
pub struct MerkleLeaf {
#[schemars(with = "String")]
#[serde(with = "nym_serde_helpers::hex")]
pub hash: Vec<u8>,
pub index: usize,
}
#[derive(Default, Clone)]
pub struct IssuedTicketbooksMerkleTree {
inner: MerkleTree<Sha256>,
}
impl IssuedTicketbooksMerkleTree {
pub fn new() -> IssuedTicketbooksMerkleTree {
IssuedTicketbooksMerkleTree {
inner: MerkleTree::new(),
}
}
pub fn rebuild(leaves: &[[u8; 32]]) -> IssuedTicketbooksMerkleTree {
IssuedTicketbooksMerkleTree {
inner: MerkleTree::from_leaves(leaves),
}
}
pub fn all_leaves(&self) -> Option<Vec<[u8; 32]>> {
self.inner.leaves()
}
pub fn insert(&mut self, issued: &IssuedTicketbook) -> InsertedMerkleLeaf {
let hash = issued.hash_to_merkle_leaf();
self.insert_leaf(hash)
}
#[allow(clippy::unwrap_used)]
pub fn insert_leaf(&mut self, leaf_hash: [u8; 32]) -> InsertedMerkleLeaf {
let leaves = self.inner.leaves_len();
self.inner.insert(leaf_hash).commit();
InsertedMerkleLeaf {
// SAFETY: after inserting at least a single node, the root will always be available
new_root: self.inner.root().unwrap().to_vec(),
leaf: MerkleLeaf {
hash: leaf_hash.to_vec(),
index: leaves,
},
}
}
pub fn rollback(&mut self) {
self.inner.rollback();
}
pub fn root(&self) -> Option<[u8; 32]> {
self.inner.root()
}
pub fn generate_proof(
&self,
leaf_indices: &[usize],
) -> Option<IssuedTicketbooksFullMerkleProof> {
let leaves = self.inner.leaves()?;
let mut included_leaves = Vec::new();
for &index in leaf_indices {
let hash = *leaves.get(index)?;
included_leaves.push(MerkleLeaf {
hash: hash.to_vec(),
index,
})
}
Some(IssuedTicketbooksFullMerkleProof {
inner_proof: self.inner.proof(leaf_indices),
included_leaves,
total_leaves: self.inner.leaves_len(),
root: self.inner.root()?.to_vec(),
})
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct IssuedTicketbooksFullMerkleProof {
#[schemars(with = "String")]
#[serde(with = "inner_proof_base64_serde")]
inner_proof: MerkleProof<Sha256>,
included_leaves: Vec<MerkleLeaf>,
total_leaves: usize,
#[schemars(with = "String")]
#[serde(with = "nym_serde_helpers::hex")]
root: Vec<u8>,
}
impl Debug for IssuedTicketbooksFullMerkleProof {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IssuedTicketbooksFullMerkleProof")
.field("inner_proof", &self.inner_proof.proof_hashes_hex())
.field("included_leaves", &self.included_leaves)
.field("total_leaves", &self.total_leaves)
.field("root", &self.root)
.finish()
}
}
impl Clone for IssuedTicketbooksFullMerkleProof {
fn clone(&self) -> Self {
IssuedTicketbooksFullMerkleProof {
inner_proof: MerkleProof::new(self.inner_proof.proof_hashes().to_vec()),
included_leaves: self.included_leaves.clone(),
total_leaves: self.total_leaves,
root: self.root.clone(),
}
}
}
impl IssuedTicketbooksFullMerkleProof {
pub fn contains_leaf_hash(&self, hash: [u8; 32]) -> bool {
self.included_leaves.iter().any(|m| m.hash == hash)
}
pub fn contains_full_leaf(&self, leaf: &MerkleLeaf) -> bool {
self.included_leaves.iter().any(|m| m == leaf)
}
pub fn total_leaves(&self) -> usize {
self.total_leaves
}
pub fn verify(&self, expected_root: [u8; 32]) -> bool {
if self.root != expected_root {
return false;
}
let mut leaf_indices = Vec::with_capacity(self.included_leaves.len());
let mut leaf_hashes = Vec::with_capacity(self.included_leaves.len());
for leaf in &self.included_leaves {
leaf_indices.push(leaf.index);
let Ok(sha256_hash) = leaf.hash.clone().try_into() else {
return false;
};
leaf_hashes.push(sha256_hash);
}
self.inner_proof.verify(
expected_root,
&leaf_indices,
&leaf_hashes,
self.total_leaves,
)
}
}
mod inner_proof_base64_serde {
use rs_merkle::algorithms::Sha256;
use rs_merkle::proof_serializers::DirectHashesOrder;
use rs_merkle::MerkleProof;
use serde::{Deserializer, Serializer};
pub fn serialize<S: Serializer>(
proof: &MerkleProof<Sha256>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let bytes = proof.serialize::<DirectHashesOrder>();
nym_serde_helpers::base64::serialize(&bytes, serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<MerkleProof<Sha256>, D::Error> {
let bytes = nym_serde_helpers::base64::deserialize(deserializer)?;
MerkleProof::<Sha256>::deserialize::<DirectHashesOrder>(&bytes)
.map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nym_credentials_interface::ecash_today;
use rand::{RngCore, SeedableRng};
fn test_rng() -> rand_chacha::ChaChaRng {
let dummy_seed = [42u8; 32];
rand_chacha::ChaCha20Rng::from_seed(dummy_seed)
}
fn dummy_issued(rng: &mut rand_chacha::ChaCha20Rng) -> IssuedTicketbook {
let mut blinded_partial_credential = vec![0u8; 42];
rng.fill_bytes(&mut blinded_partial_credential);
let mut joined_encoded_private_attributes_commitments = vec![0u8; 48 * 3];
rng.fill_bytes(&mut joined_encoded_private_attributes_commitments);
IssuedTicketbook {
deposit_id: rng.next_u32(),
epoch_id: rng.next_u64(),
blinded_partial_credential,
joined_encoded_private_attributes_commitments,
expiration_date: ecash_today().date(),
ticketbook_type: TicketType::V1MixnetEntry,
}
}
#[test]
fn single_leaf() {
let mut rng = test_rng();
let issued = dummy_issued(&mut rng);
let expected_hash = issued.hash_to_merkle_leaf();
let mut tree = IssuedTicketbooksMerkleTree::new();
let inserted_node = tree.insert(&issued);
assert_eq!(inserted_node.leaf.index, 0);
assert_eq!(inserted_node.leaf.hash, expected_hash);
assert_eq!(inserted_node.new_root, expected_hash);
let proof = tree.generate_proof(&[0]).unwrap();
assert!(proof.verify(expected_hash));
assert_eq!(proof.total_leaves, 1);
assert_eq!(proof.included_leaves, vec![inserted_node.leaf]);
assert_eq!(proof.root, expected_hash);
}
#[test]
fn multiple_leaves() {
let mut rng = test_rng();
let mut tree = IssuedTicketbooksMerkleTree::new();
for i in 0..100 {
let issued = dummy_issued(&mut rng);
let expected_hash = issued.hash_to_merkle_leaf();
let inserted_node = tree.insert(&issued);
assert_eq!(inserted_node.leaf.index, i);
assert_eq!(inserted_node.leaf.hash, expected_hash);
// proof for this single node
let proof = tree.generate_proof(&[i]).unwrap();
assert!(proof.verify(tree.root().unwrap()));
assert_eq!(proof.total_leaves, i + 1);
assert!(proof.contains_leaf_hash(expected_hash));
}
// proof for multiple nodes
let indices = [0, 5, 42, 69, 74, 99];
let all_leaves = tree.inner.leaves().unwrap();
let big_proof = tree.generate_proof(&indices).unwrap();
for &index in &indices {
let leaf_hash = all_leaves.get(index).unwrap();
assert!(big_proof.contains_leaf_hash(*leaf_hash));
}
assert!(big_proof.verify(tree.root().unwrap()))
}
#[test]
fn merkle_proof_serialisation_roundtrip() {
let mut rng = test_rng();
let mut tree = IssuedTicketbooksMerkleTree::new();
for _ in 0..100 {
let issued = dummy_issued(&mut rng);
tree.insert(&issued);
}
let indices = [0, 5, 42, 69, 74, 99];
let big_proof = tree.generate_proof(&indices).unwrap();
let bytes = serde_json::to_vec(&big_proof).unwrap();
let recovered: IssuedTicketbooksFullMerkleProof = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
big_proof.inner_proof.proof_hashes(),
recovered.inner_proof.proof_hashes()
);
assert_eq!(big_proof.included_leaves, recovered.included_leaves);
assert_eq!(big_proof.total_leaves, recovered.total_leaves);
assert_eq!(big_proof.root, recovered.root);
}
}
+63
View File
@@ -328,6 +328,19 @@
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_latest_deposit"
],
"properties": {
"get_latest_deposit": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
@@ -579,6 +592,56 @@
}
}
},
"get_latest_deposit": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LatestDepositResponse",
"type": "object",
"properties": {
"deposit": {
"anyOf": [
{
"$ref": "#/definitions/DepositData"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"definitions": {
"Deposit": {
"type": "object",
"required": [
"bs58_encoded_ed25519_pubkey"
],
"properties": {
"bs58_encoded_ed25519_pubkey": {
"type": "string"
}
},
"additionalProperties": false
},
"DepositData": {
"type": "object",
"required": [
"deposit",
"id"
],
"properties": {
"deposit": {
"$ref": "#/definitions/Deposit"
},
"id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false
}
}
},
"get_required_deposit_amount": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Coin",
+13
View File
@@ -88,6 +88,19 @@
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_latest_deposit"
],
"properties": {
"get_latest_deposit": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
@@ -0,0 +1,50 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LatestDepositResponse",
"type": "object",
"properties": {
"deposit": {
"anyOf": [
{
"$ref": "#/definitions/DepositData"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"definitions": {
"Deposit": {
"type": "object",
"required": [
"bs58_encoded_ed25519_pubkey"
],
"properties": {
"bs58_encoded_ed25519_pubkey": {
"type": "string"
}
},
"additionalProperties": false
},
"DepositData": {
"type": "object",
"required": [
"deposit",
"id"
],
"properties": {
"deposit": {
"$ref": "#/definitions/Deposit"
},
"id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false
}
}
}
+22 -1
View File
@@ -17,7 +17,9 @@ use nym_ecash_contract_common::blacklist::{
BlacklistedAccount, BlacklistedAccountResponse, Blacklisting, PagedBlacklistedAccountResponse,
};
use nym_ecash_contract_common::counters::PoolCounters;
use nym_ecash_contract_common::deposit::{DepositData, DepositResponse, PagedDepositsResponse};
use nym_ecash_contract_common::deposit::{
DepositData, DepositResponse, LatestDepositResponse, PagedDepositsResponse,
};
use nym_ecash_contract_common::events::{
DEPOSITED_FUNDS_EVENT_TYPE, DEPOSIT_ID, PROPOSAL_ID_ATTRIBUTE_NAME,
};
@@ -177,6 +179,25 @@ impl NymEcashContract<'_> {
})
}
#[msg(query)]
pub fn get_latest_deposit(
&self,
ctx: QueryCtx,
) -> Result<LatestDepositResponse, EcashContractError> {
let Some(latest_id) = self.deposits.latest_deposit(ctx.deps.storage)? else {
return Ok(LatestDepositResponse::default());
};
let maybe_deposit = self.deposits.try_load_by_id(ctx.deps.storage, latest_id)?;
Ok(LatestDepositResponse {
deposit: maybe_deposit.map(|deposit| DepositData {
id: latest_id,
deposit,
}),
})
}
#[msg(query)]
pub fn get_deposits_paged(
&self,
+32
View File
@@ -19,6 +19,15 @@ impl<'a> DepositStorage<'a> {
}
}
pub fn latest_deposit(
&self,
storage: &dyn Storage,
) -> Result<Option<DepositId>, EcashContractError> {
self.deposit_id_counter
.may_load(storage)
.map_err(Into::into)
}
fn next_id(&self, store: &mut dyn Storage) -> Result<DepositId, EcashContractError> {
let id: DepositId = self.deposit_id_counter.may_load(store)?.unwrap_or_default();
let next_id = id + 1;
@@ -107,6 +116,29 @@ mod tests {
use cosmwasm_std::testing::mock_dependencies;
use nym_crypto::asymmetric::ed25519;
#[test]
fn getting_latest_deposit() -> anyhow::Result<()> {
let mut deps = mock_dependencies();
let storage = DepositStorage::new();
// it's `None` for fresh storage
let first = storage.latest_deposit(deps.as_ref().storage)?;
assert!(first.is_none());
let _ = storage.next_id(deps.as_mut().storage)?;
// is correctly incremented for each subsequent id
let second = storage.latest_deposit(deps.as_ref().storage)?;
assert_eq!(second, Some(1));
let _ = storage.next_id(deps.as_mut().storage)?;
let third = storage.latest_deposit(deps.as_ref().storage)?;
assert_eq!(third, Some(2));
Ok(())
}
#[test]
fn iterating_over_deposits() {
let mut deps = mock_dependencies();
+1
View File
@@ -119,6 +119,7 @@ nym-node-requests = { path = "../nym-node/nym-node-requests" }
nym-types = { path = "../common/types" }
nym-http-api-common = { path = "../common/http-api-common", features = ["utoipa"] }
nym-serde-helpers = { path = "../common/serde-helpers", features = ["date"] }
nym-ticketbooks-merkle = { path = "../common/ticketbooks-merkle" }
nym-statistics-common = {path ="../common/statistics" }
[features]
@@ -0,0 +1,28 @@
/*
* Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: Apache-2.0
*/
-- remove old tables as they don't have the required information
DROP TABLE epoch_credentials;
DROP TABLE issued_ticketbook;
CREATE TABLE issued_ticketbook
(
deposit_id INTEGER NOT NULL PRIMARY KEY,
dkg_epoch_id INTEGER NOT NULL,
blinded_partial_credential BLOB NOT NULL,
joined_private_commitments BLOB NOT NULL,
expiration_date DATE NOT NULL,
ticketbook_type_repr INTEGER NOT NULL,
-- hash on the whole data as in what has been inserted into the merkle tree
merkle_leaf BLOB NOT NULL,
-- index of the leaf under which the data has been inserted
merkle_index INTEGER NOT NULL
);
-- helper index for getting tickets issued with particular expiration date for easier proof construction
CREATE INDEX issued_ticketbook_date ON issued_ticketbook (expiration_date);
+5
View File
@@ -34,6 +34,11 @@ nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts
nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract" }
nym-node-requests = { path = "../../nym-node/nym-node-requests", default-features = false, features = ["openapi"] }
nym-network-defaults = { path = "../../common/network-defaults" }
nym-ticketbooks-merkle = { path = "../../common/ticketbooks-merkle" }
[dev-dependencies]
rand_chacha = { workspace = true }
nym-crypto = { path = "../../common/crypto", features = ["rand"] }
[features]
@@ -1,34 +0,0 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_compact_ecash::BlindedSignature;
use nym_credentials_interface::TicketType;
use nym_ecash_time::EcashTime;
use std::iter::once;
use time::Date;
// recomputes plaintext on the credential nym-api has used for signing
//
// note: this method doesn't have to be reversible so just naively concatenate everything
pub fn issued_credential_plaintext(
epoch_id: u32,
deposit_id: u32,
blinded_partial_credential: &BlindedSignature,
encoded_private_attributes_commitments: &[Vec<u8>],
expiration_date: Date,
ticketbook_type: TicketType,
) -> Vec<u8> {
epoch_id
.to_be_bytes()
.into_iter()
.chain(deposit_id.to_be_bytes())
.chain(blinded_partial_credential.to_bytes())
.chain(
encoded_private_attributes_commitments
.iter()
.flat_map(|attr| attr.iter().copied()),
)
.chain(expiration_date.ecash_unix_timestamp().to_be_bytes())
.chain(once(ticketbook_type.to_repr() as u8))
.collect()
}
+2 -4
View File
@@ -1,11 +1,9 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod helpers;
pub mod models;
pub use models::{
BlindSignRequestBody, BlindedSignatureResponse, CredentialsRequestBody,
PartialCoinIndicesSignatureResponse, PartialExpirationDateSignatureResponse,
VerificationKeyResponse, VerifyEcashCredentialBody,
BlindSignRequestBody, BlindedSignatureResponse, PartialCoinIndicesSignatureResponse,
PartialExpirationDateSignatureResponse, VerificationKeyResponse, VerifyEcashCredentialBody,
};
+177 -58
View File
@@ -1,18 +1,19 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::ecash::helpers::issued_credential_plaintext;
use crate::helpers::PlaceholderJsonSchemaImpl;
use cosmrs::AccountId;
use nym_compact_ecash::scheme::coin_indices_signatures::AnnotatedCoinIndexSignature;
use nym_compact_ecash::scheme::expiration_date_signatures::AnnotatedExpirationDateSignature;
use nym_compact_ecash::Bytable;
use nym_compact_ecash::utils::try_deserialize_g1_projective;
use nym_compact_ecash::{Bytable, G1Projective};
use nym_credentials_interface::TicketType;
use nym_credentials_interface::{
BlindedSignature, CompactEcashError, CredentialSpendingData, PublicKeyUser,
VerificationKeyAuth, WithdrawalRequest,
};
use nym_crypto::asymmetric::identity;
use nym_crypto::asymmetric::{ed25519, identity};
use nym_ticketbooks_merkle::{IssuedTicketbook, IssuedTicketbooksFullMerkleProof};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::Digest;
@@ -22,7 +23,7 @@ use thiserror::Error;
use time::Date;
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, Clone, JsonSchema)]
#[derive(Serialize, Deserialize, Clone, JsonSchema, ToSchema)]
pub struct VerifyEcashTicketBody {
/// The cryptographic material required for spending the underlying credential.
#[schemars(with = "PlaceholderJsonSchemaImpl")]
@@ -33,7 +34,7 @@ pub struct VerifyEcashTicketBody {
pub gateway_cosmos_addr: AccountId,
}
#[derive(Serialize, Deserialize, Clone, JsonSchema)]
#[derive(Serialize, Deserialize, Clone, JsonSchema, ToSchema)]
pub struct VerifyEcashCredentialBody {
/// The cryptographic material required for spending the underlying credential.
#[schemars(with = "PlaceholderJsonSchemaImpl")]
@@ -105,7 +106,7 @@ pub enum EcashTicketVerificationRejection {
}
// All strings are base58 encoded representations of structs
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, JsonSchema)]
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, JsonSchema, ToSchema)]
pub struct BlindSignRequestBody {
#[schemars(with = "PlaceholderJsonSchemaImpl")]
pub inner_sign_request: WithdrawalRequest,
@@ -147,13 +148,36 @@ impl BlindSignRequestBody {
}
}
pub fn encode_commitments(&self) -> Vec<Vec<u8>> {
pub fn encode_join_commitments(&self) -> Vec<u8> {
self.inner_sign_request
.get_private_attributes_commitments()
.iter()
.map(|c| c.to_byte_vec())
.flat_map(|c| c.to_byte_vec())
.collect()
}
pub fn try_decode_joined_commitments(
joined: &[u8],
) -> Result<Vec<G1Projective>, CompactEcashError> {
if joined.len() % 48 != 0 {
// that's not the most ideal error variant, but creating dedicated error type would have been an overkill
return Err(CompactEcashError::DeserializationLengthMismatch {
type_name: "joined commitments".to_string(),
expected: (joined.len() / 48) * 48,
actual: joined.len(),
});
};
let mut commitments = Vec::new();
for chunk in joined.chunks_exact(48) {
//SAFETY : we're taking chunks of 48 bytes
#[allow(clippy::unwrap_used)]
let bytes: &[u8; 48] = chunk.try_into().unwrap();
commitments.push(try_deserialize_g1_projective(bytes)?);
}
Ok(commitments)
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -187,7 +211,7 @@ impl BlindedSignatureResponse {
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[derive(Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct MasterVerificationKeyResponse {
#[schemars(with = "PlaceholderJsonSchemaImpl")]
pub key: VerificationKeyAuth,
@@ -273,16 +297,6 @@ pub struct Pagination<T> {
pub limit: Option<u32>,
}
#[derive(Clone, Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CredentialsRequestBody {
/// Explicit ids of the credentials to retrieve. Note: it can't be set alongside pagination.
pub credential_ids: Vec<i64>,
/// Pagination settings for retrieving credentials. Note: it can't be set alongside explicit ids.
pub pagination: Option<Pagination<i64>>,
}
#[derive(Clone, Serialize, Deserialize, Debug, JsonSchema, PartialEq)]
pub struct SerialNumberWrapper(
#[serde(with = "nym_serde_helpers::bs58")]
@@ -372,74 +386,136 @@ impl SpentCredentialsResponse {
}
}
#[derive(Clone, Serialize, Deserialize, Debug, JsonSchema, ToSchema)]
pub type DepositId = u32;
#[derive(Clone, Serialize, Deserialize, Debug, JsonSchema, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EpochCredentialsResponse {
pub epoch_id: u64,
pub first_epoch_credential_id: Option<i64>,
pub total_issued: u32,
pub struct CommitedDeposit {
pub deposit_id: DepositId,
pub merkle_index: usize,
}
#[derive(Clone, Serialize, Deserialize, Debug, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssuedCredentialsResponse {
// note: BTreeMap returns ordered results, so it's fine to use it with pagination
pub credentials: BTreeMap<i64, IssuedTicketbookBody>,
pub struct IssuedTicketbooksForResponseBody {
#[schemars(with = "String")]
#[serde(with = "crate::helpers::date_serde")]
pub expiration_date: Date,
pub deposits: Vec<CommitedDeposit>,
pub merkle_root: Option<[u8; 32]>,
}
impl IssuedTicketbooksForResponseBody {
pub fn plaintext(&self) -> Vec<u8> {
#[allow(clippy::unwrap_used)]
serde_json::to_vec(self).unwrap()
}
pub fn sign(self, key: &ed25519::PrivateKey) -> IssuedTicketbooksForResponse {
IssuedTicketbooksForResponse {
signature: key.sign(self.plaintext()),
body: self,
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssuedCredentialResponse {
pub credential: Option<IssuedTicketbookBody>,
}
pub struct IssuedTicketbooksForResponse {
pub body: IssuedTicketbooksForResponseBody,
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssuedTicketbookBody {
pub credential: IssuedTicketbook,
/// Signature on the body
#[schemars(with = "PlaceholderJsonSchemaImpl")]
pub signature: identity::Signature,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, JsonSchema)]
impl IssuedTicketbooksForResponse {
pub fn verify_signature(&self, pub_key: &ed25519::PublicKey) -> bool {
pub_key
.verify(self.body.plaintext(), &self.signature)
.is_ok()
}
}
#[derive(Serialize, Deserialize, JsonSchema, ToSchema, Debug)]
#[serde(rename_all = "camelCase")]
pub struct IssuedTicketbook {
pub id: i64,
pub epoch_id: u32,
pub deposit_id: u32,
// NOTE: if we find creation of this guy takes too long,
// change `BlindedSignature` to `BlindedSignatureBytes`
// so that nym-api wouldn't need to parse the value out of its storage
#[schemars(with = "PlaceholderJsonSchemaImpl")]
pub blinded_partial_credential: BlindedSignature,
pub encoded_private_attributes_commitments: Vec<Vec<u8>>,
pub struct IssuedTicketbooksChallengeRequest {
#[schemars(with = "String")]
#[serde(with = "crate::helpers::date_serde")]
pub expiration_date: Date,
pub deposits: Vec<DepositId>,
}
#[derive(Serialize, Deserialize, JsonSchema, ToSchema, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct IssuedTicketbooksChallengeResponseBody {
#[schemars(with = "String")]
#[serde(with = "crate::helpers::date_serde")]
pub expiration_date: Date,
#[schemars(with = "String")]
pub ticketbook_type: TicketType,
pub partial_ticketbooks: BTreeMap<DepositId, IssuedTicketbook>,
pub merkle_proof: IssuedTicketbooksFullMerkleProof,
}
impl IssuedTicketbook {
// this method doesn't have to be reversible so just naively concatenate everything
pub fn signable_plaintext(&self) -> Vec<u8> {
issued_credential_plaintext(
self.epoch_id,
self.deposit_id,
&self.blinded_partial_credential,
&self.encoded_private_attributes_commitments,
self.expiration_date,
self.ticketbook_type,
impl IssuedTicketbooksChallengeResponseBody {
pub fn plaintext(&self) -> Vec<u8> {
#[allow(clippy::unwrap_used)]
serde_json::to_vec(self).unwrap()
}
pub fn sign(self, key: &ed25519::PrivateKey) -> IssuedTicketbooksChallengeResponse {
IssuedTicketbooksChallengeResponse {
signature: key.sign(self.plaintext()),
body: self,
}
}
// helpers so that library consumers wouldn't need to import all the required dependencies
pub fn try_get_partial_credential(
issued: &IssuedTicketbook,
) -> Result<BlindedSignature, CompactEcashError> {
BlindedSignature::from_bytes(&issued.blinded_partial_credential)
}
pub fn try_get_private_attributes_commitments(
issued: &IssuedTicketbook,
) -> Result<Vec<G1Projective>, CompactEcashError> {
BlindSignRequestBody::try_decode_joined_commitments(
&issued.joined_encoded_private_attributes_commitments,
)
}
}
#[derive(Serialize, Deserialize, JsonSchema, ToSchema, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct IssuedTicketbooksChallengeResponse {
pub body: IssuedTicketbooksChallengeResponseBody,
#[schemars(with = "PlaceholderJsonSchemaImpl")]
pub signature: identity::Signature,
}
impl IssuedTicketbooksChallengeResponse {
pub fn verify_signature(&self, pub_key: &ed25519::PublicKey) -> bool {
pub_key
.verify(self.body.plaintext(), &self.signature)
.is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use nym_compact_ecash::scheme::keygen::KeyPairUser;
use nym_compact_ecash::withdrawal_request;
use nym_ecash_time::{ecash_today_date, EcashTime};
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
pub fn test_rng() -> ChaCha20Rng {
let dummy_seed = [42u8; 32];
ChaCha20Rng::from_seed(dummy_seed)
}
// had some issues with `Date` and serde...
// so might as well leave this unit test in case we do something to the helper
@@ -461,4 +537,47 @@ mod tests {
let de: BatchRedeemTicketsBody = serde_json::from_slice(&bytes).unwrap();
assert_eq!(req, de);
}
#[test]
fn decoding_attribute_commitments() {
let mut rng = test_rng();
let keys = ed25519::KeyPair::new(&mut rng);
let dummy_sig = keys.private_key().sign("foomp");
let dummy_keypair = KeyPairUser::new();
let date = ecash_today_date();
let typ = TicketType::V1MixnetEntry;
let (inner, _) = withdrawal_request(
dummy_keypair.secret_key(),
date.ecash_unix_timestamp(),
typ.encode(),
)
.unwrap();
let dummy = BlindSignRequestBody {
inner_sign_request: inner,
deposit_id: 42,
signature: dummy_sig,
ecash_pubkey: dummy_keypair.public_key(),
expiration_date: date,
ticketbook_type: typ,
};
let good = dummy.encode_join_commitments();
let recovered = BlindSignRequestBody::try_decode_joined_commitments(&good).unwrap();
assert_eq!(
recovered,
dummy
.inner_sign_request
.get_private_attributes_commitments()
);
let mut bad1 = good.clone();
let mut bad2 = good.clone();
bad1.push(42);
bad2.pop().unwrap();
assert!(BlindSignRequestBody::try_decode_joined_commitments(&bad1).is_err());
assert!(BlindSignRequestBody::try_decode_joined_commitments(&bad2).is_err());
}
}
-26
View File
@@ -1,32 +1,6 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::ecash::error::Result;
use crate::ecash::storage::models::IssuedTicketbook;
use nym_api_requests::ecash::models::IssuedCredentialsResponse;
use nym_api_requests::ecash::models::IssuedTicketbookBody;
use std::collections::BTreeMap;
pub(crate) fn build_credentials_response(
raw: Vec<IssuedTicketbook>,
) -> Result<IssuedCredentialsResponse> {
let mut credentials = BTreeMap::new();
for raw_credential in raw {
let id = raw_credential.id;
let api_issued = IssuedTicketbookBody::try_from(raw_credential)?;
let old = credentials.insert(id, api_issued);
if old.is_some() {
// why do we panic here rather than return an error? because it's a critical failure because
// since the raw values came directly from the database with the PRIMARY KEY constraint
// it should be IMPOSSIBLE to have duplicate values here
panic!("somehow retrieved multiple credentials with id {id} from the database!")
}
}
Ok(IssuedCredentialsResponse { credentials })
}
#[derive(serde::Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Path)]
pub(super) struct EpochIdParam {
+42 -100
View File
@@ -1,156 +1,98 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::ecash::api_routes::helpers::build_credentials_response;
use crate::ecash::error::EcashError;
use crate::ecash::state::EcashState;
use crate::ecash::storage::EcashStorageExt;
use crate::node_status_api::models::AxumResult;
use crate::support::http::state::AppState;
use axum::extract::Path;
use axum::{Json, Router};
use nym_api_requests::ecash::models::{
EpochCredentialsResponse, IssuedCredentialResponse, IssuedCredentialsResponse,
IssuedTicketbooksChallengeRequest, IssuedTicketbooksChallengeResponse,
IssuedTicketbooksForResponse,
};
use nym_api_requests::ecash::CredentialsRequestBody;
use serde::Deserialize;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use utoipa::IntoParams;
use time::Date;
use tracing::trace;
use utoipa::{IntoParams, ToSchema};
pub(crate) fn issued_routes(ecash_state: Arc<EcashState>) -> Router<AppState> {
Router::new()
.route(
"/epoch-credentials/:epoch",
"/issued-ticketbooks-for/:expiration_date",
axum::routing::get({
let ecash_state = Arc::clone(&ecash_state);
|epoch| epoch_credentials(epoch, ecash_state)
|expiration_date| issued_ticketbooks_for(expiration_date, ecash_state)
}),
)
.route(
"/issued-credential/:id",
axum::routing::get({
let ecash_state = Arc::clone(&ecash_state);
|id| issued_credential(id, ecash_state)
}),
)
.route(
"/issued-credentials",
"/issued-ticketbooks-challenge",
axum::routing::post({
let ecash_state = Arc::clone(&ecash_state);
|body| issued_credentials(body, ecash_state)
|body| issued_ticketbooks_challenge(body, ecash_state)
}),
)
}
#[derive(Deserialize, IntoParams)]
#[derive(Debug, Serialize, Deserialize, Copy, Clone, IntoParams, ToSchema, JsonSchema)]
#[into_params(parameter_in = Path)]
struct EpochParam {
epoch: u64,
pub(crate) struct ExpirationDatePathParam {
#[schema(value_type = String, example = "1970-01-01")]
#[schemars(with = "String")]
#[serde(with = "nym_serde_helpers::date")]
pub(crate) expiration_date: Date,
}
#[utoipa::path(
tag = "Ecash",
get,
params(
EpochParam
ExpirationDatePathParam
),
path = "/v1/ecash/epoch-credentials/{epoch}",
path = "/issued-ticketbooks-for/{expiration_date}",
context_path = "/v1/ecash",
responses(
(status = 200, body = EpochCredentialsResponse),
(status = 200, body = IssuedTicketbooksForResponse),
(status = 400, body = ErrorResponse, description = "this nym-api is not an ecash signer in the current epoch"),
)
)]
async fn epoch_credentials(
Path(EpochParam { epoch }): Path<EpochParam>,
async fn issued_ticketbooks_for(
Path(ExpirationDatePathParam { expiration_date }): Path<ExpirationDatePathParam>,
state: Arc<EcashState>,
) -> AxumResult<Json<EpochCredentialsResponse>> {
) -> AxumResult<Json<IssuedTicketbooksForResponse>> {
state.ensure_signer().await?;
let issued = state.aux.storage.get_epoch_credentials(epoch).await?;
let response = if let Some(issued) = issued {
issued.into()
} else {
EpochCredentialsResponse {
epoch_id: epoch,
first_epoch_credential_id: None,
total_issued: 0,
}
};
Ok(Json(response))
}
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Path)]
struct IdParam {
id: i64,
}
#[utoipa::path(
tag = "Ecash",
get,
params(
IdParam
),
path = "/v1/ecash/issued-credential/{id}",
responses(
(status = 200, body = IssuedCredentialResponse),
(status = 400, body = ErrorResponse, description = "this nym-api is not an ecash signer in the current epoch"),
)
)]
async fn issued_credential(
Path(IdParam { id }): Path<IdParam>,
state: Arc<EcashState>,
) -> AxumResult<Json<IssuedCredentialResponse>> {
state.ensure_signer().await?;
let issued = state.aux.storage.get_issued_credential(id).await?;
let credential = if let Some(issued) = issued {
Some(issued.try_into()?)
} else {
None
};
Ok(Json(IssuedCredentialResponse { credential }))
Ok(Json(
state
.get_issued_ticketbooks_deposits_on(expiration_date)
.await?
.sign(state.local.identity_keypair.private_key()),
))
}
#[utoipa::path(
tag = "Ecash",
post,
request_body = CredentialsRequestBody,
path = "/v1/ecash/issued-credentials",
request_body = IssuedTicketbooksChallengeRequest,
path = "/issued-ticketbooks-challenge",
context_path = "/v1/ecash",
responses(
(status = 200, body = IssuedCredentialsResponse),
(status = 200, body = IssuedTicketbooksChallengeResponse),
(status = 400, body = ErrorResponse, description = "this nym-api is not an ecash signer in the current epoch"),
)
)]
async fn issued_credentials(
Json(params): Json<CredentialsRequestBody>,
async fn issued_ticketbooks_challenge(
Json(challenge): Json<IssuedTicketbooksChallengeRequest>,
state: Arc<EcashState>,
) -> AxumResult<Json<IssuedCredentialsResponse>> {
) -> AxumResult<Json<IssuedTicketbooksChallengeResponse>> {
trace!("replying to ticketbooks challenge: {:?}", challenge);
state.ensure_signer().await?;
if params.pagination.is_some() && !params.credential_ids.is_empty() {
return Err(EcashError::InvalidQueryArguments.into());
}
let credentials = if let Some(pagination) = params.pagination {
Ok(Json(
state
.aux
.storage
.get_issued_credentials_paged(pagination)
.get_issued_ticketbooks(challenge)
.await?
} else {
state
.aux
.storage
.get_issued_credentials(params.credential_ids)
.await?
};
build_credentials_response(credentials)
.map(Json)
.map_err(From::from)
.sign(state.local.identity_keypair.private_key()),
))
}
+1 -6
View File
@@ -1,14 +1,9 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
// pub(crate) mod aggregation;
mod helpers;
// pub(crate) mod issued;
// pub(crate) mod partial_signing;
// pub(crate) mod spending;
pub(crate) mod aggregation;
pub(crate) mod handlers;
mod helpers;
pub(crate) mod issued;
pub(crate) mod partial_signing;
pub(crate) mod spending;
@@ -109,7 +109,7 @@ async fn post_blind_sign(
// store the information locally
debug!("storing the issued credential in the database");
state
.store_issued_credential(blind_sign_request_body, &blinded_signature)
.store_issued_ticketbook(blind_sign_request_body, &blinded_signature)
.await?;
// finally return the credential to the client
+6 -68
View File
@@ -15,42 +15,19 @@ use nym_ecash_contract_common::redeem_credential::BATCH_REDEMPTION_PROPOSAL_TITL
use nym_validator_client::coconut::EcashApiError;
use nym_validator_client::nyxd::error::NyxdError;
use nym_validator_client::nyxd::AccountId;
use std::num::ParseIntError;
use thiserror::Error;
use time::error::ComponentRange;
use time::OffsetDateTime;
pub type Result<T, E = EcashError> = std::result::Result<T, E>;
#[derive(Debug, Error)]
pub enum EcashError {
#[error("permanently restricted")]
Restricted,
#[error(transparent)]
IOError(#[from] std::io::Error),
#[error("this operation couldn't be completed as this nym-api is not an active ecash signer")]
NotASigner,
#[error("the address of the bandwidth contract hasn't been set")]
MissingBandwidthContractAddress,
#[error("the current bandwidth contract does not have any admin address set")]
MissingBandwidthContractAdmin,
#[error("failed to derive the admin account from the provided public key: {formatted_source}")]
AdminAccountDerivationFailure { formatted_source: String },
#[error("only secp256k1 keys are supported for free pass issuance")]
UnsupportedNonSecp256k1Key,
#[error("failed to parse the free pass expiry date: {source}")]
ExpiryDateParsingFailure {
#[source]
source: ParseIntError,
},
#[error("the provided expiration date is too late")]
ExpirationDateTooLate,
@@ -60,22 +37,6 @@ pub enum EcashError {
#[error("the provided expiration date is malformed")]
MalformedExpirationDate { raw: String },
#[error("failed to parse expiry timestamp into proper datetime: {source}")]
InvalidExpiryDate {
unix_timestamp: i64,
#[source]
source: ComponentRange,
},
#[error("the received bandwidth voucher did not contain deposit value")]
MissingBandwidthValue,
#[error("failed to parse the bandwidth voucher value: {source}")]
VoucherValueParsingFailure {
#[source]
source: ParseIntError,
},
#[error("coconut api query failure: {0}")]
CoconutApiError(#[from] EcashApiError),
@@ -88,13 +49,6 @@ pub enum EcashError {
#[error("could not parse X25519 data: {0}")]
X25519ParseError(#[from] KeyRecoveryError),
#[error("could not get transaction details for '{tx_hash}': {source}")]
TxRetrievalFailure {
tx_hash: String,
#[source]
source: NyxdError,
},
#[error("nyxd error: {0}")]
NyxdError(#[from] NyxdError),
@@ -107,12 +61,6 @@ pub enum EcashError {
#[error("Account linked to this public key has been blacklisted")]
BlacklistedAccount,
#[error("could not find a deposit event in the transaction provided")]
DepositEventNotFound,
#[error("could not find the deposit info in the event")]
DepositInfoNotFound,
#[error("signature didn't verify correctly")]
SignatureVerificationError(#[from] SignatureError),
@@ -122,9 +70,6 @@ pub enum EcashError {
#[error("credentials error: {0}")]
CredentialsError(#[from] nym_credentials::error::Error),
#[error("incorrect credential proposal description: {reason}")]
IncorrectProposal { reason: String },
#[error("DKG error: {0}")]
DkgError(#[from] DkgError),
@@ -137,16 +82,6 @@ pub enum EcashError {
#[error("DKG has not finished yet in order to derive the coconut key")]
KeyPairNotDerivedYet,
#[error("the coconut keypair is corrupted")]
CorruptedCoconutKeyPair,
#[error("there was a problem with the proposal id: {reason}")]
ProposalIdError { reason: String },
// I guess we should make this one a bit more detailed
#[error("the provided query arguments were invalid")]
InvalidQueryArguments,
#[error("the internal dkg state for epoch {epoch_id} is missing - we might have joined mid exchange")]
MissingDkgState { epoch_id: EpochId },
@@ -181,9 +116,6 @@ pub enum EcashError {
#[error("the provided request digest does not match the hash of attached serial numbers")]
MismatchedRequestDigest,
#[error("the on chain proposal digest does not match the attached request digest")]
MismatchedOnChainDigest,
#[error("one of the attached tickets {serial_number_bs58} has not been verified before")]
TicketNotVerified { serial_number_bs58: String },
@@ -215,6 +147,12 @@ pub enum EcashError {
#[error(transparent)]
UnknownTicketBookType(#[from] UnknownTicketType),
#[error("could not find issued ticketbook associated with deposit {deposit_id}")]
UnavailableTicketbook { deposit_id: DepositId },
#[error("could not generate merkle proof for the provided deposits")]
MerkleProofGenerationFailure,
}
// impl<'r, 'o: 'r> Responder<'r, 'o> for EcashError {
+4
View File
@@ -137,4 +137,8 @@ where
#[allow(clippy::unwrap_used)]
Ok(RwLockReadGuard::map(guard, |map| map.get(&key).unwrap()))
}
// pub(crate) async fn remove(&self, key: K) {
// self.inner.write().await.remove(&key);
// }
}
+130
View File
@@ -1,17 +1,25 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::ecash::error::EcashError;
use crate::ecash::helpers::{
CachedImmutableEpochItem, CachedImmutableItems, IssuedCoinIndicesSignatures,
IssuedExpirationDateSignatures,
};
use crate::ecash::keys::KeyPair;
use crate::ecash::storage::models::IssuedHash;
use nym_api_requests::ecash::models::{CommitedDeposit, DepositId};
use nym_config::defaults::BloomfilterParameters;
use nym_crypto::asymmetric::identity;
use nym_ecash_double_spending::DoubleSpendingFilter;
use nym_ticketbooks_merkle::{
IssuedTicketbook, IssuedTicketbooksFullMerkleProof, IssuedTicketbooksMerkleTree, MerkleLeaf,
};
use std::collections::HashMap;
use std::sync::Arc;
use time::Date;
use tokio::sync::RwLock;
use tracing::error;
pub(crate) struct TicketDoubleSpendingFilter {
built_on: Date,
@@ -76,6 +84,116 @@ impl TicketDoubleSpendingFilter {
}
}
#[derive(Default)]
pub(crate) struct DailyMerkleTree {
pub(crate) merkle_tree: IssuedTicketbooksMerkleTree,
// keep the individual leaves so that we could easily obtain indices for particular leaves
// when constructing proofs
pub(crate) inserted_leaves: HashMap<DepositId, MerkleLeaf>,
}
impl DailyMerkleTree {
pub(crate) fn new(initial_leaves: Vec<IssuedHash>) -> Self {
let mut leaves: HashMap<_, _> = initial_leaves
.into_iter()
.map(|l| (l.merkle_index, l))
.collect();
let mut sorted_leaves = Vec::new();
for i in 0..leaves.len() {
if let Some(next_leaf) = leaves.remove(&i) {
sorted_leaves.push(next_leaf);
} else {
let lost = leaves.len() - i + 1;
error!("failed to produce consistent merkle tree. there was no leaf with index {i}. at least {lost} leaves got lost")
}
}
let hashes = sorted_leaves
.iter()
.map(|i| i.merkle_leaf)
.collect::<Vec<_>>();
DailyMerkleTree {
merkle_tree: IssuedTicketbooksMerkleTree::rebuild(&hashes),
inserted_leaves: sorted_leaves
.into_iter()
.map(|leaf| {
(
leaf.deposit_id,
MerkleLeaf {
hash: leaf.merkle_leaf.to_vec(),
index: leaf.merkle_index,
},
)
})
.collect(),
}
}
pub(crate) fn proof(
&self,
deposits: &[DepositId],
) -> Result<IssuedTicketbooksFullMerkleProof, EcashError> {
let mut indices = Vec::with_capacity(deposits.len());
for &deposit_id in deposits {
let Some(leaf) = self.inserted_leaves.get(&deposit_id) else {
return Err(EcashError::UnavailableTicketbook { deposit_id });
};
indices.push(leaf.index);
}
self.merkle_tree
.generate_proof(&indices)
.ok_or(EcashError::MerkleProofGenerationFailure)
}
pub(crate) fn merkle_root(&self) -> Option<[u8; 32]> {
self.merkle_tree.root()
}
pub(crate) fn deposits(&self) -> Vec<CommitedDeposit> {
self.inserted_leaves
.iter()
.map(|(&deposit_id, leaf)| CommitedDeposit {
deposit_id,
merkle_index: leaf.index,
})
.collect()
}
fn rebuild_without_history(&mut self) {
let new_tree = if let Some(raw_leaves) = self.merkle_tree.all_leaves() {
IssuedTicketbooksMerkleTree::rebuild(&raw_leaves)
} else {
error!("the merkle tree does not seem to have any leaves for rebuilding!");
return;
};
self.merkle_tree = new_tree;
}
pub(crate) fn insert(&mut self, issued: &IssuedTicketbook) -> MerkleLeaf {
let inserted = self.merkle_tree.insert(issued);
self.inserted_leaves
.insert(issued.deposit_id, inserted.leaf.clone());
inserted.leaf
}
pub(crate) fn rollback(&mut self, deposit_id: DepositId) {
self.merkle_tree.rollback();
self.inserted_leaves.remove(&deposit_id);
}
pub(crate) fn maybe_rebuild(&mut self) {
// every 1000 leaves, rebuild the tree to purge the history
// (I wish the API of the library allowed to do it without having to go through those extra steps...)
if !self.inserted_leaves.is_empty() && self.inserted_leaves.len() % 1000 == 0 {
self.rebuild_without_history();
}
}
}
pub(crate) struct LocalEcashState {
pub(crate) ecash_keypair: KeyPair,
pub(crate) identity_keypair: identity::KeyPair,
@@ -91,6 +209,9 @@ pub(crate) struct LocalEcashState {
// the actual, up to date, bloomfilter
pub(crate) double_spending_filter: Arc<RwLock<TicketDoubleSpendingFilter>>,
// merkle trees for ticketbooks issued for particular expiration dates
pub(crate) issued_merkle_trees: Arc<RwLock<HashMap<Date, DailyMerkleTree>>>,
}
impl LocalEcashState {
@@ -108,6 +229,15 @@ impl LocalEcashState {
partial_coin_index_signatures: Default::default(),
partial_expiration_date_signatures: Default::default(),
double_spending_filter: Arc::new(RwLock::new(double_spending_filter)),
issued_merkle_trees: Arc::new(Default::default()),
}
}
pub(crate) async fn is_merkle_empty(&self, expiration_date: Date) -> bool {
self.issued_merkle_trees
.read()
.await
.get(&expiration_date)
.is_none()
}
}
+235 -52
View File
@@ -13,14 +13,17 @@ use crate::ecash::state::helpers::{
ensure_sane_expiration_date, prepare_partial_bloomfilter_builder, query_all_threshold_apis,
try_rebuild_bloomfilter,
};
use crate::ecash::state::local::LocalEcashState;
use crate::ecash::state::local::{DailyMerkleTree, LocalEcashState};
use crate::ecash::storage::models::{SerialNumberWrapper, TicketProvider};
use crate::ecash::storage::EcashStorageExt;
use crate::support::config::Config;
use crate::support::storage::NymApiStorage;
use cosmwasm_std::{from_binary, CosmosMsg, WasmMsg};
use cw3::Status;
use nym_api_requests::ecash::helpers::issued_credential_plaintext;
use nym_api_requests::ecash::models::BatchRedeemTicketsBody;
use nym_api_requests::ecash::models::{
BatchRedeemTicketsBody, IssuedTicketbooksChallengeRequest,
IssuedTicketbooksChallengeResponseBody, IssuedTicketbooksForResponseBody,
};
use nym_api_requests::ecash::BlindSignRequestBody;
use nym_coconut_dkg_common::types::EpochId;
use nym_compact_ecash::scheme::coin_indices_signatures::{
@@ -30,7 +33,7 @@ use nym_compact_ecash::scheme::expiration_date_signatures::{
aggregate_annotated_expiration_signatures, ExpirationDateSignatureShare,
};
use nym_compact_ecash::{
constants, scheme::expiration_date_signatures::sign_expiration_date, BlindedSignature,
constants, scheme::expiration_date_signatures::sign_expiration_date, BlindedSignature, Bytable,
SecretKeyAuth, VerificationKeyAuth,
};
use nym_config::defaults::BloomfilterParameters;
@@ -41,13 +44,16 @@ use nym_ecash_contract_common::deposit::{Deposit, DepositId};
use nym_ecash_contract_common::msg::ExecuteMsg;
use nym_ecash_contract_common::redeem_credential::BATCH_REDEMPTION_PROPOSAL_TITLE;
use nym_ecash_double_spending::DoubleSpendingFilter;
use nym_ecash_time::cred_exp_date;
use nym_ecash_time::{cred_exp_date, ecash_today_date};
use nym_ticketbooks_merkle::{IssuedTicketbook, IssuedTicketbooksFullMerkleProof, MerkleLeaf};
use nym_validator_client::nyxd::AccountId;
use nym_validator_client::EcashApiClient;
use rand::{thread_rng, RngCore};
use std::collections::HashMap;
use std::ops::Deref;
use time::ext::NumericalDuration;
use time::{Date, OffsetDateTime};
use tokio::sync::RwLockReadGuard;
use tokio::sync::{RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, error, info, warn};
pub(crate) mod auxiliary;
@@ -56,7 +62,32 @@ pub(crate) mod global;
mod helpers;
pub(crate) mod local;
pub struct EcashStateConfig {
pub(crate) issued_ticketbooks_retention_period_days: u32,
}
impl EcashStateConfig {
pub(crate) fn ticketbook_retention_cutoff(&self) -> Date {
ecash_today_date()
- time::Duration::days(self.issued_ticketbooks_retention_period_days as i64)
}
}
impl EcashStateConfig {
pub(crate) fn new(global_config: &Config) -> Self {
EcashStateConfig {
issued_ticketbooks_retention_period_days: global_config
.ecash_signer
.debug
.issued_ticketbooks_retention_period_days,
}
}
}
pub struct EcashState {
// additional global config parameters
pub(crate) config: EcashStateConfig,
// state global to the system, like aggregated keys, addresses, etc.
pub(crate) global: GlobalEcachState,
@@ -69,13 +100,13 @@ pub struct EcashState {
impl EcashState {
pub(crate) async fn new<C, D>(
global_config: &Config,
contract_address: AccountId,
client: C,
identity_keypair: identity::KeyPair,
key_pair: KeyPair,
comm_channel: D,
storage: NymApiStorage,
signer_disabled: bool,
) -> Result<Self>
where
C: LocalClient + Send + Sync + 'static,
@@ -84,12 +115,13 @@ impl EcashState {
let double_spending_filter = try_rebuild_bloomfilter(&storage).await?;
Ok(Self {
config: EcashStateConfig::new(global_config),
global: GlobalEcachState::new(contract_address),
local: LocalEcashState::new(
key_pair,
identity_keypair,
double_spending_filter,
signer_disabled,
!global_config.ecash_signer.enabled,
),
aux: AuxiliaryEcashState::new(client, comm_channel, storage),
})
@@ -455,12 +487,11 @@ impl EcashState {
/// Check if this nym-api has already issued a credential for the provided deposit id.
/// If so, return it.
pub async fn already_issued(&self, deposit_id: DepositId) -> Result<Option<BlindedSignature>> {
self.aux
Ok(self
.aux
.storage
.get_issued_bandwidth_credential_by_deposit_id(deposit_id)
.await?
.map(|cred| cred.try_into())
.transpose()
.get_issued_partial_signature(deposit_id)
.await?)
}
pub async fn get_deposit(&self, deposit_id: DepositId) -> Result<Deposit> {
@@ -619,67 +650,217 @@ impl EcashState {
// });
// }
pub(crate) async fn sign_and_store_credential(
pub(crate) async fn persist_issued(
&self,
current_epoch: EpochId,
request_body: BlindSignRequestBody,
blinded_signature: &BlindedSignature,
) -> Result<i64> {
let encoded_commitments = request_body.encode_commitments();
let plaintext = issued_credential_plaintext(
current_epoch as u32,
request_body.deposit_id,
blinded_signature,
&encoded_commitments,
request_body.expiration_date,
request_body.ticketbook_type,
);
let signature = self.local.identity_keypair.private_key().sign(plaintext);
issued: &IssuedTicketbook,
merkle_leaf: MerkleLeaf,
) -> Result<()> {
// note: we have a UNIQUE constraint on the deposit_id column of the credential
// and so if the api is processing request for the same deposit at the same time,
// only one of them will be successfully inserted to the database
let credential_id = self
.aux
self.aux
.storage
.store_issued_credential(
.store_issued_ticketbook(
issued.deposit_id,
current_epoch as u32,
request_body.deposit_id,
blinded_signature,
signature,
encoded_commitments,
request_body.expiration_date,
request_body.ticketbook_type,
&issued.blinded_partial_credential,
&issued.joined_encoded_private_attributes_commitments,
issued.expiration_date,
issued.ticketbook_type,
merkle_leaf,
)
.await?;
Ok(credential_id)
Ok(())
}
pub async fn store_issued_credential(
async fn get_updated_merkle_read(
&self,
expiration_date: Date,
) -> Result<RwLockReadGuard<DailyMerkleTree>> {
let write_guard = self.get_updated_full_write(expiration_date).await?;
// SAFETY: the entry was either not empty or we just inserted data in there, whilst never dropping the lock
// thus it MUST exist
#[allow(clippy::unwrap_used)]
Ok(RwLockWriteGuard::downgrade_map(write_guard, |map| {
map.get(&expiration_date).unwrap()
}))
}
async fn get_updated_full_write(
&self,
expiration_date: Date,
) -> Result<RwLockWriteGuard<HashMap<Date, DailyMerkleTree>>> {
let mut write_guard = self.local.issued_merkle_trees.write().await;
// double check if it's still empty in case another task has already grabbed the write lock and performed the update
let still_empty = write_guard.get(&expiration_date).is_none();
if still_empty {
// the order actually does not matter since we're building the tree back from scratch
let issued_hashes = self.aux.storage.get_issued_hashes(expiration_date).await?;
write_guard.insert(expiration_date, DailyMerkleTree::new(issued_hashes));
}
Ok(write_guard)
}
pub async fn store_issued_ticketbook(
&self,
request_body: BlindSignRequestBody,
blinded_signature: &BlindedSignature,
) -> Result<()> {
let current_epoch = self.aux.current_epoch().await?;
let expiration = request_body.expiration_date;
let deposit_id = request_body.deposit_id;
// note: we have a UNIQUE constraint on the tx_hash column of the credential
// and so if the api is processing request for the same hash at the same time,
let joined_encoded_private_attributes_commitments = request_body.encode_join_commitments();
let issued = IssuedTicketbook {
deposit_id: request_body.deposit_id,
epoch_id: current_epoch,
blinded_partial_credential: blinded_signature.to_byte_vec(),
joined_encoded_private_attributes_commitments,
expiration_date: request_body.expiration_date,
ticketbook_type: request_body.ticketbook_type,
};
let mut map = self.get_updated_full_write(expiration).await?;
// SAFETY: get_updated_full_write inserted relevant entry to the map, and we never dropped the lock
#[allow(clippy::unwrap_used)]
let merkle_entry = map.get_mut(&expiration).unwrap();
// insert the ticketbook into the merkle tree
let inserted_leaf = merkle_entry.insert(&issued);
// note: there's a primary key constraint on the deposit_id
// and so if the api is processing request for the same deposit at the same time,
// only one of them will be successfully inserted to the database
let credential_id = self
.sign_and_store_credential(current_epoch, request_body, blinded_signature)
.await?;
self.aux
.storage
.update_epoch_credentials_entry(current_epoch, credential_id)
.await?;
debug!("the stored credential has id {credential_id}");
if let Err(err) = self
.persist_issued(current_epoch, &issued, inserted_leaf)
.await
{
// if we failed to insert it into the db, rollback the tree. there was most likely clash on the deposit
warn!("failed to persist ticketbook corresponding to deposit {deposit_id}: {err}");
merkle_entry.rollback(deposit_id);
return Err(err);
}
// if we managed to insert it into db, check if we might want to purge the tree history,
// since we will no longer have to roll it back
merkle_entry.maybe_rebuild();
// toss a coin to check if we should clean memory of old merkle trees
if thread_rng().next_u32() % 10000 == 0 {
let mut values_to_clean = Vec::new();
let cutoff = self.config.ticketbook_retention_cutoff();
info!("attempting to remove old issued ticketbooks. the cutoff is set to {cutoff}");
for date in map.keys() {
if date < &cutoff {
values_to_clean.push(*date)
}
}
for date in values_to_clean {
// remove the in-memory merkle tree
map.remove(&date);
}
// remove data from the storage
self.aux
.storage
.remove_old_issued_ticketbooks(cutoff)
.await?;
}
Ok(())
}
async fn get_merkle_proof(
&self,
expiration_date: Date,
deposits: &[DepositId],
) -> Result<IssuedTicketbooksFullMerkleProof> {
// check if the entry for this expiration date is empty. if so, it might imply we have crashed/shutdown
// and not have the full data in memory
if self.local.is_merkle_empty(expiration_date).await {
let entry = self.get_updated_merkle_read(expiration_date).await?;
return entry.proof(deposits);
}
// I can imagine this could happen under very rare edge case when the function is called just as the retention period expired
let guard = self.local.issued_merkle_trees.read().await;
let Some(entry) = guard.get(&expiration_date) else {
warn!("it seems our merkle tree has just expired!");
return Err(EcashError::ExpirationDateTooEarly);
};
entry.proof(deposits)
}
pub async fn get_issued_ticketbooks(
&self,
challenge: IssuedTicketbooksChallengeRequest,
) -> Result<IssuedTicketbooksChallengeResponseBody> {
if challenge.expiration_date < self.config.ticketbook_retention_cutoff() {
return Err(EcashError::ExpirationDateTooEarly);
}
let merkle_proof = self
.get_merkle_proof(challenge.expiration_date, &challenge.deposits)
.await?;
let partial_ticketbooks = self
.aux
.storage
.get_issued_ticketbooks(challenge.deposits)
.await?;
let partial_ticketbooks = partial_ticketbooks
.into_iter()
.map(|t| (t.deposit_id, t))
.collect();
Ok(IssuedTicketbooksChallengeResponseBody {
expiration_date: challenge.expiration_date,
partial_ticketbooks,
merkle_proof,
})
}
pub async fn get_issued_ticketbooks_deposits_on(
&self,
expiration: Date,
) -> Result<IssuedTicketbooksForResponseBody> {
if expiration < self.config.ticketbook_retention_cutoff() {
return Err(EcashError::ExpirationDateTooEarly);
}
// check if the entry for this expiration date is empty. if so, it might imply we have crashed/shutdown
// and not have the full data in memory
if self.local.is_merkle_empty(expiration).await {
let entry = self.get_updated_merkle_read(expiration).await?;
return Ok(IssuedTicketbooksForResponseBody {
expiration_date: expiration,
deposits: entry.deposits(),
merkle_root: entry.merkle_root(),
});
}
// I can imagine this could happen under very rare edge case when the function is called just as the retention period expired
let guard = self.local.issued_merkle_trees.read().await;
let Some(entry) = guard.get(&expiration) else {
warn!("it seems our merkle tree has just expired!");
return Err(EcashError::ExpirationDateTooEarly);
};
Ok(IssuedTicketbooksForResponseBody {
expiration_date: expiration,
deposits: entry.deposits(),
merkle_root: entry.merkle_root(),
})
}
pub async fn store_verified_ticket(
&self,
ticket_data: &CredentialSpendingData,
@@ -690,6 +871,8 @@ impl EcashState {
.store_verified_ticket(ticket_data, gateway_addr)
.await
.map_err(Into::into)
// TODO UNIMPLEMENTED: we should probably also be removing old tickets here
}
pub async fn get_ticket_provider(
+109 -300
View File
@@ -2,101 +2,61 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::ecash::storage::models::{
EpochCredentials, IssuedTicketbook, RawExpirationDateSignatures, SerialNumberWrapper,
IssuedHash, RawExpirationDateSignatures, RawIssuedTicketbook, SerialNumberWrapper,
StoredBloomfilterParams, TicketProvider, VerifiedTicket,
};
use crate::support::storage::manager::StorageManager;
use async_trait::async_trait;
use nym_coconut_dkg_common::types::EpochId;
use nym_ecash_contract_common::deposit::DepositId;
use time::{Date, OffsetDateTime};
use tracing::{error, info};
#[async_trait]
pub trait EcashStorageManagerExt {
/// Gets the information about all issued partial credentials in this (coconut) epoch.
///
/// # Arguments
///
/// * `epoch_id`: Id of the (coconut) epoch in question.
async fn get_epoch_credentials(
&self,
epoch_id: EpochId,
) -> Result<Option<EpochCredentials>, sqlx::Error>;
/// Creates new entry for EpochCredentials for this (coconut) epoch.
///
/// # Arguments
///
/// * `epoch_id`: Id of the (coconut) epoch in question.
#[allow(dead_code)]
async fn create_epoch_credentials_entry(&self, epoch_id: EpochId) -> Result<(), sqlx::Error>;
/// Update the EpochCredentials by incrementing the total number of issued credentials,
/// and setting `start_id` if unset (i.e. this is the first credential issued this epoch)
///
/// # Arguments
/// * `epoch_id`: Id of the (coconut) epoch in question.
/// * `credential_id`: (database) Id of the coconut credential that triggered the update.
async fn update_epoch_credentials_entry(
&self,
epoch_id: EpochId,
credential_id: i64,
) -> Result<(), sqlx::Error>;
/// Attempts to retrieve an issued credential from the data store.
///
/// # Arguments
///
/// * `credential_id`: (database) id of the issued credential
async fn get_issued_credential(
&self,
credential_id: i64,
) -> Result<Option<IssuedTicketbook>, sqlx::Error>;
/// Attempts to retrieve an issued credential from the data store.
///
/// # Arguments
///
/// * `deposit_id`: id the deposit used in the issued bandwidth credential
async fn get_issued_bandwidth_credential_by_deposit_id(
async fn get_issued_partial_signature(
&self,
deposit_id: DepositId,
) -> Result<Option<IssuedTicketbook>, sqlx::Error>;
) -> Result<Option<Vec<u8>>, sqlx::Error>;
/// Store the provided issued credential information and return its (database) id.
/// Get the hashes of all issued ticketbooks with the particular expiration date
async fn get_issued_hashes(
&self,
expiration_date: Date,
) -> Result<Vec<IssuedHash>, sqlx::Error>;
/// Store the provided issued credential information.
#[allow(clippy::too_many_arguments)]
async fn store_issued_ticketbook(
&self,
epoch_id: u32,
deposit_id: DepositId,
partial_credential: &[u8],
signature: &[u8],
dkg_epoch_id: u32,
blinded_partial_credential: &[u8],
joined_private_commitments: &[u8],
expiration_date: Date,
ticketbook_type_repr: u8,
) -> Result<i64, sqlx::Error>;
merkle_leaf: &[u8],
merkle_index: u32,
) -> Result<(), sqlx::Error>;
/// Attempts to retrieve issued credentials from the data store using provided ids.
async fn remove_old_issued_ticketbooks(
&self,
cutoff_expiration_date: Date,
) -> Result<(), sqlx::Error>;
/// Attempts to retrieve issued ticketbooks from the data store using associated deposits.
///
/// # Arguments
///
/// * `credential_ids`: (database) ids of the issued credentials
/// * `deposit_ids`: deposits used for obtaining underlying ticketbook
async fn get_issued_ticketbooks(
&self,
credential_ids: Vec<i64>,
) -> Result<Vec<IssuedTicketbook>, sqlx::Error>;
/// Attempts to retrieve issued credentials from the data store using pagination specification.
///
/// # Arguments
///
/// * `start_after`: the value preceding the first retrieved result
/// * `limit`: the maximum number of entries to retrieve
async fn get_issued_ticketbooks_paged(
&self,
start_after: i64,
limit: u32,
) -> Result<Vec<IssuedTicketbook>, sqlx::Error>;
deposits: &[DepositId],
) -> Result<Vec<RawIssuedTicketbook>, sqlx::Error>;
async fn insert_ticket_provider(&self, gateway_address: &str) -> Result<i64, sqlx::Error>;
@@ -220,282 +180,131 @@ pub trait EcashStorageManagerExt {
#[async_trait]
impl EcashStorageManagerExt for StorageManager {
/// Gets the information about all issued partial credentials in this (coconut) epoch.
///
/// # Arguments
///
/// * `epoch_id`: Id of the (coconut) epoch in question.
async fn get_epoch_credentials(
&self,
epoch_id: EpochId,
) -> Result<Option<EpochCredentials>, sqlx::Error> {
// even if we were changing epochs every second, it's rather impossible to overflow here
// within any sane amount of time
assert!(epoch_id <= u32::MAX as u64);
let epoch_id_downcasted = epoch_id as u32;
sqlx::query_as!(
EpochCredentials,
r#"
SELECT epoch_id as "epoch_id: u32", start_id, total_issued as "total_issued: u32"
FROM epoch_credentials
WHERE epoch_id = ?
"#,
epoch_id_downcasted
)
.fetch_optional(&self.connection_pool)
.await
}
/// Creates new entry for EpochCredentials for this (coconut) epoch.
///
/// # Arguments
///
/// * `epoch_id`: Id of the (coconut) epoch in question.
async fn create_epoch_credentials_entry(&self, epoch_id: EpochId) -> Result<(), sqlx::Error> {
// even if we were changing epochs every second, it's rather impossible to overflow here
// within any sane amount of time
assert!(epoch_id <= u32::MAX as u64);
let epoch_id_downcasted = epoch_id as u32;
sqlx::query!(
r#"
INSERT INTO epoch_credentials
(epoch_id, start_id, total_issued)
VALUES (?, ?, ?);
"#,
epoch_id_downcasted,
-1,
0
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
// the logic in this function can be summarised with:
// 1. get the current EpochCredentials for this epoch
// 2. if it exists -> increment `total_issued`
// 3. it has invalid (i.e. -1) `start_id` set it to the provided value
// 4. if it didn't exist, create new entry
/// Update the EpochCredentials by incrementing the total number of issued credentials,
/// and setting `start_id` if unset (i.e. this is the first credential issued this epoch)
///
/// # Arguments
/// * `epoch_id`: Id of the (coconut) epoch in question.
/// * `credential_id`: (database) Id of the coconut credential that triggered the update.
async fn update_epoch_credentials_entry(
&self,
epoch_id: EpochId,
credential_id: i64,
) -> Result<(), sqlx::Error> {
// even if we were changing epochs every second, it's rather impossible to overflow here
// within any sane amount of time
assert!(epoch_id <= u32::MAX as u64);
let epoch_id_downcasted = epoch_id as u32;
// make the atomic transaction in case other tasks are attempting to use the pool
let mut tx = self.connection_pool.begin().await?;
if let Some(existing) = sqlx::query_as!(
EpochCredentials,
r#"
SELECT epoch_id as "epoch_id: u32", start_id, total_issued as "total_issued: u32"
FROM epoch_credentials
WHERE epoch_id = ?
"#,
epoch_id_downcasted
)
.fetch_optional(&mut *tx)
.await?
{
// the entry has existed before -> update it
if existing.total_issued == 0 {
// no credentials has been issued -> we have to set the `start_id`
sqlx::query!(
r#"
UPDATE epoch_credentials
SET total_issued = 1, start_id = ?
WHERE epoch_id = ?
"#,
credential_id,
epoch_id_downcasted
)
.execute(&mut *tx)
.await?;
} else {
// we have issued credentials in this epoch before -> just increment `total_issued`
sqlx::query!(
r#"
UPDATE epoch_credentials
SET total_issued = total_issued + 1
WHERE epoch_id = ?
"#,
epoch_id_downcasted
)
.execute(&mut *tx)
.await?;
}
} else {
// the entry has never been created -> probably some race condition; create it instead
sqlx::query!(
r#"
INSERT INTO epoch_credentials
(epoch_id, start_id, total_issued)
VALUES (?, ?, ?);
"#,
epoch_id_downcasted,
credential_id,
1
)
.execute(&mut *tx)
.await?;
}
// finally commit the transaction
tx.commit().await
}
/// Attempts to retrieve an issued credential from the data store.
///
/// # Arguments
///
/// * `credential_id`: (database) id of the issued credential
async fn get_issued_credential(
&self,
credential_id: i64,
) -> Result<Option<IssuedTicketbook>, sqlx::Error> {
sqlx::query_as!(
IssuedTicketbook,
r#"
SELECT
id,
epoch_id as "epoch_id: u32",
deposit_id as "deposit_id: DepositId",
partial_credential,
signature,
joined_private_commitments,
expiration_date as "expiration_date: Date",
ticketbook_type_repr as "ticketbook_type_repr: u8"
FROM issued_ticketbook
WHERE id = ?
"#,
credential_id
)
.fetch_optional(&self.connection_pool)
.await
}
/// Attempts to retrieve an issued credential from the data store.
///
/// # Arguments
///
/// * `deposit_id`: id the deposit used in the issued bandwidth credential
async fn get_issued_bandwidth_credential_by_deposit_id(
async fn get_issued_partial_signature(
&self,
deposit_id: DepositId,
) -> Result<Option<IssuedTicketbook>, sqlx::Error> {
sqlx::query_as!(
IssuedTicketbook,
) -> Result<Option<Vec<u8>>, sqlx::Error> {
Ok(sqlx::query!(
r#"
SELECT
id,
epoch_id as "epoch_id: u32",
deposit_id as "deposit_id: DepositId",
partial_credential,
signature,
joined_private_commitments,
expiration_date as "expiration_date: Date",
ticketbook_type_repr as "ticketbook_type_repr: u8"
blinded_partial_credential
FROM issued_ticketbook
WHERE deposit_id = ?
"#,
deposit_id
)
.fetch_optional(&self.connection_pool)
.await
.await?
.map(|r| r.blinded_partial_credential))
}
/// Store the provided issued credential information and return its (database) id.
/// Get the hashes of all issued ticketbooks with the particular expiration date
async fn get_issued_hashes(
&self,
expiration_date: Date,
) -> Result<Vec<IssuedHash>, sqlx::Error> {
Ok(sqlx::query!(
r#"
SELECT deposit_id as "deposit_id: DepositId", merkle_leaf, merkle_index as "merkle_index: u32"
FROM issued_ticketbook WHERE expiration_date = ?
"#,
expiration_date
)
.fetch_all(&self.connection_pool)
.await?
.into_iter()
.filter_map(|r| r.merkle_leaf.try_into().inspect_err(|_| error!("possible database corruption: one of the stored merkle leaves is not a valid 32byte hash")).ok().map(|merkle_leaf| IssuedHash {
deposit_id: r.deposit_id,
merkle_leaf,
merkle_index: r.merkle_index as usize,
}))
.collect())
}
/// Store the provided issued credential information.
async fn store_issued_ticketbook(
&self,
epoch_id: u32,
deposit_id: DepositId,
partial_credential: &[u8],
signature: &[u8],
dkg_epoch_id: u32,
blinded_partial_credential: &[u8],
joined_private_commitments: &[u8],
expiration_date: Date,
ticketbook_type_repr: u8,
) -> Result<i64, sqlx::Error> {
let row_id = sqlx::query!(
merkle_leaf: &[u8],
merkle_index: u32,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO issued_ticketbook
(epoch_id, deposit_id, partial_credential, signature, joined_private_commitments, expiration_date, ticketbook_type_repr)
VALUES
(?, ?, ?, ?, ?, ?, ?)
INSERT INTO issued_ticketbook (
deposit_id,
dkg_epoch_id,
blinded_partial_credential,
joined_private_commitments,
expiration_date,
ticketbook_type_repr,
merkle_leaf,
merkle_index
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
epoch_id, deposit_id, partial_credential, signature, joined_private_commitments, expiration_date, ticketbook_type_repr
).execute(&self.connection_pool).await?.last_insert_rowid();
deposit_id,
dkg_epoch_id,
blinded_partial_credential,
joined_private_commitments,
expiration_date,
ticketbook_type_repr,
merkle_leaf,
merkle_index
)
.execute(&self.connection_pool)
.await?;
Ok(row_id)
Ok(())
}
/// Attempts to retrieve issued credentials from the data store using provided ids.
async fn remove_old_issued_ticketbooks(
&self,
cutoff_expiration_date: Date,
) -> Result<(), sqlx::Error> {
let res = sqlx::query!(
r#"
DELETE FROM issued_ticketbook
WHERE expiration_date < ?
"#,
cutoff_expiration_date
)
.execute(&self.connection_pool)
.await?;
info!("removed {} issued ticketbooks", res.rows_affected());
Ok(())
}
/// Attempts to retrieve issued ticketbooks from the data store using associated deposits.
///
/// # Arguments
///
/// * `credential_ids`: (database) ids of the issued credentials
/// * `deposit_ids`: deposits used for obtaining underlying ticketbook
async fn get_issued_ticketbooks(
&self,
credential_ids: Vec<i64>,
) -> Result<Vec<IssuedTicketbook>, sqlx::Error> {
deposits: &[DepositId],
) -> Result<Vec<RawIssuedTicketbook>, sqlx::Error> {
// that sucks : (
// https://stackoverflow.com/a/70032524
let params = format!("?{}", ", ?".repeat(credential_ids.len() - 1));
let query_str = format!("SELECT * FROM issued_ticketbook WHERE id IN ( {params} )");
let params = format!("?{}", ", ?".repeat(deposits.len() - 1));
let query_str = format!("SELECT * FROM issued_ticketbook WHERE deposit_id IN ( {params} )");
let mut query = sqlx::query_as(&query_str);
for id in credential_ids {
query = query.bind(id)
for deposit_id in deposits {
query = query.bind(deposit_id)
}
query.fetch_all(&self.connection_pool).await
}
/// Attempts to retrieve issued credentials from the data store using pagination specification.
///
/// # Arguments
///
/// * `start_after`: the value preceding the first retrieved result
/// * `limit`: the maximum number of entries to retrieve
async fn get_issued_ticketbooks_paged(
&self,
start_after: i64,
limit: u32,
) -> Result<Vec<IssuedTicketbook>, sqlx::Error> {
sqlx::query_as!(
IssuedTicketbook,
r#"
SELECT
id,
epoch_id as "epoch_id: u32",
deposit_id as "deposit_id: DepositId",
partial_credential,
signature,
joined_private_commitments,
expiration_date as "expiration_date: Date",
ticketbook_type_repr as "ticketbook_type_repr: u8"
FROM issued_ticketbook
WHERE id > ?
ORDER BY id
LIMIT ?
"#,
start_after,
limit
)
.fetch_all(&self.connection_pool)
.await
}
async fn insert_ticket_provider(&self, gateway_address: &str) -> Result<i64, sqlx::Error> {
let id = sqlx::query!(
"INSERT INTO ticket_providers(gateway_address) VALUES (?)",
+79 -146
View File
@@ -7,32 +7,27 @@ use crate::ecash::storage::helpers::{
serialise_coin_index_signatures, serialise_expiration_date_signatures,
};
use crate::ecash::storage::manager::EcashStorageManagerExt;
use crate::ecash::storage::models::{
join_attributes, EpochCredentials, IssuedTicketbook, SerialNumberWrapper, TicketProvider,
};
use crate::ecash::storage::models::{IssuedHash, SerialNumberWrapper, TicketProvider};
use crate::node_status_api::models::NymApiStorageError;
use crate::support::storage::NymApiStorage;
use async_trait::async_trait;
use nym_api_requests::ecash::models::Pagination;
use nym_coconut_dkg_common::types::EpochId;
use nym_compact_ecash::scheme::coin_indices_signatures::AnnotatedCoinIndexSignature;
use nym_compact_ecash::BlindedSignature;
use nym_compact_ecash::VerificationKeyAuth;
use nym_compact_ecash::{BlindedSignature, VerificationKeyAuth};
use nym_config::defaults::BloomfilterParameters;
use nym_credentials::CredentialSpendingData;
use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::identity;
use nym_ecash_contract_common::deposit::DepositId;
use nym_ticketbooks_merkle::{IssuedTicketbook, MerkleLeaf};
use nym_validator_client::nyxd::AccountId;
use std::collections::HashSet;
use time::{Date, OffsetDateTime};
use tracing::info;
use tracing::{info, warn};
mod helpers;
pub(crate) mod manager;
pub(crate) mod models;
const DEFAULT_CREDENTIALS_PAGE_LIMIT: u32 = 100;
#[async_trait]
pub trait EcashStorageExt {
async fn get_double_spending_filter_params(
@@ -64,63 +59,38 @@ pub trait EcashStorageExt {
async fn remove_expired_verified_tickets(&self, cutoff: Date)
-> Result<(), NymApiStorageError>;
async fn get_epoch_credentials(
&self,
epoch_id: EpochId,
) -> Result<Option<EpochCredentials>, NymApiStorageError>;
#[allow(dead_code)]
async fn create_epoch_credentials_entry(
&self,
epoch_id: EpochId,
) -> Result<(), NymApiStorageError>;
async fn update_epoch_credentials_entry(
&self,
epoch_id: EpochId,
credential_id: i64,
) -> Result<(), NymApiStorageError>;
async fn get_issued_credential(
&self,
credential_id: i64,
) -> Result<Option<IssuedTicketbook>, NymApiStorageError>;
async fn get_issued_bandwidth_credential_by_deposit_id(
async fn get_issued_partial_signature(
&self,
deposit_id: DepositId,
) -> Result<Option<IssuedTicketbook>, NymApiStorageError>;
) -> Result<Option<BlindedSignature>, NymApiStorageError>;
async fn get_issued_hashes(
&self,
expiration_date: Date,
) -> Result<Vec<IssuedHash>, NymApiStorageError>;
#[allow(clippy::too_many_arguments)]
async fn store_issued_credential(
async fn store_issued_ticketbook(
&self,
epoch_id: u32,
deposit_id: DepositId,
partial_credential: &BlindedSignature,
signature: identity::Signature,
private_commitments: Vec<Vec<u8>>,
dkg_epoch_id: u32,
blinded_partial_credential: &[u8],
joined_private_commitments: &[u8],
expiration_date: Date,
ticketbook_type: TicketType,
) -> Result<i64, NymApiStorageError>;
merkle_leaf: MerkleLeaf,
) -> Result<(), NymApiStorageError>;
async fn get_issued_credentials(
async fn remove_old_issued_ticketbooks(
&self,
credential_ids: Vec<i64>,
cutoff_expiration_date: Date,
) -> Result<(), NymApiStorageError>;
async fn get_issued_ticketbooks(
&self,
deposits: Vec<DepositId>,
) -> Result<Vec<IssuedTicketbook>, NymApiStorageError>;
async fn get_issued_credentials_paged(
&self,
pagination: Pagination<i64>,
) -> Result<Vec<IssuedTicketbook>, NymApiStorageError>;
//
// async fn insert_credential(
// &self,
// credential: &CredentialSpendingData,
// serial_number_bs58: String,
// gateway_addr: &AccountId,
// proposal_id: u64,
// ) -> Result<(), NymApiStorageError>;
//
async fn get_credential_data(
&self,
serial_number: &[u8],
@@ -282,123 +252,86 @@ impl EcashStorageExt for NymApiStorage {
Ok(self.manager.remove_expired_verified_tickets(cutoff).await?)
}
async fn get_epoch_credentials(
&self,
epoch_id: EpochId,
) -> Result<Option<EpochCredentials>, NymApiStorageError> {
Ok(self.manager.get_epoch_credentials(epoch_id).await?)
}
async fn create_epoch_credentials_entry(
&self,
epoch_id: EpochId,
) -> Result<(), NymApiStorageError> {
Ok(self
.manager
.create_epoch_credentials_entry(epoch_id)
.await?)
}
async fn update_epoch_credentials_entry(
&self,
epoch_id: EpochId,
credential_id: i64,
) -> Result<(), NymApiStorageError> {
Ok(self
.manager
.update_epoch_credentials_entry(epoch_id, credential_id)
.await?)
}
async fn get_issued_credential(
&self,
credential_id: i64,
) -> Result<Option<IssuedTicketbook>, NymApiStorageError> {
Ok(self.manager.get_issued_credential(credential_id).await?)
}
async fn get_issued_bandwidth_credential_by_deposit_id(
async fn get_issued_partial_signature(
&self,
deposit_id: DepositId,
) -> Result<Option<IssuedTicketbook>, NymApiStorageError> {
Ok(self
) -> Result<Option<BlindedSignature>, NymApiStorageError> {
let Some(raw) = self
.manager
.get_issued_bandwidth_credential_by_deposit_id(deposit_id)
.await?)
.get_issued_partial_signature(deposit_id)
.await?
else {
return Ok(None);
};
Ok(Some(BlindedSignature::from_bytes(&raw).map_err(|err| {
NymApiStorageError::database_inconsistency(format!(
"failed to recover stored partial signature: {err}"
))
})?))
}
async fn get_issued_hashes(
&self,
expiration_date: Date,
) -> Result<Vec<IssuedHash>, NymApiStorageError> {
Ok(self.manager.get_issued_hashes(expiration_date).await?)
}
#[allow(clippy::too_many_arguments)]
async fn store_issued_credential(
async fn store_issued_ticketbook(
&self,
epoch_id: u32,
deposit_id: DepositId,
partial_credential: &BlindedSignature,
signature: identity::Signature,
private_commitments: Vec<Vec<u8>>,
dkg_epoch_id: u32,
blinded_partial_credential: &[u8],
joined_private_commitments: &[u8],
expiration_date: Date,
ticketbook_type: TicketType,
) -> Result<i64, NymApiStorageError> {
merkle_leaf: MerkleLeaf,
) -> Result<(), NymApiStorageError> {
Ok(self
.manager
.store_issued_ticketbook(
epoch_id,
deposit_id,
&partial_credential.to_bytes(),
&signature.to_bytes(),
&join_attributes(private_commitments),
dkg_epoch_id,
blinded_partial_credential,
joined_private_commitments,
expiration_date,
ticketbook_type.encode(),
&merkle_leaf.hash,
merkle_leaf.index as u32,
)
.await?)
}
async fn get_issued_credentials(
async fn remove_old_issued_ticketbooks(
&self,
credential_ids: Vec<i64>,
) -> Result<Vec<IssuedTicketbook>, NymApiStorageError> {
Ok(self.manager.get_issued_ticketbooks(credential_ids).await?)
}
async fn get_issued_credentials_paged(
&self,
pagination: Pagination<i64>,
) -> Result<Vec<IssuedTicketbook>, NymApiStorageError> {
// rows start at 1
let start_after = pagination.last_key.unwrap_or(0);
let limit = match pagination.limit {
Some(v) => {
if v == 0 || v > DEFAULT_CREDENTIALS_PAGE_LIMIT {
DEFAULT_CREDENTIALS_PAGE_LIMIT
} else {
v
}
}
None => DEFAULT_CREDENTIALS_PAGE_LIMIT,
};
cutoff_expiration_date: Date,
) -> Result<(), NymApiStorageError> {
Ok(self
.manager
.get_issued_ticketbooks_paged(start_after, limit)
.remove_old_issued_ticketbooks(cutoff_expiration_date)
.await?)
}
// async fn insert_credential(
// &self,
// credential: &CredentialSpendingData,
// serial_number_bs58: String,
// gateway_addr: &AccountId,
// proposal_id: u64,
// ) -> Result<(), NymApiStorageError> {
// self.manager
// .insert_credential(
// credential.to_bs58(),
// serial_number_bs58,
// gateway_addr.to_string(),
// proposal_id as i64,
// )
// .await
// .map_err(|err| err.into())
// }
async fn get_issued_ticketbooks(
&self,
deposits: Vec<DepositId>,
) -> Result<Vec<IssuedTicketbook>, NymApiStorageError> {
let raw = self.manager.get_issued_ticketbooks(&deposits).await?;
if raw.len() != deposits.len() {
warn!("failed to get ticketbooks for all requested deposits. requested {} but only got {}", raw.len(), deposits.len());
let available: HashSet<_> = raw.iter().map(|t| t.deposit_id).collect();
let mut missing = Vec::new();
for requested in deposits {
if !available.contains(&requested) {
warn!("the storage is missing ticketbook for deposit {requested}");
missing.push(requested);
}
}
return Err(NymApiStorageError::UnavailableTicketbooks { deposits: missing });
}
raw.into_iter().map(TryInto::try_into).collect()
}
async fn get_credential_data(
&self,
+31 -96
View File
@@ -1,44 +1,15 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::ecash::error::EcashError;
use crate::node_status_api::models::NymApiStorageError;
use nym_api_requests::ecash::models::{
EpochCredentialsResponse, IssuedTicketbook as ApiIssuedCredential,
IssuedTicketbookBody as ApiIssuedCredentialInner,
};
use nym_api_requests::ecash::BlindedSignatureResponse;
use nym_compact_ecash::BlindedSignature;
use nym_config::defaults::BloomfilterParameters;
use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::ed25519;
use nym_ecash_contract_common::deposit::DepositId;
use nym_ticketbooks_merkle::IssuedTicketbook;
use sqlx::FromRow;
use std::ops::Deref;
use time::{Date, OffsetDateTime};
pub struct EpochCredentials {
pub epoch_id: u32,
pub start_id: i64,
pub total_issued: u32,
}
impl From<EpochCredentials> for EpochCredentialsResponse {
fn from(value: EpochCredentials) -> Self {
let first_epoch_credential_id = if value.start_id == -1 {
None
} else {
Some(value.start_id)
};
EpochCredentialsResponse {
epoch_id: value.epoch_id as u64,
first_epoch_credential_id,
total_issued: value.total_issued,
}
}
}
#[derive(FromRow)]
#[allow(unused)]
pub struct TicketProvider {
@@ -71,23 +42,46 @@ pub struct VerifiedTicket {
pub(crate) gateway_id: i64,
}
pub struct IssuedHash {
pub deposit_id: DepositId,
pub merkle_leaf: [u8; 32],
pub merkle_index: usize,
}
#[derive(FromRow)]
pub struct IssuedTicketbook {
pub id: i64,
pub epoch_id: u32,
pub struct RawIssuedTicketbook {
pub deposit_id: DepositId,
pub partial_credential: Vec<u8>,
pub dkg_epoch_id: u32,
/// signature on the issued credential (and the attributes)
pub signature: Vec<u8>,
pub blinded_partial_credential: Vec<u8>,
// i.e. "'attr1','attr2',..."
pub joined_private_commitments: Vec<u8>,
pub expiration_date: Date,
pub ticketbook_type_repr: u8,
/// hash on the whole data as in what has been inserted into the merkle tree
pub merkle_leaf: Vec<u8>,
/// index of the leaf under which the data has been inserted
pub merkle_index: u32,
}
impl TryFrom<RawIssuedTicketbook> for IssuedTicketbook {
type Error = NymApiStorageError;
fn try_from(raw: RawIssuedTicketbook) -> Result<Self, Self::Error> {
Ok(IssuedTicketbook {
deposit_id: raw.deposit_id,
epoch_id: raw.dkg_epoch_id as u64,
blinded_partial_credential: raw.blinded_partial_credential,
joined_encoded_private_attributes_commitments: raw.joined_private_commitments,
expiration_date: raw.expiration_date,
ticketbook_type: TicketType::try_from_encoded(raw.ticketbook_type_repr)
.map_err(|err| NymApiStorageError::database_inconsistency(err.to_string()))?,
})
}
}
#[derive(FromRow)]
@@ -142,62 +136,3 @@ impl<'a> TryFrom<&'a StoredBloomfilterParams> for BloomfilterParameters {
})
}
}
impl TryFrom<IssuedTicketbook> for ApiIssuedCredentialInner {
type Error = EcashError;
fn try_from(value: IssuedTicketbook) -> Result<Self, Self::Error> {
Ok(ApiIssuedCredentialInner {
credential: ApiIssuedCredential {
id: value.id,
epoch_id: value.epoch_id,
deposit_id: value.deposit_id,
blinded_partial_credential: BlindedSignature::from_bytes(
&value.partial_credential,
)?,
encoded_private_attributes_commitments: split_attributes(
value.joined_private_commitments,
),
expiration_date: value.expiration_date,
ticketbook_type: TicketType::try_from_encoded(value.ticketbook_type_repr)?,
},
signature: ed25519::Signature::from_bytes(&value.signature)?,
})
}
}
impl TryFrom<IssuedTicketbook> for BlindedSignatureResponse {
type Error = EcashError;
fn try_from(value: IssuedTicketbook) -> Result<Self, Self::Error> {
Ok(BlindedSignatureResponse {
blinded_signature: BlindedSignature::from_bytes(&value.partial_credential)?,
})
}
}
impl TryFrom<IssuedTicketbook> for BlindedSignature {
type Error = EcashError;
fn try_from(value: IssuedTicketbook) -> Result<Self, Self::Error> {
Ok(BlindedSignature::from_bytes(&value.partial_credential)?)
}
}
pub(crate) fn join_attributes(attrs: Vec<Vec<u8>>) -> Vec<u8> {
// note: 48 is length of encoded G1 element
let mut out = Vec::with_capacity(48 * attrs.len());
for mut attr in attrs {
// since this is called internally only, we expect valid attributes here!
assert_eq!(attr.len(), 48);
out.append(&mut attr)
}
out
}
pub(crate) fn split_attributes(attrs: Vec<u8>) -> Vec<Vec<u8>> {
assert_eq!(attrs.len() % 48, 0, "database corruption");
attrs.chunks_exact(48).map(|c| c.to_vec()).collect()
}
@@ -1,249 +0,0 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::ecash::tests::{voucher_fixture, TestFixture};
use axum::http::StatusCode;
use nym_api_requests::ecash::models::{
EpochCredentialsResponse, IssuedCredentialResponse, IssuedCredentialsResponse, Pagination,
};
use nym_api_requests::ecash::CredentialsRequestBody;
use nym_validator_client::nym_api::routes::{API_VERSION, ECASH_ROUTES};
use std::collections::BTreeMap;
#[tokio::test]
async fn epoch_credentials() {
let route_epoch1 = format!("/{API_VERSION}/{ECASH_ROUTES}/epoch-credentials/1");
let route_epoch2 = format!("/{API_VERSION}/{ECASH_ROUTES}/epoch-credentials/2");
let route_epoch42 = format!("/{API_VERSION}/{ECASH_ROUTES}/epoch-credentials/42");
let test_fixture = TestFixture::new().await;
// initially we expect 0 issued
let response = test_fixture.axum.get(&route_epoch1).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: EpochCredentialsResponse = response.json();
assert_eq!(parsed_response.epoch_id, 1);
assert_eq!(parsed_response.total_issued, 0);
assert_eq!(parsed_response.first_epoch_credential_id, None);
// get credential
test_fixture.issue_dummy_credential().await;
// now there should be one
let response = test_fixture.axum.get(&route_epoch1).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: EpochCredentialsResponse = response.json();
assert_eq!(parsed_response.epoch_id, 1);
assert_eq!(parsed_response.total_issued, 1);
assert_eq!(parsed_response.first_epoch_credential_id, Some(1));
// and another
test_fixture.issue_dummy_credential().await;
let response = test_fixture.axum.get(&route_epoch1).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: EpochCredentialsResponse = response.json();
// note that first epoch credential didn't change
assert_eq!(parsed_response.epoch_id, 1);
assert_eq!(parsed_response.total_issued, 2);
assert_eq!(parsed_response.first_epoch_credential_id, Some(1));
test_fixture.set_epoch(2).await;
let response = test_fixture.axum.get(&route_epoch2).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: EpochCredentialsResponse = response.json();
// note the epoch change
assert_eq!(parsed_response.epoch_id, 2);
assert_eq!(parsed_response.total_issued, 0);
assert_eq!(parsed_response.first_epoch_credential_id, None);
test_fixture.issue_dummy_credential().await;
let response = test_fixture.axum.get(&route_epoch2).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: EpochCredentialsResponse = response.json();
// note the epoch change
assert_eq!(parsed_response.epoch_id, 2);
assert_eq!(parsed_response.total_issued, 1);
assert_eq!(parsed_response.first_epoch_credential_id, Some(3));
// random epoch in the future
let response = test_fixture.axum.get(&route_epoch42).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: EpochCredentialsResponse = response.json();
assert_eq!(parsed_response.epoch_id, 42);
assert_eq!(parsed_response.total_issued, 0);
assert_eq!(parsed_response.first_epoch_credential_id, None);
}
#[tokio::test]
async fn issued_credential() {
fn route(id: i64) -> String {
format!("/{API_VERSION}/{ECASH_ROUTES}/issued-credential/{id}")
}
// let test_fixture = TestFixture::new()
let deposit_id1 = 123;
let deposit_id2 = 321;
let voucher1 = voucher_fixture(Some(deposit_id1));
let voucher2 = voucher_fixture(Some(deposit_id2));
let signing_data1 = voucher1.prepare_for_signing();
let request1 = voucher1.create_blind_sign_request_body(&signing_data1);
let signing_data2 = voucher2.prepare_for_signing();
let request2 = voucher2.create_blind_sign_request_body(&signing_data2);
let test_fixture = TestFixture::new().await;
test_fixture.add_deposit(&voucher1);
test_fixture.add_deposit(&voucher2);
// random credential that was never issued
let response = test_fixture.axum.get(&route(42)).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: IssuedCredentialResponse = response.json();
assert!(parsed_response.credential.is_none());
let cred1 = test_fixture.issue_credential(request1.clone()).await;
test_fixture.set_epoch(3).await;
let cred2 = test_fixture.issue_credential(request2.clone()).await;
let response = test_fixture.axum.get(&route(1)).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: IssuedCredentialResponse = response.json();
let issued1 = parsed_response.credential.unwrap();
let response = test_fixture.axum.get(&route(2)).await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: IssuedCredentialResponse = response.json();
let issued2 = parsed_response.credential.unwrap();
// TODO: currently we have no signature checks
assert_eq!(1, issued1.credential.id);
assert_eq!(1, issued1.credential.epoch_id);
assert_eq!(voucher1.deposit_id(), issued1.credential.deposit_id);
assert_eq!(
cred1.to_bytes(),
issued1.credential.blinded_partial_credential.to_bytes()
);
assert_eq!(
request1.encode_commitments(),
issued1.credential.encoded_private_attributes_commitments
);
assert_eq!(
voucher1.expiration_date(),
issued1.credential.expiration_date
);
assert_eq!(2, issued2.credential.id);
assert_eq!(3, issued2.credential.epoch_id);
assert_eq!(voucher2.deposit_id(), issued2.credential.deposit_id);
assert_eq!(
cred2.to_bytes(),
issued2.credential.blinded_partial_credential.to_bytes()
);
assert_eq!(
request2.encode_commitments(),
issued2.credential.encoded_private_attributes_commitments
);
assert_eq!(
voucher2.expiration_date(),
issued2.credential.expiration_date
);
}
#[tokio::test]
async fn issued_credentials() {
let route = format!("/{API_VERSION}/{ECASH_ROUTES}/issued-credentials");
let test_fixture = TestFixture::new().await;
// issue some credentials
for _ in 0..20 {
test_fixture.issue_dummy_credential().await;
}
let issued1 = test_fixture.issued_unchecked(1).await;
let issued2 = test_fixture.issued_unchecked(2).await;
let issued3 = test_fixture.issued_unchecked(3).await;
let issued4 = test_fixture.issued_unchecked(4).await;
let issued5 = test_fixture.issued_unchecked(5).await;
let issued13 = test_fixture.issued_unchecked(13).await;
let response = test_fixture
.axum
.post(&route)
.json(&CredentialsRequestBody {
credential_ids: vec![5],
pagination: None,
})
.await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: IssuedCredentialsResponse = response.json();
assert_eq!(parsed_response.credentials[&5], issued5);
assert!(!parsed_response.credentials.contains_key(&13));
let response = test_fixture
.axum
.post(&route)
.json(&CredentialsRequestBody {
credential_ids: vec![5, 13],
pagination: None,
})
.await;
assert_eq!(response.status_code(), StatusCode::OK);
let parsed_response: IssuedCredentialsResponse = response.json();
assert_eq!(parsed_response.credentials[&5], issued5);
assert_eq!(parsed_response.credentials[&13], issued13);
let response_paginated = test_fixture
.axum
.post(&route)
.json(&CredentialsRequestBody {
credential_ids: vec![],
pagination: Some(Pagination {
last_key: None,
limit: Some(2),
}),
})
.await;
assert_eq!(response_paginated.status_code(), StatusCode::OK);
let parsed_response: IssuedCredentialsResponse = response_paginated.json();
let mut expected = BTreeMap::new();
expected.insert(1, issued1);
expected.insert(2, issued2);
assert_eq!(expected, parsed_response.credentials);
let response_paginated = test_fixture
.axum
.post(&route)
.json(&CredentialsRequestBody {
credential_ids: vec![],
pagination: Some(Pagination {
last_key: Some(2),
limit: Some(3),
}),
})
.await;
assert_eq!(response_paginated.status_code(), StatusCode::OK);
let parsed_response: IssuedCredentialsResponse = response_paginated.json();
let mut expected = BTreeMap::new();
expected.insert(3, issued3);
expected.insert(4, issued4);
expected.insert(5, issued5);
assert_eq!(expected, parsed_response.credentials);
}
@@ -0,0 +1,172 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::ecash::error::EcashError;
use crate::ecash::tests::{voucher_fixture, TestFixture};
use nym_api_requests::ecash::models::CommitedDeposit;
#[tokio::test]
async fn issued_ticketbooks_for() {
let deposit_id1 = 123;
let deposit_id2 = 321;
let voucher1 = voucher_fixture(Some(deposit_id1));
let voucher2 = voucher_fixture(Some(deposit_id2));
let expiration_date = voucher1.expiration_date();
let signing_data1 = voucher1.prepare_for_signing();
let request1 = voucher1.create_blind_sign_request_body(&signing_data1);
let signing_data2 = voucher2.prepare_for_signing();
let request2 = voucher2.create_blind_sign_request_body(&signing_data2);
let test_fixture = TestFixture::new().await;
test_fixture.add_chain_deposit(&voucher1);
test_fixture.add_chain_deposit(&voucher2);
// no ticketbooks issued yet
let response = test_fixture
.issued_ticketbooks_for_unchecked(expiration_date)
.await;
assert!(response.body.deposits.is_empty());
assert!(response.body.merkle_root.is_none());
test_fixture.issue_ticketbook(request1.clone()).await;
let response1 = test_fixture
.issued_ticketbooks_for_unchecked(expiration_date)
.await;
assert_eq!(
response1.body.deposits,
vec![CommitedDeposit {
deposit_id: request1.deposit_id,
merkle_index: 0,
}]
);
assert!(response1.body.merkle_root.is_some());
test_fixture.issue_ticketbook(request2.clone()).await;
let response2 = test_fixture
.issued_ticketbooks_for_unchecked(expiration_date)
.await;
let mut got_sorted = response2.body.deposits.clone();
got_sorted.sort_by_key(|d| d.merkle_index);
assert_eq!(
got_sorted,
vec![
CommitedDeposit {
deposit_id: request1.deposit_id,
merkle_index: 0,
},
CommitedDeposit {
deposit_id: request2.deposit_id,
merkle_index: 1,
}
]
);
assert!(response2.body.merkle_root.is_some());
assert_ne!(response1.body.merkle_root, response2.body.merkle_root);
}
#[tokio::test]
async fn issued_ticketbooks_challenge() {
let deposit_id1 = 123;
let deposit_id2 = 321;
let voucher1 = voucher_fixture(Some(deposit_id1));
let voucher2 = voucher_fixture(Some(deposit_id2));
let expiration_date = voucher1.expiration_date();
let signing_data1 = voucher1.prepare_for_signing();
let request1 = voucher1.create_blind_sign_request_body(&signing_data1);
let signing_data2 = voucher2.prepare_for_signing();
let request2 = voucher2.create_blind_sign_request_body(&signing_data2);
let test_fixture = TestFixture::new().await;
test_fixture.add_chain_deposit(&voucher1);
test_fixture.add_chain_deposit(&voucher2);
// empty challenge
let response = test_fixture
.issued_ticketbooks_challenge(expiration_date, vec![])
.await;
assert_eq!(
response.text(),
EcashError::MerkleProofGenerationFailure.to_string()
);
// // challenge for what we haven't issued
let response = test_fixture
.issued_ticketbooks_challenge(expiration_date, vec![deposit_id1])
.await;
assert_eq!(
response.text(),
EcashError::UnavailableTicketbook {
deposit_id: deposit_id1
}
.to_string()
);
let cred1 = test_fixture.issue_ticketbook(request1.clone()).await;
let response = test_fixture
.issued_ticketbooks_challenge_unchecked(expiration_date, vec![deposit_id1])
.await;
assert_eq!(response.body.partial_ticketbooks.len(), 1);
assert_eq!(response.body.expiration_date, expiration_date);
assert_eq!(response.body.merkle_proof.total_leaves(), 1);
let ticketbook = &response.body.partial_ticketbooks[&deposit_id1];
assert_eq!(
cred1.blinded_signature.to_bytes().to_vec(),
ticketbook.blinded_partial_credential
);
let cred2 = test_fixture.issue_ticketbook(request2.clone()).await;
// proof for the old deposit
let response = test_fixture
.issued_ticketbooks_challenge_unchecked(expiration_date, vec![deposit_id1])
.await;
assert_eq!(response.body.partial_ticketbooks.len(), 1);
assert_eq!(response.body.expiration_date, expiration_date);
assert_eq!(response.body.merkle_proof.total_leaves(), 2);
// proof for new deposit
let ticketbook = &response.body.partial_ticketbooks[&deposit_id1];
assert_eq!(
cred1.blinded_signature.to_bytes().to_vec(),
ticketbook.blinded_partial_credential
);
// proof for new deposit
let response = test_fixture
.issued_ticketbooks_challenge_unchecked(expiration_date, vec![deposit_id2])
.await;
assert_eq!(response.body.partial_ticketbooks.len(), 1);
assert_eq!(response.body.expiration_date, expiration_date);
assert_eq!(response.body.merkle_proof.total_leaves(), 2);
// proof for new deposit
let ticketbook = &response.body.partial_ticketbooks[&deposit_id2];
assert_eq!(
cred2.blinded_signature.to_bytes().to_vec(),
ticketbook.blinded_partial_credential
);
// proof for BOTH deposits
let response = test_fixture
.issued_ticketbooks_challenge_unchecked(expiration_date, vec![deposit_id1, deposit_id2])
.await;
assert_eq!(response.body.partial_ticketbooks.len(), 2);
assert_eq!(response.body.expiration_date, expiration_date);
assert_eq!(response.body.merkle_proof.total_leaves(), 2);
let ticketbook = &response.body.partial_ticketbooks[&deposit_id1];
assert_eq!(
cred1.blinded_signature.to_bytes().to_vec(),
ticketbook.blinded_partial_credential
);
let ticketbook = &response.body.partial_ticketbooks[&deposit_id2];
assert_eq!(
cred2.blinded_signature.to_bytes().to_vec(),
ticketbook.blinded_partial_credential
);
}
+103 -54
View File
@@ -6,26 +6,29 @@ use crate::ecash::api_routes::handlers::ecash_routes;
use crate::ecash::error::{EcashError, Result};
use crate::ecash::keys::KeyPairWithEpoch;
use crate::ecash::state::EcashState;
use crate::ecash::storage::EcashStorageExt;
use crate::network::models::NetworkDetails;
use crate::node_describe_cache::DescribedNodes;
use crate::node_status_api::handlers::unstable;
use crate::node_status_api::NodeStatusCache;
use crate::nym_contract_cache::cache::NymContractCache;
use crate::support::caching::cache::SharedCache;
use crate::support::config;
use crate::support::http::state::AppState;
use crate::support::storage::NymApiStorage;
use async_trait::async_trait;
use axum::Router;
use axum_test::http::StatusCode;
use axum_test::TestServer;
use axum_test::{TestResponse, TestServer};
use cosmwasm_std::testing::{mock_env, mock_info};
use cosmwasm_std::{
from_binary, to_binary, Addr, Binary, BlockInfo, CosmosMsg, Decimal, MessageInfo, WasmMsg,
};
use cw3::{Proposal, ProposalResponse, Vote, VoteInfo, VoteResponse, Votes};
use cw4::{Cw4Contract, MemberResponse};
use nym_api_requests::ecash::models::{IssuedCredentialResponse, IssuedTicketbookBody};
use nym_api_requests::ecash::models::{
IssuedTicketbooksChallengeRequest, IssuedTicketbooksChallengeResponse,
IssuedTicketbooksForResponse,
};
use nym_api_requests::ecash::{BlindSignRequestBody, BlindedSignatureResponse};
use nym_coconut_dkg_common::dealer::{
DealerDetails, DealerDetailsResponse, DealerType, RegisteredDealerDetails,
@@ -50,7 +53,10 @@ use nym_crypto::asymmetric::identity;
use nym_dkg::{NodeIndex, Threshold};
use nym_ecash_contract_common::blacklist::{BlacklistedAccountResponse, Blacklisting};
use nym_ecash_contract_common::deposit::{Deposit, DepositId, DepositResponse};
use nym_validator_client::nym_api::routes::{API_VERSION, ECASH_BLIND_SIGN, ECASH_ROUTES};
use nym_validator_client::nym_api::routes::{
API_VERSION, ECASH_BLIND_SIGN, ECASH_ISSUED_TICKETBOOKS_CHALLENGE,
ECASH_ISSUED_TICKETBOOKS_FOR, ECASH_ROUTES,
};
use nym_validator_client::nyxd::cosmwasm_client::logs::Log;
use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult;
use nym_validator_client::nyxd::{AccountId, ExecTxResult, Fee, Hash, TxResponse};
@@ -63,11 +69,12 @@ use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tempfile::{tempdir, TempDir};
use time::Date;
use tokio::sync::RwLock;
pub(crate) mod fixtures;
pub(crate) mod helpers;
mod issued_credentials;
mod issued_ticketbooks;
const TEST_COIN_DENOM: &str = "unym";
const TEST_REWARDING_VALIDATOR_ADDRESS: &str = "n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0";
@@ -548,9 +555,7 @@ impl super::client::Client for DummyClient {
.proposals
.get(&proposal_id)
.cloned()
.ok_or(EcashError::IncorrectProposal {
reason: String::from("proposal not found"),
})?;
.expect("proposal not found");
// replicate behaviour from `query_proposal` of cw3
Ok(proposal_to_response(
@@ -852,9 +857,7 @@ impl super::client::Client for DummyClient {
let voter = self.validator_address.to_string();
let mut chain = self.state.lock().unwrap();
if !chain.multisig_contract.proposals.contains_key(&proposal_id) {
return Err(EcashError::IncorrectProposal {
reason: String::from("proposal not found"),
});
panic!("proposal not found");
}
// for now we assume every group member is a voter
@@ -898,9 +901,7 @@ impl super::client::Client for DummyClient {
let multisig_address: AccountId = chain.multisig_contract.address.as_str().parse().unwrap();
let Some(proposal) = chain.multisig_contract.proposals.get_mut(&proposal_id) else {
return Err(EcashError::ProposalIdError {
reason: String::from("proposal id not found"),
});
panic!("proposal not found");
};
if proposal.status != cw3::Status::Passed {
@@ -1049,7 +1050,7 @@ impl super::client::Client for DummyClient {
let epoch_id = chain.dkg_contract.epoch.epoch_id;
let Some(dealer_details) = chain.dkg_contract.get_dealer_details(&address, epoch_id) else {
// Just throw some error, not really the correct one
return Err(EcashError::DepositInfoNotFound);
return Err(EcashError::NotASigner);
};
let dkg_contract = chain.dkg_contract.address.clone();
@@ -1116,6 +1117,7 @@ impl super::client::Client for DummyClient {
}
}
#[allow(dead_code)]
#[derive(Clone)]
pub struct DummyCommunicationChannel {
current_epoch: Arc<AtomicU64>,
@@ -1242,6 +1244,7 @@ pub fn voucher_fixture(deposit_id: Option<DepositId>) -> IssuanceTicketBook {
IssuanceTicketBook::new(deposit_id, identifier, id_priv, TicketType::V1MixnetEntry)
}
#[allow(unused)]
fn dummy_signature() -> identity::Signature {
"3vUCc6MCN5AC2LNgDYjRB1QeErZSN1S8f6K14JHjpUcKWXbjGYFExA8DbwQQBki9gyUqrpBF94Drttb4eMcGQXkp"
.parse()
@@ -1315,14 +1318,17 @@ impl TestFixture {
.parse()
.unwrap();
let mut config = config::Config::new("test");
config.ecash_signer.enabled = true;
let ecash_state = EcashState::new(
&config,
ecash_contract,
nyxd_client,
identity,
staged_key_pair,
comm_channel,
storage.clone(),
false,
)
.await
.unwrap();
@@ -1342,6 +1348,7 @@ impl TestFixture {
}
}
#[allow(dead_code)]
async fn set_epoch(&self, epoch: u64) {
let current_epoch = self.epoch.load(Ordering::Relaxed);
self.epoch.store(epoch, Ordering::Relaxed);
@@ -1358,7 +1365,7 @@ impl TestFixture {
self.chain_state.lock().unwrap().txs.insert(hash, tx);
}
fn add_deposit(&self, voucher_data: &IssuanceTicketBook) {
fn add_chain_deposit(&self, voucher_data: &IssuanceTicketBook) {
let mut chain = self.chain_state.lock().unwrap();
let deposit = Deposit {
bs58_encoded_ed25519_pubkey: voucher_data
@@ -1373,6 +1380,7 @@ impl TestFixture {
assert!(existing.is_none());
}
#[allow(dead_code)]
async fn issue_dummy_credential(&self) {
let mut rng = OsRng;
let deposit_id = rng.next_u32();
@@ -1382,14 +1390,14 @@ impl TestFixture {
let signing_data = voucher.prepare_for_signing();
let req = voucher.create_blind_sign_request_body(&signing_data);
self.add_deposit(&voucher);
self.issue_credential(req).await;
self.add_chain_deposit(&voucher);
self.issue_ticketbook(req).await;
}
async fn issue_credential(&self, req: BlindSignRequestBody) -> BlindedSignatureResponse {
async fn issue_ticketbook(&self, req: BlindSignRequestBody) -> BlindedSignatureResponse {
let response = self
.axum
.post(&format!("/{API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}",))
.post(&format!("/{API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"))
.json(&req)
.await;
@@ -1397,31 +1405,57 @@ impl TestFixture {
response.json()
}
async fn issued_credential(&self, id: i64) -> Option<IssuedCredentialResponse> {
async fn issued_ticketbooks_for_unchecked(
&self,
expiration_date: Date,
) -> IssuedTicketbooksForResponse {
let response = self
.axum
.get(&format!(
"/{API_VERSION}/{ECASH_ROUTES}/issued-credential/{id}"
"/{API_VERSION}/{ECASH_ROUTES}/{ECASH_ISSUED_TICKETBOOKS_FOR}/{expiration_date}"
))
.await;
assert_eq!(response.status_code(), StatusCode::OK);
response.json()
}
async fn issued_unchecked(&self, id: i64) -> IssuedTicketbookBody {
self.issued_credential(id)
async fn issued_ticketbooks_challenge(
&self,
expiration_date: Date,
deposits: Vec<DepositId>,
) -> TestResponse {
self.axum
.post(&format!(
"/{API_VERSION}/{ECASH_ROUTES}/{ECASH_ISSUED_TICKETBOOKS_CHALLENGE}"
))
.json(&IssuedTicketbooksChallengeRequest {
expiration_date,
deposits,
})
.await
.unwrap()
.credential
.unwrap()
}
async fn issued_ticketbooks_challenge_unchecked(
&self,
expiration_date: Date,
deposits: Vec<DepositId>,
) -> IssuedTicketbooksChallengeResponse {
let response = self
.issued_ticketbooks_challenge(expiration_date, deposits)
.await;
assert_eq!(response.status_code(), StatusCode::OK);
response.json()
}
}
#[cfg(test)]
mod credential_tests {
use super::*;
use crate::ecash::storage::EcashStorageExt;
use axum::http::StatusCode;
use nym_compact_ecash::ttp_keygen;
use nym_ticketbooks_merkle::MerkleLeaf;
#[tokio::test]
async fn already_issued() {
@@ -1432,28 +1466,31 @@ mod credential_tests {
let deposit_id = request_body.deposit_id;
let test_fixture = TestFixture::new().await;
test_fixture.add_deposit(&voucher);
test_fixture.add_chain_deposit(&voucher);
let sig = blinded_signature_fixture();
let commitments = request_body.encode_commitments();
let commitments = request_body.encode_join_commitments();
let expiration_date = request_body.expiration_date;
test_fixture
.storage
.store_issued_credential(
42,
.store_issued_ticketbook(
deposit_id,
&sig,
dummy_signature(),
commitments,
42,
&sig.to_bytes(),
&commitments,
expiration_date,
voucher.ticketbook_type(),
MerkleLeaf {
hash: vec![42u8; 32],
index: 0,
},
)
.await
.unwrap();
let response = test_fixture
.axum
.post(&format!("/{API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}",))
.post(&format!("/{API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"))
.json(&request_body)
.await;
@@ -1493,7 +1530,11 @@ mod credential_tests {
.await;
staged_key_pair.validate();
let mut config = config::Config::new("test");
config.ecash_signer.enabled = true;
let state = EcashState::new(
&config,
"n16a32stm6kknhq5cc8rx77elr66pygf2hfszw7wvpq746x3uffylqkjar4l"
.parse()
.unwrap(),
@@ -1502,7 +1543,6 @@ mod credential_tests {
staged_key_pair,
comm_channel,
storage.clone(),
false,
)
.await
.unwrap();
@@ -1514,18 +1554,21 @@ mod credential_tests {
let signing_data = voucher.prepare_for_signing();
let request_body = voucher.create_blind_sign_request_body(&signing_data);
let commitments = request_body.encode_commitments();
let commitments = request_body.encode_join_commitments();
let expiration_date = request_body.expiration_date;
let sig = blinded_signature_fixture();
storage
.store_issued_credential(
42,
.store_issued_ticketbook(
deposit_id,
&sig,
dummy_signature(),
commitments.clone(),
42,
&sig.to_bytes(),
&commitments,
expiration_date,
voucher.ticketbook_type(),
MerkleLeaf {
hash: vec![42u8; 32],
index: 0,
},
)
.await
.unwrap();
@@ -1552,14 +1595,17 @@ mod credential_tests {
// Check that the new payload is not stored if there was already something signed for tx_hash
let storage_err = storage
.store_issued_credential(
42,
.store_issued_ticketbook(
deposit_id,
&blinded_signature,
dummy_signature(),
commitments.clone(),
42,
&blinded_signature.to_bytes(),
&commitments,
expiration_date,
voucher.ticketbook_type(),
MerkleLeaf {
hash: vec![42u8; 32],
index: 1,
},
)
.await;
assert!(storage_err.is_err());
@@ -1568,14 +1614,17 @@ mod credential_tests {
let deposit_id = 69;
storage
.store_issued_credential(
42,
.store_issued_ticketbook(
deposit_id,
&blinded_signature,
dummy_signature(),
commitments.clone(),
42,
&blinded_signature.to_bytes(),
&commitments,
expiration_date,
voucher.ticketbook_type(),
MerkleLeaf {
hash: vec![42u8; 32],
index: 2,
},
)
.await
.unwrap();
-1
View File
@@ -26,7 +26,6 @@ pub(crate) mod nym_contract_cache;
pub(crate) mod nym_nodes;
mod status;
pub(crate) mod support;
mod v3_migration;
// TODO rocket: remove all such Todos once rocket is phased out completely
#[tokio::main]
+8 -3
View File
@@ -5,6 +5,7 @@ use crate::ecash::error::{EcashError, RedemptionError};
use crate::node_status_api::utils::NodeUptimes;
use crate::storage::models::NodeStatus;
use crate::support::caching::cache::UninitialisedCache;
use nym_api_requests::ecash::models::DepositId;
use nym_api_requests::models::{
HistoricalPerformanceResponse, HistoricalUptimeResponse, NodePerformance,
OldHistoricalUptimeResponse, RequestError,
@@ -437,6 +438,9 @@ pub enum NymApiStorageError {
#[error("could not find gateway {identity} in the storage")]
GatewayNotFound { identity: String },
#[error("could not find ticketbooks corresponding to deposits {deposits:?}")]
UnavailableTicketbooks { deposits: Vec<DepositId> },
// I don't think we want to expose errors to the user about what really happened
#[error("experienced internal database error")]
InternalDatabaseError(sqlx::Error),
@@ -460,8 +464,9 @@ impl From<sqlx::Error> for NymApiStorageError {
impl NymApiStorageError {
pub fn database_inconsistency<S: Into<String>>(reason: S) -> NymApiStorageError {
NymApiStorageError::DatabaseInconsistency {
reason: reason.into(),
}
let reason = reason.into();
error!("experienced database inconsistency: {reason}");
NymApiStorageError::DatabaseInconsistency { reason }
}
}
+3 -3
View File
@@ -24,8 +24,8 @@ use crate::support::config::Config;
use crate::support::http::state::{AppState, ShutdownHandles, TASK_MANAGER_TIMEOUT_S};
use crate::support::http::RouterBuilder;
use crate::support::nyxd;
use crate::support::storage::runtime_migrations::m001_directory_services_v2_1::migrate_to_directory_services_v2_1;
use crate::support::storage::NymApiStorage;
use crate::v3_migration::migrate_v3_database;
use crate::{
circulating_supply_api, ecash, epoch_operations, network_monitor, node_describe_cache,
node_status_api, nym_contract_cache,
@@ -122,7 +122,7 @@ async fn start_nym_api_tasks_axum(config: &Config) -> anyhow::Result<ShutdownHan
let storage = NymApiStorage::init(&config.node_status_api.storage_paths.database_path).await?;
// try to perform any needed migrations of the storage
migrate_v3_database(&storage, &nyxd_client).await?;
migrate_to_directory_services_v2_1(&storage, &nyxd_client).await?;
let identity_keypair = config.base.storage_paths.load_identity()?;
let identity_public_key = *identity_keypair.public_key();
@@ -147,13 +147,13 @@ async fn start_nym_api_tasks_axum(config: &Config) -> anyhow::Result<ShutdownHan
let encoded_identity = identity_keypair.public_key().to_base58_string();
let ecash_state = EcashState::new(
config,
ecash_contract,
nyxd_client.clone(),
identity_keypair,
ecash_keypair_wrapper.clone(),
comm_channel,
storage.clone(),
!config.ecash_signer.enabled,
)
.await?;
+7
View File
@@ -58,6 +58,9 @@ const DEFAULT_CIRCULATING_SUPPLY_CACHE_INTERVAL: Duration = Duration::from_secs(
pub(crate) const DEFAULT_NODE_DESCRIBE_CACHE_INTERVAL: Duration = Duration::from_secs(4500);
pub(crate) const DEFAULT_NODE_DESCRIBE_BATCH_SIZE: usize = 50;
// keep them for 2 extra days beyond the specified expiration date
pub(crate) const DEFAULT_MAX_ISSUED_TICKETBOOKS_RETENTION_DAYS: u32 = 2;
const DEFAULT_MONITOR_THRESHOLD: u8 = 60;
const DEFAULT_MIN_MIXNODE_RELIABILITY: u8 = 50;
const DEFAULT_MIN_GATEWAY_RELIABILITY: u8 = 20;
@@ -552,12 +555,16 @@ pub struct EcashSignerDebug {
/// Duration of the interval for polling the dkg contract.
#[serde(with = "humantime_serde")]
pub dkg_contract_polling_rate: Duration,
/// Specifies how long should the issued ticketbooks be kept (beyond the specified expiration date)
pub issued_ticketbooks_retention_period_days: u32,
}
impl Default for EcashSignerDebug {
fn default() -> Self {
EcashSignerDebug {
dkg_contract_polling_rate: DEFAULT_DKG_CONTRACT_POLLING_RATE,
issued_ticketbooks_retention_period_days: DEFAULT_MAX_ISSUED_TICKETBOOKS_RETENTION_DAYS,
}
}
}
+10 -5
View File
@@ -65,17 +65,22 @@ use utoipauto::utoipauto;
nym_api_requests::ecash::VerificationKeyResponse,
nym_api_requests::ecash::models::AggregatedExpirationDateSignatureResponse,
nym_api_requests::ecash::models::AggregatedCoinIndicesSignatureResponse,
nym_api_requests::ecash::models::EpochCredentialsResponse,
nym_api_requests::ecash::models::IssuedCredentialResponse,
nym_api_requests::ecash::models::IssuedTicketbookBody,
nym_api_requests::ecash::models::MasterVerificationKeyResponse,
nym_api_requests::ecash::models::BlindedSignatureResponse,
nym_api_requests::ecash::models::BlindSignRequestBody,
nym_api_requests::ecash::models::PartialExpirationDateSignatureResponse,
nym_api_requests::ecash::models::PartialCoinIndicesSignatureResponse,
nym_api_requests::ecash::models::EcashTicketVerificationResponse,
nym_api_requests::ecash::models::EcashTicketVerificationRejection,
nym_api_requests::ecash::models::EcashBatchTicketRedemptionResponse,
nym_api_requests::ecash::models::SpentCredentialsResponse,
nym_api_requests::ecash::models::IssuedCredentialsResponse,
nym_api_requests::ecash::models::VerifyEcashTicketBody,
nym_api_requests::ecash::models::VerifyEcashCredentialBody,
nym_api_requests::ecash::models::CommitedDeposit,
nym_api_requests::ecash::models::IssuedTicketbooksForResponseBody,
nym_api_requests::ecash::models::IssuedTicketbooksForResponse,
nym_api_requests::ecash::models::IssuedTicketbooksChallengeRequest,
nym_api_requests::ecash::models::IssuedTicketbooksChallengeResponseBody,
nym_api_requests::ecash::models::IssuedTicketbooksChallengeResponse,
nym_api_requests::nym_nodes::SkimmedNode,
nym_api_requests::nym_nodes::SemiSkimmedNode,
nym_api_requests::nym_nodes::FullFatNode,
+1
View File
@@ -23,6 +23,7 @@ use self::manager::{AvgGatewayReliability, AvgMixnodeReliability};
pub(crate) mod manager;
pub(crate) mod models;
pub(crate) mod runtime_migrations;
// note that clone here is fine as upon cloning the same underlying pool will be used
#[derive(Clone)]
@@ -7,7 +7,7 @@ use anyhow::bail;
use std::collections::HashMap;
use tracing::{debug, info, warn};
pub async fn migrate_v3_database(
pub async fn migrate_to_directory_services_v2_1(
storage: &NymApiStorage,
nyxd_client: &Client,
) -> anyhow::Result<()> {
@@ -0,0 +1,2 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,5 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod m001_directory_services_v2_1;
pub mod m002_issued_credentials_commitments;
+23
View File
@@ -2284,6 +2284,7 @@ dependencies = [
"nym-network-defaults",
"nym-node-requests",
"nym-serde-helpers",
"nym-ticketbooks-merkle",
"schemars",
"serde",
"serde_json",
@@ -2671,6 +2672,19 @@ dependencies = [
"thiserror",
]
[[package]]
name = "nym-ticketbooks-merkle"
version = "0.1.0"
dependencies = [
"nym-credentials-interface",
"nym-serde-helpers",
"rs_merkle",
"schemars",
"serde",
"sha2 0.10.8",
"time",
]
[[package]]
name = "nym-validator-client"
version = "0.1.0"
@@ -3423,6 +3437,15 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "rs_merkle"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b241d2e59b74ef9e98d94c78c47623d04c8392abaf82014dfd372a16041128f"
dependencies = [
"sha2 0.10.8",
]
[[package]]
name = "rsa"
version = "0.9.6"
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-validator-rewarder"
version = "0.1.0"
version = "0.2.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -16,6 +16,7 @@ bip39 = { workspace = true, features = ["zeroize"] }
cosmwasm-std.workspace = true
clap = { workspace = true, features = ["cargo", "env"] }
futures.workspace = true
rand.workspace = true
serde = { workspace = true, features = ["derive"] }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate", "time"] }
thiserror.workspace = true
@@ -24,7 +25,7 @@ tracing.workspace = true
time.workspace = true
url.workspace = true
zeroize.workspace = true
serde_json.workspace = true
serde_with = { workspace = true }
sha2 = { workspace = true }
humantime = { workspace = true }
@@ -34,6 +35,7 @@ humantime-serde.workspace = true
nym-bin-common = { path = "../common/bin-common", features = ["output_format", "basic_tracing"] }
nym-config = { path = "../common/config" }
nym-ecash-time = { path = "../common/ecash-time" }
nym-contracts-common = { path = "../common/cosmwasm-smart-contracts/contracts-common" }
nym-compact-ecash = { path = "../common/nym_offline_compact_ecash" }
nym-crypto = { path = "../common/crypto", features = ["asymmetric"] }
nym-credentials = { path = "../common/credentials" }
@@ -43,6 +45,8 @@ nym-validator-client = { path = "../common/client-libs/validator-client" }
nym-coconut-dkg-common = { path = "../common/cosmwasm-smart-contracts/coconut-dkg" }
nym-coconut-bandwidth-contract-common = { path = "../common/cosmwasm-smart-contracts/coconut-bandwidth-contract" }
nyxd-scraper = { path = "../common/nyxd-scraper" }
nym-ticketbooks-merkle = { path = "../common/ticketbooks-merkle" }
nym-serde-helpers = { path = "../common/serde-helpers", features = ["base64"] }
[build-dependencies]
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
@@ -0,0 +1,141 @@
/*
* Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: GPL-3.0-only
*/
-- explicitly mark end of "old" combined rewarding with the `_v1` suffix
-- (as a result, we have to recreate bunch of tables due to foreign key constraints)
ALTER TABLE rewarding_epoch
RENAME TO combined_rewarding_epoch_v1;
ALTER TABLE epoch_block_signing
RENAME TO epoch_block_signing_v1;
ALTER TABLE block_signing_reward
RENAME TO block_signing_reward_v1;
CREATE TABLE block_signing_rewarding_epoch
(
id INTEGER NOT NULL PRIMARY KEY,
start_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
end_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
-- rewarding budget allocated to this block signing rewarding epoch
budget TEXT NOT NULL,
-- indicates whether block signing rewarding/monitoring module is disabled
disabled BOOLEAN NOT NULL
);
CREATE TABLE block_signing_rewarding_details
(
rewarding_epoch_id INTEGER NOT NULL REFERENCES block_signing_rewarding_epoch (id),
-- total voting power at the start of the epoch used for determining relative rewards
total_voting_power_at_epoch_start INTEGER NOT NULL,
-- total number of blocks processed during this rewarding epoch
num_blocks INTEGER NOT NULL,
-- the actual amount spent (decreased by missing blocks, etc.)
spent TEXT NOT NULL,
-- if successful, the transaction hash of the rewarding transaction
rewarding_tx TEXT,
-- if unsuccessful, the error indicating why the rewards were not sent out
rewarding_error TEXT,
-- indicates whether this instance is running in 'monitor only' mode where it's not expected to be sending any transactions
monitor_only BOOLEAN NOT NULL
);
CREATE TABLE block_signing_reward
(
rewarding_epoch_id INTEGER NOT NULL REFERENCES block_signing_rewarding_epoch (id),
validator_consensus_address TEXT NOT NULL,
operator_account TEXT NOT NULL,
whitelisted BOOLEAN NOT NULL,
amount TEXT NOT NULL,
voting_power BIGINT NOT NULL,
voting_power_share TEXT NOT NULL,
signed_blocks INTEGER NOT NULL,
signed_blocks_percent TEXT NOT NULL,
UNIQUE (rewarding_epoch_id, operator_account)
);
-- recreate tables for issuance rewarding as the mechanisms/epochs/etc for verification changed
DROP TABLE epoch_credential_issuance;
DROP TABLE malformed_credential;
DROP TABLE credential_issuance_reward;
DROP TABLE validated_deposit;
DROP TABLE double_signing_evidence;
DROP TABLE issuance_evidence;
DROP TABLE issuance_validation_failure;
CREATE TABLE ticketbook_issuance_epoch
(
expiration_date DATE NOT NULL PRIMARY KEY,
-- rewarding budget allocated to this ticketbook issuance epoch
total_budget TEXT NOT NULL,
whitelist_size INTEGER NOT NULL,
-- rewarding budget allocated for a single operator based on total budget and whitelist size
budget_per_operator TEXT NOT NULL,
-- indicates whether block signing rewarding/monitoring module is disabled
disabled BOOLEAN NOT NULL
);
CREATE TABLE ticketbook_issuance_rewarding_details
(
ticketbook_expiration_date DATE NOT NULL REFERENCES ticketbook_issuance_epoch (expiration_date),
-- approximate numbers of total deposits made with the particular expiration date
approximate_deposits INTEGER NOT NULL,
-- the actual amount spent (decreased by not issuing every available ticketbook, etc. it's not expected everyone will ever get 100%)
spent TEXT NOT NULL,
-- if successful, the transaction hash of the rewarding transaction
rewarding_tx TEXT,
-- if unsuccessful, the error indicating why the rewards were not sent out
rewarding_error TEXT,
-- indicates whether this instance is running in 'monitor only' mode where it's not expected to be sending any transactions
monitor_only BOOLEAN NOT NULL
);
CREATE TABLE ticketbook_issuance_reward
(
ticketbook_expiration_date DATE NOT NULL REFERENCES ticketbook_issuance_epoch (expiration_date),
api_endpoint TEXT NOT NULL,
operator_account TEXT NOT NULL,
whitelisted BOOLEAN NOT NULL,
banned BOOLEAN NOT NULL,
amount TEXT NOT NULL,
issued_partial_ticketbooks INTEGER NOT NULL,
share_of_issued_ticketbooks FLOAT NOT NULL,
skipped_verification BOOLEAN NOT NULL,
subsample_size INTEGER NOT NULL,
UNIQUE (ticketbook_expiration_date, operator_account)
);
CREATE TABLE banned_ticketbook_issuer
(
operator_account TEXT PRIMARY KEY NOT NULL,
api_endpoint TEXT NOT NULL,
banned_on TIMESTAMP WITHOUT TIME ZONE NOT NULL,
associated_ticketbook_expiration_date DATE NOT NULL REFERENCES ticketbook_issuance_epoch (expiration_date),
reason TEXT NOT NULL,
evidence BLOB NOT NULL
)
+8 -20
View File
@@ -59,20 +59,14 @@ pub struct ConfigOverridableArgs {
#[clap(long, env = "NYM_VALIDATOR_REWARDER_BLOCK_SIGNING_MONITORING_ONLY")]
pub block_signing_monitoring_only: bool,
#[clap(long, env = "NYM_VALIDATOR_TICKETBOOK_ISSUANCE_MONITORING_ONLY")]
pub ticketbook_issuance_monitoring_only: bool,
#[clap(
long,
env = "NYM_VALIDATOR_REWARDER_DISABLE_CREDENTIAL_ISSUANCE_REWARDING"
env = "NYM_VALIDATOR_REWARDER_DISABLE_TICKETBOOK_ISSUANCE_REWARDING"
)]
pub disable_credential_issuance_rewarding: bool,
#[clap(long, env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_MONITOR_RUN_INTERVAL")]
pub credential_monitor_run_interval: Option<humantime::Duration>,
#[clap(long, env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_MONITOR_MIN_VALIDATION")]
pub credential_monitor_min_validation: Option<usize>,
#[clap(long, env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_MONITOR_SAMPLING_RATE")]
pub credential_monitor_sampling_rate: Option<f64>,
pub disable_ticketbook_issuance_rewarding: bool,
#[clap(long, env = "NYM_VALIDATOR_REWARDER_SCRAPER_ENDPOINT")]
pub scraper_endpoint: Option<Url>,
@@ -86,17 +80,11 @@ pub struct ConfigOverridableArgs {
#[clap(long, env = "NYM_VALIDATOR_REWARDER_EPOCH_DURATION")]
pub epoch_duration: Option<humantime::Duration>,
#[clap(long, env = "NYM_VALIDATOR_REWARDER_BLOCK_SIGNIG_REWARD_RATIO")]
#[clap(long, env = "NYM_VALIDATOR_REWARDER_BLOCK_SIGNING_REWARD_RATIO")]
pub block_signing_reward_ratio: Option<f64>,
#[clap(long, env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_ISSUANCE_REWARD_RATIO")]
pub credential_issuance_reward_ratio: Option<f64>,
#[clap(
long,
env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_VERIFICATION_REWARD_RATIO"
)]
pub credential_verification_reward_ratio: Option<f64>,
#[clap(long, env = "NYM_VALIDATOR_REWARDER_TICKETBOOK_ISSUANCE_REWARD_RATIO")]
pub ticketbook_issuance_reward_ratio: Option<f64>,
}
#[derive(Subcommand, Debug)]
+126 -37
View File
@@ -5,6 +5,8 @@ use crate::config::persistence::paths::ValidatorRewarderPaths;
use crate::config::r#override::ConfigOverride;
use crate::config::template::CONFIG_TEMPLATE;
use crate::error::NymRewarderError;
use crate::rewarder::ticketbook_issuance;
use cosmwasm_std::{Decimal, Uint128};
use nym_config::{
must_get_home, read_config_from_toml_file, save_formatted_config_to_file, NymConfigTemplate,
DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILENAME, DEFAULT_DATA_DIR, NYM_DIR,
@@ -16,7 +18,7 @@ use serde_with::{serde_as, DisplayFromStr};
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::debug;
use tracing::{debug, info};
use url::Url;
use zeroize::{Zeroize, ZeroizeOnDrop};
@@ -27,13 +29,16 @@ mod template;
const DEFAULT_REWARDER_DIR: &str = "validators-rewarder";
#[allow(clippy::inconsistent_digit_grouping)]
const DEFAULT_MIX_REWARDING_BUDGET: u128 = 1000_000000;
const DEFAULT_MIX_REWARDING_DENOM: &str = "unym";
const DEFAULT_DAILY_REWARDING_BUDGET: u128 = 24000_000000;
const DEFAULT_EPOCH_DURATION: Duration = Duration::from_secs(60 * 60);
const DEFAULT_MONITOR_RUN_INTERVAL: Duration = Duration::from_secs(10 * 60);
const DEFAULT_MONITOR_MIN_VALIDATE: usize = 10;
const DEFAULT_MONITOR_SAMPLING_RATE: f64 = 0.10;
// #[allow(clippy::inconsistent_digit_grouping)]
// const DEFAULT_MIX_REWARDING_BUDGET: u128 = 1000_000000;
const DEFAULT_REWARDING_DENOM: &str = "unym";
const DEFAULT_BLOCK_SIGNING_EPOCH_DURATION: Duration = Duration::from_secs(60 * 60);
const DEFAULT_TICKETBOOK_ISSUANCE_MIN_VALIDATE: usize = 10;
const DEFAULT_TICKETBOOK_ISSUANCE_SAMPLING_RATE: f64 = 0.10;
const DEFAULT_TICKETBOOK_ISSUANCE_FULL_VERIFICATION_RATIO: f64 = 0.60;
// 'worst' case scenario
pub const TYPICAL_BLOCK_TIME: f32 = 5.;
@@ -79,7 +84,7 @@ pub struct Config {
#[zeroize(skip)]
#[serde(default)]
pub issuance_monitor: IssuanceMonitor,
pub ticketbook_issuance: TicketbookIssuance,
#[zeroize(skip)]
pub nyxd_scraper: NyxdScraper,
@@ -103,7 +108,7 @@ impl Config {
save_path: None,
rewarding: Rewarding::default(),
block_signing: Default::default(),
issuance_monitor: IssuanceMonitor::default(),
ticketbook_issuance: TicketbookIssuance::default(),
nyxd_scraper: NyxdScraper {
websocket_url,
pruning: Default::default(),
@@ -125,9 +130,18 @@ impl Config {
}
}
pub fn verification_config(&self) -> ticketbook_issuance::VerificationConfig {
ticketbook_issuance::VerificationConfig {
min_validate_per_issuer: self.ticketbook_issuance.min_validate_per_issuer,
sampling_rate: self.ticketbook_issuance.sampling_rate,
full_verification_ratio: self.ticketbook_issuance.full_verification_ratio,
}
}
pub fn validate(&self) -> Result<(), NymRewarderError> {
self.rewarding.ratios.validate()?;
self.nyxd_scraper.validate(self.rewarding.epoch_duration)?;
self.nyxd_scraper
.validate(self.block_signing.epoch_duration)?;
Ok(())
}
@@ -171,6 +185,58 @@ impl Config {
pub fn save_to_path<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
save_formatted_config_to_file(self, path)
}
pub fn will_attempt_to_send_rewards(&self) -> bool {
(self.block_signing.enabled && !self.block_signing.monitor_only)
|| (self.ticketbook_issuance.enabled && !self.ticketbook_issuance.monitor_only)
}
/// Returns the total rewarding budget for block signing for given epoch
pub fn block_signing_epoch_budget(&self) -> Coin {
// it doesn't have to be exact to sub micronym precision
let daily_block_signing_budget =
self.rewarding.daily_budget.amount as f64 * self.rewarding.ratios.block_signing;
// how many epochs per day are there?
let epoch_ratio = self.block_signing.epoch_duration.as_secs_f64() / (24. * 60. * 60.);
let epoch_budget = (daily_block_signing_budget * epoch_ratio) as u128;
Coin::new(epoch_budget, &self.rewarding.daily_budget.denom)
}
/// Returns the total rewarding budget for ticketbook issuance for given day
pub fn ticketbook_issuance_daily_budget(&self) -> Coin {
// it doesn't have to be exact to sub micronym precision
let daily_ticketbook_issuance_budget =
self.rewarding.daily_budget.amount as f64 * self.rewarding.ratios.ticketbook_issuance;
let ticketbook_issuance_budget = daily_ticketbook_issuance_budget as u128;
Coin::new(
ticketbook_issuance_budget,
&self.rewarding.daily_budget.denom,
)
}
/// Returns the total rewarding budget for ticketbook issuance for an individual operator for given day
pub fn ticketbook_per_operator_daily_budget(&self) -> Coin {
let ticketbook_total_budget = self.ticketbook_issuance_daily_budget();
let whitelist_size = self.ticketbook_issuance.whitelist.len();
let amount = if self.ticketbook_issuance.whitelist.is_empty() {
Uint128::zero()
} else {
Uint128::new(ticketbook_total_budget.amount)
* Decimal::from_ratio(1u32, whitelist_size as u64)
};
let per_operator = Coin::new(amount.u128(), &ticketbook_total_budget.denom);
let total_budget = &self.rewarding.daily_budget;
info!("ISSUANCE BUDGET: with the total daily budget of {total_budget} ({ticketbook_total_budget} for ticketbook issuance) and with whitelist size of {whitelist_size}, the per operator budget is set to {per_operator}");
per_operator
}
}
#[derive(Debug, Deserialize, Serialize, Zeroize, ZeroizeOnDrop)]
@@ -185,13 +251,11 @@ pub struct Base {
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct Rewarding {
/// Specifies total budget for the epoch
/// Specifies total budget for a 24h period
#[serde_as(as = "DisplayFromStr")]
pub epoch_budget: Coin,
#[serde(with = "humantime_serde")]
pub epoch_duration: Duration,
pub daily_budget: Coin,
pub ratios: RewardingRatios,
}
@@ -199,8 +263,7 @@ pub struct Rewarding {
impl Default for Rewarding {
fn default() -> Self {
Rewarding {
epoch_budget: Coin::new(DEFAULT_MIX_REWARDING_BUDGET, DEFAULT_MIX_REWARDING_DENOM),
epoch_duration: DEFAULT_EPOCH_DURATION,
daily_budget: Coin::new(DEFAULT_DAILY_REWARDING_BUDGET, DEFAULT_REWARDING_DENOM),
ratios: RewardingRatios::default(),
}
}
@@ -211,11 +274,13 @@ pub struct RewardingRatios {
/// The percent of the epoch reward being awarded for block signing.
pub block_signing: f64,
/// The percent of the epoch reward being awarded for credential issuance.
pub credential_issuance: f64,
/// The percent of the epoch reward being awarded for ticketbook issuance.
#[serde(alias = "credential_issuance")]
pub ticketbook_issuance: f64,
/// The percent of the epoch reward being awarded for credential verification.
pub credential_verification: f64,
/// The percent of the epoch reward being awarded for ticketbook verification.
#[serde(alias = "credential_verification")]
pub ticketbook_verification: f64,
// /// The percent of the epoch reward given to Nym.
// pub nym: f64,
}
@@ -224,8 +289,8 @@ impl Default for RewardingRatios {
fn default() -> Self {
RewardingRatios {
block_signing: 0.67,
credential_issuance: 0.33,
credential_verification: 0.0,
ticketbook_issuance: 0.33,
ticketbook_verification: 0.0,
// nym: 0.0,
}
}
@@ -233,7 +298,7 @@ impl Default for RewardingRatios {
impl RewardingRatios {
pub fn validate(&self) -> Result<(), NymRewarderError> {
if self.block_signing + self.credential_verification + self.credential_issuance != 1.0 {
if self.block_signing + self.ticketbook_verification + self.ticketbook_issuance != 1.0 {
return Err(NymRewarderError::InvalidRewardingRatios { ratios: *self });
}
Ok(())
@@ -287,6 +352,10 @@ pub struct BlockSigning {
/// Specifies whether rewards for block signing is enabled.
pub enabled: bool,
/// Duration of block signing epoch.
#[serde(with = "humantime_serde")]
pub epoch_duration: Duration,
/// Specifies whether to only monitor and not send rewards.
pub monitor_only: bool,
@@ -299,6 +368,7 @@ impl Default for BlockSigning {
fn default() -> Self {
BlockSigning {
enabled: true,
epoch_duration: DEFAULT_BLOCK_SIGNING_EPOCH_DURATION,
monitor_only: false,
whitelist: vec![],
}
@@ -306,32 +376,51 @@ impl Default for BlockSigning {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IssuanceMonitor {
/// Specifies whether credential issuance monitoring (and associated rewards) are enabled.
pub struct TicketbookIssuance {
/// Specifies whether rewarding for ticketbook issuance is enabled.
pub enabled: bool,
#[serde(with = "humantime_serde")]
pub run_interval: Duration,
/// Specifies whether to only monitor and not send rewards.
pub monitor_only: bool,
/// Defines the minimum number of credentials the monitor will validate
/// Defines the minimum number of ticketbooks the rewarder will validate
/// regardless of the sampling rate
#[serde(default = "default_ticketbook_issuance_min_validate")]
pub min_validate_per_issuer: usize,
/// The sampling rate of the issued credentials
/// The sampling rate of the issued ticketbooks
#[serde(default = "default_ticketbook_issuance_sampling_rate")]
pub sampling_rate: f64,
/// List of validators that will receive rewards for credential issuance.
/// If not on the list, the validator will be treated as if it hadn't issued a single credential.
/// Ratio of issuers that will undergo full verification as opposed to being let through.
#[serde(default = "default_ticketbook_issuance_full_verification_ratio")]
pub full_verification_ratio: f64,
/// List of validators that will receive rewards for ticketbook issuance.
/// If not on the list, the validator will be treated as if it hadn't issued a single ticketbook.
pub whitelist: Vec<AccountId>,
}
impl Default for IssuanceMonitor {
fn default_ticketbook_issuance_min_validate() -> usize {
TicketbookIssuance::default().min_validate_per_issuer
}
fn default_ticketbook_issuance_sampling_rate() -> f64 {
TicketbookIssuance::default().sampling_rate
}
fn default_ticketbook_issuance_full_verification_ratio() -> f64 {
TicketbookIssuance::default().full_verification_ratio
}
impl Default for TicketbookIssuance {
fn default() -> Self {
IssuanceMonitor {
TicketbookIssuance {
enabled: false,
run_interval: DEFAULT_MONITOR_RUN_INTERVAL,
min_validate_per_issuer: DEFAULT_MONITOR_MIN_VALIDATE,
sampling_rate: DEFAULT_MONITOR_SAMPLING_RATE,
monitor_only: false,
min_validate_per_issuer: DEFAULT_TICKETBOOK_ISSUANCE_MIN_VALIDATE,
sampling_rate: DEFAULT_TICKETBOOK_ISSUANCE_SAMPLING_RATE,
full_verification_ratio: DEFAULT_TICKETBOOK_ISSUANCE_FULL_VERIFICATION_RATIO,
whitelist: vec![],
}
}
+7 -25
View File
@@ -18,20 +18,12 @@ impl ConfigOverride for ConfigOverridableArgs {
config.block_signing.monitor_only = true
}
if self.disable_credential_issuance_rewarding {
config.issuance_monitor.enabled = false
if self.ticketbook_issuance_monitoring_only {
config.ticketbook_issuance.monitor_only = true
}
if let Some(credential_monitor_run_interval) = self.credential_monitor_run_interval {
config.issuance_monitor.run_interval = credential_monitor_run_interval.into()
}
if let Some(credential_monitor_min_validation) = self.credential_monitor_min_validation {
config.issuance_monitor.min_validate_per_issuer = credential_monitor_min_validation
}
if let Some(credential_monitor_sampling_rate) = self.credential_monitor_sampling_rate {
config.issuance_monitor.sampling_rate = credential_monitor_sampling_rate
if self.disable_ticketbook_issuance_rewarding {
config.ticketbook_issuance.enabled = false
}
if let Some(scraper_endpoint) = self.scraper_endpoint {
@@ -43,25 +35,15 @@ impl ConfigOverride for ConfigOverridableArgs {
}
if let Some(epoch_budget) = self.epoch_budget {
config.rewarding.epoch_budget = epoch_budget
}
if let Some(epoch_duration_secs) = self.epoch_duration {
config.rewarding.epoch_duration = epoch_duration_secs.into()
config.rewarding.daily_budget = epoch_budget
}
if let Some(block_signing_reward_ratio) = self.block_signing_reward_ratio {
config.rewarding.ratios.block_signing = block_signing_reward_ratio;
}
if let Some(credential_issuance_reward_ratio) = self.credential_issuance_reward_ratio {
config.rewarding.ratios.credential_issuance = credential_issuance_reward_ratio;
}
if let Some(credential_verification_reward_ratio) =
self.credential_verification_reward_ratio
{
config.rewarding.ratios.credential_verification = credential_verification_reward_ratio;
if let Some(ticketbook_issuance_reward_ratio) = self.ticketbook_issuance_reward_ratio {
config.rewarding.ratios.ticketbook_issuance = ticketbook_issuance_reward_ratio;
}
}
}
+23 -18
View File
@@ -21,26 +21,27 @@ nyxd_scraper = '{{ storage_paths.nyxd_scraper }}'
reward_history = '{{ storage_paths.reward_history }}'
[rewarding]
# Specifies total budget for the epoch
epoch_budget = '{{ rewarding.epoch_budget }}'
epoch_duration = '{{ rewarding.epoch_duration }}'
# Specifies total budget for a 24h period.
daily_budget = '{{ rewarding.daily_budget }}'
[rewarding.ratios]
# The percent of the epoch reward being awarded for block signing.
block_signing = {{ rewarding.ratios.block_signing }}
# The percent of the epoch reward being awarded for credential issuance.
credential_issuance = {{ rewarding.ratios.credential_issuance }}
# The percent of the epoch reward being awarded for ticketbook issuance.
ticketbook_issuance = {{ rewarding.ratios.ticketbook_issuance }}
# The percent of the epoch reward being awarded for credential verification.
credential_verification = {{ rewarding.ratios.credential_verification }}
# The percent of the epoch reward being awarded for ticketbook verification.
ticketbook_verification = {{ rewarding.ratios.ticketbook_verification }}
[block_signing]
# Specifies whether rewarding for block signing is enabled.
enabled = {{ block_signing.enabled }}
# Duration of block signing epoch.
epoch_duration = '{{ block_signing.epoch_duration }}'
# Specifies whether to only monitor and not send rewards.
monitor_only = {{ block_signing.monitor_only }}
@@ -52,21 +53,25 @@ whitelist = [
]
[issuance_monitor]
# Specifies whether credential issuance monitoring (and associated rewards) are enabled.
enabled = {{ issuance_monitor.enabled }}
[ticketbook_issuance]
# Specifies whether rewarding for ticketbook issuance is enabled.
enabled = {{ ticketbook_issuance.enabled }}
run_interval = '{{ issuance_monitor.run_interval }}'
# Specifies whether to only monitor and not send rewards.
monitor_only = {{ ticketbook_issuance.monitor_only }}
# Defines the minimum number of credentials the monitor will validate
# Defines the minimum number of credentials the rewarder will validate
# regardless of the sampling rate
min_validate_per_issuer = {{ issuance_monitor.min_validate_per_issuer }}
min_validate_per_issuer = {{ ticketbook_issuance.min_validate_per_issuer }}
# The sampling rate of the issued credentials
sampling_rate = {{ issuance_monitor.sampling_rate }}
# The sampling rate of the issued ticketbooks
sampling_rate = {{ ticketbook_issuance.sampling_rate }}
# List of validators that will receive rewards for credential issuance.
# If not on the list, the validator will be treated as if it hadn't issued a single credential.
# Ratio of issuers that will undergo full verification as opposed to being let through.
full_verification_ratio = {{ ticketbook_issuance.full_verification_ratio }}
# List of validators that will receive rewards for ticketbook issuance.
# If not on the list, the validator will be treated as if it hadn't issued a single ticketbook.
whitelist = [
# needs to be manually populated; expects n1... addresses
]
+10 -4
View File
@@ -48,6 +48,9 @@ pub enum NymRewarderError {
#[error("there already exists a config file at: {}. if you want to overwrite its content, use --force flag", path.display())]
ExistingConfig { path: PathBuf },
#[error("neither block signing nor ticketbook issuance is enabled")]
RewardingModulesDisabled,
// TODO: I think this one should get split into more, explicit, variants
#[error(transparent)]
NyxdFailure(#[from] NyxdError),
@@ -159,7 +162,7 @@ pub enum NymRewarderError {
on_chain: String,
},
#[error("the current rewarder balance is insufficient to start the process. The epoch budget is: {} while we currently have {}. (the minimum is set to {})", .0.epoch_budget, .0.balance, .0.minimum)]
#[error("the current rewarder balance is insufficient to start the process. The daily budget is: {} while we currently have {}. (the minimum is set to {})", .0.daily_budget, .0.balance, .0.minimum)]
InsufficientRewarderBalance(Box<InsufficientBalance>),
#[error("the scraper websocket endpoint hasn't been provided")]
@@ -168,12 +171,15 @@ pub enum NymRewarderError {
#[error("block signing rewarding is enabled, but the validator whitelist is empty")]
EmptyBlockSigningWhitelist,
#[error("credential issuance rewarding is enabled, but the validator whitelist is empty")]
EmptyCredentialIssuanceWhitelist,
#[error("ticketbook issuance rewarding is enabled, but the validator whitelist is empty")]
EmptyTicketbookIssuanceWhitelist,
#[error("there were no validators to reward in this epoch")]
NoValidatorsToReward,
#[error("there were no ticketbook signers to reward in this epoch")]
NoSignersToReward,
#[error("the current pruning strategy is set to 'everything' - we won't have any block data for rewarding")]
EverythingPruningStrategy,
@@ -186,7 +192,7 @@ pub enum NymRewarderError {
#[derive(Debug)]
pub struct InsufficientBalance {
pub epoch_budget: Coin,
pub daily_budget: Coin,
pub balance: Coin,
pub minimum: Coin,
}
@@ -62,6 +62,8 @@ impl EpochSigning {
loop {
let mut res = self.nyxd_client.validators(page_request).await?;
let num_results = res.validators.len();
response.append(&mut res.validators);
let Some(pagination) = res.pagination else {
@@ -72,6 +74,10 @@ impl EpochSigning {
break;
}
if num_results == 0 {
break;
}
page_request = Some(PageRequest {
key: pagination.next_key,
offset: 0,
@@ -138,18 +138,19 @@ impl EpochSigningResults {
}
pub fn rewarding_amounts(&self, budget: &Coin) -> Vec<(AccountId, Vec<Coin>)> {
self.validators
.iter()
.inspect(|v| {
info!(
"validator {} will receive {} at address {} for block signing work (whitelisted: {})",
let mut amounts = Vec::with_capacity(self.validators.len());
for v in &self.validators {
let amount = v.reward_amount(budget);
info!(
"validator {} will receive {amount} at address {} for block signing work (whitelisted: {})",
v.moniker(),
v.reward_amount(budget),
v.operator_account,
v.whitelisted
);
})
.map(|v| (v.operator_account.clone(), vec![v.reward_amount(budget)]))
.collect()
amounts.push((v.operator_account.clone(), vec![amount]))
}
amounts
}
}
@@ -1,69 +0,0 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::config;
use crate::error::NymRewarderError;
use crate::rewarder::credential_issuance::monitor::CredentialIssuanceMonitor;
use crate::rewarder::credential_issuance::types::{CredentialIssuanceResults, MonitoringResults};
use crate::rewarder::epoch::Epoch;
use crate::rewarder::nyxd_client::NyxdClient;
use crate::rewarder::storage::RewarderStorage;
use nym_task::TaskClient;
use nym_validator_client::nyxd::AccountId;
use tracing::info;
mod monitor;
pub mod types;
pub struct CredentialIssuance {
monitoring_results: MonitoringResults,
storage: RewarderStorage,
}
impl CredentialIssuance {
pub(crate) async fn new(
epoch: Epoch,
storage: RewarderStorage,
nyxd_client: &NyxdClient,
whitelist: &[AccountId],
) -> Result<Self, NymRewarderError> {
Ok(CredentialIssuance {
monitoring_results: MonitoringResults::new_initial(epoch, nyxd_client, whitelist)
.await?,
storage,
})
}
pub(crate) fn start_monitor(
&self,
monitor_config: config::IssuanceMonitor,
nyxd_client: NyxdClient,
task_client: TaskClient,
) {
let monitoring_results = self.monitoring_results.clone();
let mut monitor = CredentialIssuanceMonitor::new(
monitor_config,
nyxd_client,
self.storage.clone(),
monitoring_results,
);
tokio::spawn(async move { monitor.run(task_client).await });
}
pub(crate) async fn get_issued_credentials_results(
&self,
current_epoch: Epoch,
) -> Result<CredentialIssuanceResults, NymRewarderError> {
info!(
"looking up credential issuers for epoch {} ({} - {})",
current_epoch.id,
current_epoch.start_rfc3339(),
current_epoch.end_rfc3339()
);
let raw_results = self.monitoring_results.finish_epoch().await;
Ok(raw_results.into())
}
}
@@ -1,382 +0,0 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::config;
use crate::error::NymRewarderError;
use crate::rewarder::credential_issuance::types::{
CredentialIssuer, MonitoringResults, RawOperatorResult,
};
use crate::rewarder::helpers::api_client;
use crate::rewarder::nyxd_client::NyxdClient;
use crate::rewarder::storage::RewarderStorage;
use bip39::rand::prelude::SliceRandom;
use bip39::rand::thread_rng;
use nym_coconut_dkg_common::types::EpochId;
use nym_compact_ecash::scheme::withdrawal::verify_partial_blind_signature;
use nym_compact_ecash::{date_scalar, type_scalar, Bytable, G1Projective, VerificationKeyAuth};
use nym_credentials::ecash::utils::EcashTime;
use nym_crypto::asymmetric::ed25519;
use nym_task::TaskClient;
use nym_validator_client::nym_api::{IssuedTicketbook, IssuedTicketbookBody, NymApiClientExt};
use std::cmp::max;
use tokio::time::interval;
use tracing::{debug, error, info, instrument, trace};
pub struct CredentialIssuanceMonitor {
nyxd_client: NyxdClient,
monitoring_results: MonitoringResults,
config: config::IssuanceMonitor,
storage: RewarderStorage,
}
impl CredentialIssuanceMonitor {
pub fn new(
config: config::IssuanceMonitor,
nyxd_client: NyxdClient,
storage: RewarderStorage,
monitoring_results: MonitoringResults,
) -> CredentialIssuanceMonitor {
CredentialIssuanceMonitor {
config,
storage,
nyxd_client,
monitoring_results,
}
}
fn validate_credential_signature(
&mut self,
issued_credential: &IssuedTicketbookBody,
identity_key: &ed25519::PublicKey,
) -> Result<(), NymRewarderError> {
let plaintext = issued_credential.credential.signable_plaintext();
if identity_key
.verify(plaintext, &issued_credential.signature)
.is_err()
{
Err(NymRewarderError::SignatureVerificationFailure {
credential_id: issued_credential.credential.id,
})
} else {
Ok(())
}
}
async fn check_deposit_reuse(
&mut self,
issuer_identity: &str,
credential_info: &IssuedTicketbookBody,
) -> Result<bool, NymRewarderError> {
let credential_id = credential_info.credential.id;
let deposit_id = credential_info.credential.deposit_id;
let prior_id = self
.storage
.get_deposit_credential_id(issuer_identity.to_string(), deposit_id)
.await?;
match prior_id {
None => Ok(false),
Some(prior) => {
if prior == credential_id {
debug!("we have already verified this credential before");
Ok(true)
} else {
error!("double signing detected!! used deposit {deposit_id} for credentials {prior} and {credential_id}!!");
self.storage
.insert_double_signing_evidence(
issuer_identity.to_string(),
prior,
credential_info,
)
.await?;
Err(NymRewarderError::DuplicateDepositId {
deposit_id,
first: prior,
other: credential_id,
})
}
}
}
}
async fn validate_deposit(
&mut self,
issued_credential: &IssuedTicketbookBody,
) -> Result<(), NymRewarderError> {
// check if this deposit even exists
let deposit_id = issued_credential.credential.deposit_id;
//not using value anymore, but it should still be there
let _ = self.nyxd_client.get_deposit_details(deposit_id).await?;
trace!("deposit exists");
Ok(())
}
fn verify_credential(
&self,
vk: &VerificationKeyAuth,
credential: &IssuedTicketbook,
) -> Result<(), NymRewarderError> {
verify_credential(vk, credential)
}
#[instrument(skip_all, fields(credential_id = %issued_credential.credential.id, deposit_id = %issued_credential.credential.deposit_id))]
async fn validate_issued_credential(
&mut self,
issuer: &CredentialIssuer,
issued_credential: &IssuedTicketbookBody,
) -> Result<(), NymRewarderError> {
// check if the issuer has actually signed that issued credential information
self.validate_credential_signature(issued_credential, &issuer.public_key)?;
let encoded_key = issuer.public_key.to_base58_string();
// make sure the issuer is not using the same deposit for multiple credentials
let already_checked = self
.check_deposit_reuse(&encoded_key, issued_credential)
.await?;
if already_checked {
return Ok(());
}
// check the correctness of the deposit itself
self.validate_deposit(issued_credential).await?;
// insert validated deposit info into the storage
self.storage
.insert_validated_deposit(encoded_key, issued_credential)
.await?;
// check if the partial credential correctly verifies
self.verify_credential(&issuer.verification_key, &issued_credential.credential)?;
Ok(())
}
fn sample_credential_ids(&self, first_id: i64, total_issued: i64) -> Vec<i64> {
let credential_range: Vec<_> = (first_id..first_id + total_issued).collect();
let issued = credential_range.len();
let sampled = if issued <= self.config.min_validate_per_issuer {
credential_range
} else {
let mut rng = thread_rng();
let sample_size = max(
self.config.min_validate_per_issuer,
(issued as f64 * self.config.sampling_rate) as usize,
);
credential_range
.choose_multiple(&mut rng, sample_size)
.copied()
.collect::<Vec<_>>()
};
sampled
}
#[instrument(skip(self, issuer, epoch_id), fields(dkg_epoch = epoch_id, issuer = %issuer.operator_account, url = issuer.api_runner), err(Display))]
async fn check_issuer(
&mut self,
epoch_id: EpochId,
issuer: &CredentialIssuer,
) -> Result<RawOperatorResult, NymRewarderError> {
info!("checking the issuer's credentials...");
debug!("checking the issuer's credentials...");
let api_client = api_client(issuer)?;
let epoch_credentials = api_client.epoch_credentials(epoch_id).await?;
let whitelisted = self.config.whitelist.contains(&issuer.operator_account);
let Some(first_id) = epoch_credentials.first_epoch_credential_id else {
// no point in doing anything more - if they haven't issued anything, there's nothing to verify
debug!("no credentials issued this epoch",);
return Ok(RawOperatorResult::new_empty(
issuer.operator_account.clone(),
issuer.api_runner.clone(),
whitelisted,
));
};
trace!("issued credentials: {epoch_credentials:?}");
let sampled = self.sample_credential_ids(first_id, epoch_credentials.total_issued as i64);
let request_size = sampled.len();
trace!("sampled credentials to validate: {sampled:?}");
let credentials = api_client.issued_credentials(sampled.clone()).await?;
if credentials.credentials.len() != request_size {
error!("received an incomplete credential request! the issuer **MIGHT** be cheating!! but we're lacking sufficient signatures to be certain");
return Err(NymRewarderError::IncompleteRequest {
runner_account: issuer.operator_account.clone(),
requested: request_size,
received: credentials.credentials.len(),
});
}
for (id, credential) in credentials.credentials {
trace!("checking credential {id}...");
if let Err(err) = self.validate_issued_credential(issuer, &credential).await {
error!(
"failed to validate credential {id} from {} ({})!!: {err}",
issuer.public_key, issuer.operator_account
);
self.storage
.insert_issuance_foul_play_evidence(issuer, &credential, err.to_string())
.await?;
return Err(err);
}
}
Ok(RawOperatorResult {
operator_account: issuer.operator_account.clone(),
api_runner: issuer.api_runner.clone(),
whitelisted,
issued_credentials: epoch_credentials.total_issued,
validated_credentials: sampled,
})
}
async fn check_issuers(&mut self) -> Result<(), NymRewarderError> {
info!("checking credential issuers");
let epoch = self.nyxd_client.dkg_epoch().await?;
let issuers = self
.nyxd_client
.get_credential_issuers(epoch.epoch_id)
.await?;
let mut results = Vec::with_capacity(issuers.len());
for issuer in issuers {
// we could parallelize it, but we're running the test so infrequently (relatively speaking)
// that doing it sequentially is fine
match self.check_issuer(epoch.epoch_id, &issuer).await {
Ok(res) => results.push(res),
Err(err) => {
let address = &issuer.operator_account;
error!("failed to check credential issuance of {address}: {err}");
self.storage
.insert_issuance_validation_failure_info(&issuer, err.to_string())
.await?;
}
}
}
self.monitoring_results
.append_run_results(epoch.epoch_id as u32, results)
.await;
Ok(())
}
pub async fn run(&mut self, mut task_client: TaskClient) {
info!("starting");
let mut run_interval = interval(self.config.run_interval);
while !task_client.is_shutdown() {
tokio::select! {
biased;
_ = task_client.recv() => {
info!("received shutdown");
break
}
_ = run_interval.tick() => {
if let Err(err) = self.check_issuers().await {
error!("failed to perform credential issuance check: {err}")
}
}
}
}
}
}
fn verify_credential(
vk: &VerificationKeyAuth,
credential: &IssuedTicketbook,
) -> Result<(), NymRewarderError> {
let public_attributes = [
date_scalar(credential.expiration_date.ecash_unix_timestamp()),
type_scalar(credential.ticketbook_type.encode()),
];
#[allow(clippy::map_identity)]
let attributes_refs = public_attributes.iter().collect::<Vec<_>>();
let mut public_attribute_commitments =
Vec::with_capacity(credential.encoded_private_attributes_commitments.len());
for raw_cm in &credential.encoded_private_attributes_commitments {
match G1Projective::try_from_byte_slice(raw_cm) {
Ok(cm) => public_attribute_commitments.push(cm),
Err(source) => return Err(NymRewarderError::MalformedCredentialCommitment { source }),
}
}
// actually do verify the credential now
if !verify_partial_blind_signature(
&public_attribute_commitments,
&attributes_refs,
&credential.blinded_partial_credential,
vk,
) {
return Err(NymRewarderError::BlindVerificationFailure);
}
trace!("credential correctly verifies");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nym_compact_ecash::ttp_keygen;
use nym_credentials::IssuanceTicketBook;
use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::{ed25519, identity};
use rand_chacha::rand_core::{RngCore, SeedableRng};
#[test]
fn verify_issued_partial_credential() -> anyhow::Result<()> {
let dummy_seed = [42u8; 32];
let mut rng = rand_chacha::ChaCha20Rng::from_seed(dummy_seed);
let ecash_keypair = ttp_keygen(1, 1)?.pop().unwrap();
let deposit_id = rng.next_u32();
// create dummy ticketbook
let identity_keypair = ed25519::KeyPair::new(&mut rng);
let id_priv =
identity::PrivateKey::from_bytes(&identity_keypair.private_key().to_bytes()).unwrap();
let identifier = [44u8; 32];
let issuance =
IssuanceTicketBook::new(deposit_id, identifier, id_priv, TicketType::V1MixnetEntry);
let signing_data = issuance.prepare_for_signing();
let request = issuance.create_blind_sign_request_body(&signing_data);
let partial = nym_compact_ecash::scheme::withdrawal::issue(
ecash_keypair.secret_key(),
request.ecash_pubkey.clone(),
&request.inner_sign_request,
request.expiration_date.ecash_unix_timestamp(),
request.ticketbook_type.encode(),
)?;
let commitments = request.encode_commitments();
let issued = IssuedTicketbook {
id: 0,
epoch_id: 0,
deposit_id,
blinded_partial_credential: partial,
encoded_private_attributes_commitments: commitments,
expiration_date: issuance.expiration_date(),
ticketbook_type: issuance.ticketbook_type(),
};
assert!(verify_credential(ecash_keypair.verification_key_ref(), &issued).is_ok());
Ok(())
}
}
@@ -1,352 +0,0 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::NymRewarderError;
use crate::rewarder::epoch::Epoch;
use crate::rewarder::helpers::api_client;
use crate::rewarder::nyxd_client::NyxdClient;
use cosmwasm_std::{Addr, Decimal, Uint128};
use nym_compact_ecash::VerificationKeyAuth;
use nym_crypto::asymmetric::ed25519;
use nym_validator_client::nym_api::NymApiClientExt;
use nym_validator_client::nyxd::{AccountId, Coin};
use std::collections::{HashMap, HashSet};
use std::mem;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{info, warn};
#[derive(Clone)]
pub struct MonitoringResults {
inner: Arc<Mutex<MonitoringResultsInner>>,
}
impl MonitoringResults {
pub(crate) async fn new_initial(
initial_epoch: Epoch,
nyxd_client: &NyxdClient,
whitelist: &[AccountId],
) -> Result<Self, NymRewarderError> {
let epoch = nyxd_client.dkg_epoch().await?;
let issuers = nyxd_client.get_credential_issuers(epoch.epoch_id).await?;
let mut initial_results = HashMap::new();
for issuer in issuers {
let issuer_account = issuer.operator_account.to_string();
let mut raw_issuer = RawOperatorIssuing {
api_runner: issuer.api_runner.clone(),
runner_account: issuer.operator_account.clone(),
whitelisted: whitelist.contains(&issuer.operator_account),
per_epoch: Default::default(),
};
let Ok(api_client) = api_client(&issuer) else {
warn!("failed to create api client for {issuer_account}");
initial_results.insert(issuer_account, raw_issuer);
continue;
};
let Ok(epoch_credentials) = api_client.epoch_credentials(epoch.epoch_id).await else {
warn!("failed to get initial epoch credentials from {issuer_account}");
initial_results.insert(issuer_account, raw_issuer);
continue;
};
raw_issuer.per_epoch.insert(
epoch.epoch_id as u32,
IssuedEpochCredentials {
issued_since_monitor_started: 0,
validated_ids: Default::default(),
last_total_issued: epoch_credentials.total_issued,
},
);
initial_results.insert(issuer_account, raw_issuer);
}
Ok(MonitoringResults {
inner: Arc::new(Mutex::new(MonitoringResultsInner::new(
initial_epoch,
epoch.epoch_id as u32,
initial_results,
))),
})
}
pub(crate) async fn append_run_results(&self, dkg_epoch: u32, results: Vec<RawOperatorResult>) {
let mut guard = self.inner.lock().await;
for result in results {
let Some(entry) = guard.operators.get_mut(result.operator_account.as_ref()) else {
// if this is the first time we're seeing this data, make sure to set the current results as the starting point
guard.operators.insert(
result.operator_account.to_string(),
RawOperatorIssuing::new_empty(dkg_epoch, result),
);
continue;
};
let Some(epoch_data) = entry.per_epoch.get_mut(&dkg_epoch) else {
// similar situation to the above, if we don't have the proper initial data, set it to what we got now
entry
.per_epoch
.insert(dkg_epoch, IssuedEpochCredentials::new_initial(&result));
continue;
};
let issued = result.issued_credentials - epoch_data.last_total_issued;
epoch_data.last_total_issued = result.issued_credentials;
for validated in result.validated_credentials {
epoch_data.validated_ids.insert(validated);
}
epoch_data.issued_since_monitor_started += issued;
}
}
pub(crate) async fn finish_epoch(&self) -> MonitoringResultsInner {
let mut guard = self.inner.lock().await;
let next_epoch = guard.epoch.next();
// safety: whenever the monitor results are constructed, we always have at least a single value there
#[allow(clippy::unwrap_used)]
let latest_dkg_epoch = guard.dkg_epochs.pop().unwrap();
// only keep results from the latest dkg epoch (but after resetting the counters)
let mut to_keep = HashMap::new();
for (runner, result) in &guard.operators {
let mut kept_epoch = HashMap::new();
if let Some(data) = result.per_epoch.get(&latest_dkg_epoch) {
kept_epoch.insert(
latest_dkg_epoch,
IssuedEpochCredentials {
issued_since_monitor_started: 0,
validated_ids: Default::default(),
last_total_issued: data.last_total_issued,
},
);
}
to_keep.insert(
runner.clone(),
RawOperatorIssuing {
api_runner: result.api_runner.clone(),
runner_account: result.runner_account.clone(),
whitelisted: result.whitelisted,
per_epoch: kept_epoch,
},
);
}
let next_results = MonitoringResultsInner {
epoch: next_epoch,
dkg_epochs: vec![latest_dkg_epoch],
operators: to_keep,
};
mem::replace(&mut guard, next_results)
}
}
pub(crate) struct MonitoringResultsInner {
pub(crate) epoch: Epoch,
pub(crate) dkg_epochs: Vec<u32>,
// map from operator's account to their results
pub(crate) operators: HashMap<String, RawOperatorIssuing>,
}
impl From<MonitoringResultsInner> for CredentialIssuanceResults {
fn from(value: MonitoringResultsInner) -> Self {
let mut total_issued = 0;
for operator in value.operators.values() {
// if this validator is NOT whitelisted, do not increase the total issued credentials
if operator.whitelisted {
let operator_issued: u32 = operator
.per_epoch
.values()
.map(|e| e.issued_since_monitor_started)
.sum();
total_issued += operator_issued
}
}
CredentialIssuanceResults {
total_issued_partial_credentials: total_issued,
dkg_epochs: value.dkg_epochs,
api_runners: value
.operators
.into_values()
.map(|runner| {
let issued_ratio = if total_issued == 0 || !runner.whitelisted {
Decimal::zero()
} else {
Decimal::from_ratio(runner.issued_credentials(), total_issued)
};
OperatorIssuing {
issued_ratio,
issued_credentials: runner.issued_credentials(),
validated_credentials: runner.validated_credentials(),
api_runner: runner.api_runner,
whitelisted: runner.whitelisted,
runner_account: runner.runner_account,
}
})
.collect(),
}
}
}
impl MonitoringResultsInner {
fn new(
epoch: Epoch,
initial_dkg_epoch: u32,
initial_operators: HashMap<String, RawOperatorIssuing>,
) -> MonitoringResultsInner {
MonitoringResultsInner {
epoch,
dkg_epochs: vec![initial_dkg_epoch],
operators: initial_operators,
}
}
}
pub(crate) struct RawOperatorResult {
pub(crate) operator_account: AccountId,
pub(crate) api_runner: String,
pub(crate) whitelisted: bool,
// how many credentials the operator claims to have issued in **TOTAL** in this **DKG** epoch
pub(crate) issued_credentials: u32,
pub(crate) validated_credentials: Vec<i64>,
}
impl RawOperatorResult {
pub(crate) fn new_empty(
operator_account: AccountId,
api_runner: String,
whitelisted: bool,
) -> RawOperatorResult {
RawOperatorResult {
operator_account,
api_runner,
whitelisted,
issued_credentials: 0,
validated_credentials: Default::default(),
}
}
}
pub struct RawOperatorIssuing {
pub api_runner: String,
pub runner_account: AccountId,
pub whitelisted: bool,
pub per_epoch: HashMap<u32, IssuedEpochCredentials>,
}
impl RawOperatorIssuing {
pub fn new_empty(epoch: u32, raw_result: RawOperatorResult) -> RawOperatorIssuing {
let mut per_epoch = HashMap::new();
per_epoch.insert(epoch, IssuedEpochCredentials::new_initial(&raw_result));
RawOperatorIssuing {
api_runner: raw_result.api_runner,
runner_account: raw_result.operator_account,
whitelisted: raw_result.whitelisted,
per_epoch,
}
}
pub fn issued_credentials(&self) -> u32 {
self.per_epoch
.values()
.map(|e| e.issued_since_monitor_started)
.sum()
}
pub fn validated_credentials(&self) -> u32 {
let ids: usize = self.per_epoch.values().map(|e| e.validated_ids.len()).sum();
ids as u32
}
}
pub struct IssuedEpochCredentials {
pub issued_since_monitor_started: u32,
pub validated_ids: HashSet<i64>,
pub last_total_issued: u32,
}
impl IssuedEpochCredentials {
pub fn new_initial(raw: &RawOperatorResult) -> Self {
IssuedEpochCredentials {
issued_since_monitor_started: 0,
validated_ids: raw.validated_credentials.iter().copied().collect(),
last_total_issued: raw.issued_credentials,
}
}
}
pub struct OperatorIssuing {
pub api_runner: String,
pub whitelisted: bool,
pub runner_account: AccountId,
pub issued_ratio: Decimal,
pub issued_credentials: u32,
pub validated_credentials: u32,
}
impl OperatorIssuing {
pub fn reward_amount(&self, issuance_budget: &Coin) -> Coin {
if !self.whitelisted {
return Coin::new(0, &issuance_budget.denom);
}
let amount = Uint128::new(issuance_budget.amount) * self.issued_ratio;
Coin::new(amount.u128(), &issuance_budget.denom)
}
}
pub struct CredentialIssuanceResults {
pub total_issued_partial_credentials: u32,
pub dkg_epochs: Vec<u32>,
pub api_runners: Vec<OperatorIssuing>,
}
impl CredentialIssuanceResults {
pub fn rewarding_amounts(&self, budget: &Coin) -> Vec<(AccountId, Vec<Coin>)> {
self.api_runners
.iter()
.inspect(|a| {
info!(
"operator {} will receive {} at address {} for credential issuance work (whitelisted: {})",
a.api_runner,
a.reward_amount(budget),
a.runner_account,
a.whitelisted
);
})
.map(|v| (v.runner_account.clone(), vec![v.reward_amount(budget)]))
.collect()
}
}
#[derive(Debug)]
pub struct CredentialIssuer {
pub public_key: ed25519::PublicKey,
pub operator_account: AccountId,
pub api_runner: String,
pub verification_key: VerificationKeyAuth,
}
// safety: we're converting between different wrappers for bech32 addresses
// and we trust (reasonably so), the values coming out of registered dealers in the DKG contract
pub(crate) fn addr_to_account_id(addr: Addr) -> AccountId {
#[allow(clippy::unwrap_used)]
addr.as_str().parse().unwrap()
}
@@ -8,6 +8,8 @@ use std::ops::Add;
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use tokio::time::{interval_at, Instant, Interval};
use tracing::info;
const HOUR: Duration = Duration::from_secs(60 * 60);
@@ -45,6 +47,19 @@ impl Epoch {
(self.end_time - now).try_into().unwrap_or_default()
}
pub fn epoch_ticker(&self, period: Duration) -> Interval {
let until_end = self.until_end();
info!(
"if enabled, the next block signing epoch (id: {}) will finish on {} ({} secs remaining)",
self.id,
self.end_rfc3339(),
until_end.as_secs()
);
interval_at(Instant::now().add(until_end), period)
}
pub fn next(&self) -> Self {
let duration = self.end_time - self.start_time;
Epoch {
@@ -2,8 +2,6 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::NymRewarderError;
use crate::rewarder::credential_issuance::types::CredentialIssuer;
use nym_validator_client::nym_api;
use nym_validator_client::nyxd::{AccountId, PublicKey};
use nyxd_scraper::constants::{BECH32_CONSENSUS_ADDRESS_PREFIX, BECH32_PREFIX};
use sha2::{Digest, Sha256};
@@ -33,18 +31,3 @@ pub(crate) fn operator_account_to_owner_account(
}
})
}
pub(crate) fn api_client(issuer: &CredentialIssuer) -> Result<nym_api::Client, NymRewarderError> {
let url = match issuer.api_runner.parse() {
Ok(url) => url,
Err(source) => {
return Err(NymRewarderError::MalformedApiUrl {
raw: issuer.api_runner.clone(),
runner_account: issuer.operator_account.clone(),
source,
})
}
};
Ok(nym_api::Client::new(url, None))
}
+313 -168
View File
@@ -5,72 +5,120 @@ use crate::config::Config;
use crate::error::{InsufficientBalance, NymRewarderError};
use crate::rewarder::block_signing::types::EpochSigningResults;
use crate::rewarder::block_signing::EpochSigning;
use crate::rewarder::credential_issuance::types::CredentialIssuanceResults;
use crate::rewarder::credential_issuance::CredentialIssuance;
use crate::rewarder::nyxd_client::NyxdClient;
use crate::rewarder::storage::RewarderStorage;
use crate::rewarder::ticketbook_issuance::helpers::end_of_day_ticker;
use crate::rewarder::ticketbook_issuance::types::TicketbookIssuanceResults;
use crate::rewarder::ticketbook_issuance::TicketbookIssuance;
use futures::future::{FusedFuture, OptionFuture};
use futures::FutureExt;
use nym_ecash_time::{ecash_today, ecash_today_date, EcashTime};
use nym_task::TaskManager;
use nym_validator_client::nyxd::{AccountId, Coin, Hash};
use nyxd_scraper::NyxdScraper;
use std::ops::Add;
use time::Date;
use tokio::pin;
use tokio::time::{interval_at, Instant};
use tracing::{error, info, instrument, warn};
mod block_signing;
mod credential_issuance;
mod epoch;
mod helpers;
mod nyxd_client;
mod storage;
mod tasks;
pub(crate) mod ticketbook_issuance;
pub(crate) use crate::rewarder::epoch::Epoch;
pub struct RewardingResult {
pub total_spent: Coin,
pub rewarding_tx: Hash,
pub rewarding_tx: Option<Hash>,
}
pub struct EpochRewards {
pub struct ExtractedRewardingResults {
pub rewarding_tx: Option<String>,
pub total_spent: Coin,
pub rewarding_err: Option<String>,
pub monitor_only: bool,
}
pub fn extract_rewarding_results(
results: Result<RewardingResult, NymRewarderError>,
rewarding_denom: &str,
) -> ExtractedRewardingResults {
match results {
Ok(res) => match res.rewarding_tx {
None => ExtractedRewardingResults {
rewarding_tx: None,
total_spent: Coin::new(0, rewarding_denom),
rewarding_err: None,
monitor_only: true,
},
Some(hash) => ExtractedRewardingResults {
rewarding_tx: Some(hash.to_string()),
total_spent: res.total_spent,
rewarding_err: None,
monitor_only: false,
},
},
Err(err) => ExtractedRewardingResults {
rewarding_tx: Some(err.to_string()),
total_spent: Coin::new(0, rewarding_denom),
rewarding_err: None,
monitor_only: false,
},
}
}
pub struct BlockSigningDetails {
pub epoch: Epoch,
pub signing: Result<Option<EpochSigningResults>, NymRewarderError>,
pub credentials: Result<Option<CredentialIssuanceResults>, NymRewarderError>,
pub total_budget: Coin,
pub signing_budget: Coin,
pub credentials_budget: Coin,
pub results: Option<Result<EpochSigningResults, NymRewarderError>>,
pub budget: Coin,
}
impl EpochRewards {
pub fn amounts(&self) -> Result<Vec<(AccountId, Vec<Coin>)>, NymRewarderError> {
impl BlockSigningDetails {
pub fn rewarding_amounts(&self) -> Result<Vec<(AccountId, Vec<Coin>)>, NymRewarderError> {
let mut amounts = Vec::new();
match &self.signing {
Ok(Some(signing)) => {
for (account, signing_amount) in signing.rewarding_amounts(&self.signing_budget) {
match &self.results {
Some(Ok(signing)) => {
for (account, signing_amount) in signing.rewarding_amounts(&self.budget) {
if signing_amount[0].amount != 0 {
amounts.push((account, signing_amount))
}
}
}
Err(err) => error!("failed to determine rewards for block signing: {err}"),
Some(Err(err)) => error!("failed to determine rewards for block signing: {err}"),
_ => (),
}
match &self.credentials {
Ok(Some(credentials)) => {
for (account, credential_amount) in
credentials.rewarding_amounts(&self.credentials_budget)
Ok(amounts)
}
}
pub struct TicketbookIssuanceDetails {
pub expiration_date: Date,
pub results: Option<Result<TicketbookIssuanceResults, NymRewarderError>>,
pub total_budget: Coin,
pub whitelist_size: usize,
pub per_operator_budget: Coin,
}
impl TicketbookIssuanceDetails {
pub fn rewarding_amounts(&self) -> Result<Vec<(AccountId, Vec<Coin>)>, NymRewarderError> {
let mut amounts = Vec::new();
match &self.results {
Some(Ok(issuance)) => {
for (account, issuance_amount) in
issuance.rewarding_amounts(&self.per_operator_budget)
{
if credential_amount[0].amount != 0 {
amounts.push((account, credential_amount))
if issuance_amount[0].amount != 0 {
amounts.push((account, issuance_amount))
}
}
}
Err(err) => error!("failed to determine rewards for credential issuance: {err}"),
Some(Err(err)) => error!("failed to determine rewards for ticketbook issuance: {err}"),
_ => (),
}
@@ -85,22 +133,39 @@ pub fn total_spent(amounts: &[(AccountId, Vec<Coin>)], denom: &str) -> Coin {
pub struct Rewarder {
config: Config,
current_epoch: Epoch,
current_block_signing_epoch: Epoch,
last_processed_issuance_date: Date,
storage: RewarderStorage,
nyxd_client: NyxdClient,
epoch_signing: Option<EpochSigning>,
credential_issuance: Option<CredentialIssuance>,
ticketbook_issuance: Option<TicketbookIssuance>,
}
impl Rewarder {
pub async fn new(config: Config) -> Result<Self, NymRewarderError> {
// no point in starting up if both modules are disabled
if !config.block_signing.enabled && !config.ticketbook_issuance.enabled {
return Err(NymRewarderError::RewardingModulesDisabled);
}
let nyxd_client = NyxdClient::new(&config)?;
let storage = RewarderStorage::init(&config.storage_paths.reward_history).await?;
let current_epoch = if let Some(last_epoch) = storage.load_last_rewarding_epoch().await? {
last_epoch.next()
let current_block_signing_epoch =
if let Some(last_epoch) = storage.load_last_block_signing_rewarding_epoch().await? {
last_epoch.next()
} else {
Epoch::first(config.block_signing.epoch_duration)?
};
let last_processed_issuance_date = if let Some(last_processed) = storage
.load_last_ticketbook_issuance_expiration_date()
.await?
{
last_processed
} else {
Epoch::first(config.rewarding.epoch_duration)?
#[allow(clippy::unwrap_used)]
ecash_today_date().previous_day().unwrap()
};
let epoch_signing = if config.block_signing.enabled {
@@ -124,35 +189,35 @@ impl Rewarder {
None
};
let credential_issuance = if config.issuance_monitor.enabled {
let whitelist = &config.issuance_monitor.whitelist;
let credential_issuance = if config.ticketbook_issuance.enabled {
let whitelist = &config.ticketbook_issuance.whitelist;
if whitelist.is_empty() {
return Err(NymRewarderError::EmptyCredentialIssuanceWhitelist);
return Err(NymRewarderError::EmptyTicketbookIssuanceWhitelist);
}
Some(
CredentialIssuance::new(current_epoch, storage.clone(), &nyxd_client, whitelist)
.await?,
)
Some(TicketbookIssuance::new(
config.verification_config(),
storage.clone(),
&nyxd_client,
whitelist,
))
} else {
None
};
if config.issuance_monitor.enabled
|| (config.block_signing.enabled && !config.block_signing.monitor_only)
{
if config.will_attempt_to_send_rewards() {
let balance = nyxd_client
.balance(&config.rewarding.epoch_budget.denom)
.balance(&config.rewarding.daily_budget.denom)
.await?;
let minimum = Coin::new(
config.rewarding.epoch_budget.amount * 100,
&config.rewarding.epoch_budget.denom,
config.rewarding.daily_budget.amount * 7,
&config.rewarding.daily_budget.denom,
);
if balance.amount < minimum.amount {
return Err(NymRewarderError::InsufficientRewarderBalance(Box::new(
InsufficientBalance {
epoch_budget: config.rewarding.epoch_budget.clone(),
daily_budget: config.rewarding.daily_budget.clone(),
balance,
minimum,
},
@@ -161,82 +226,43 @@ impl Rewarder {
}
Ok(Rewarder {
current_epoch,
credential_issuance,
ticketbook_issuance: credential_issuance,
epoch_signing,
nyxd_client,
storage,
config,
current_block_signing_epoch,
last_processed_issuance_date,
})
}
#[instrument(skip(self))]
async fn calculate_block_signing_rewards(
&mut self,
) -> Result<Option<EpochSigningResults>, NymRewarderError> {
async fn block_signing_details(&mut self) -> BlockSigningDetails {
info!("calculating reward shares");
if let Some(epoch_signing) = &mut self.epoch_signing {
let results = if let Some(epoch_signing) = &mut self.epoch_signing {
Some(
epoch_signing
.get_signed_blocks_results(self.current_epoch)
.get_signed_blocks_results(self.current_block_signing_epoch)
.await,
)
} else {
None
}
.transpose()
}
#[instrument(skip(self))]
async fn calculate_credential_rewards(
&mut self,
) -> Result<Option<CredentialIssuanceResults>, NymRewarderError> {
info!("calculating reward shares");
if let Some(credential_issuance) = &mut self.credential_issuance {
Some(
credential_issuance
.get_issued_credentials_results(self.current_epoch)
.await,
)
} else {
None
}
.transpose()
}
async fn determine_epoch_rewards(&mut self) -> EpochRewards {
let epoch_budget = self.config.rewarding.epoch_budget.clone();
let denom = &epoch_budget.denom;
let signing_budget = Coin::new(
(self.config.rewarding.ratios.block_signing * epoch_budget.amount as f64) as u128,
denom,
);
let credentials_budget = Coin::new(
(self.config.rewarding.ratios.credential_issuance * epoch_budget.amount as f64) as u128,
denom,
);
let signing_rewards = self.calculate_block_signing_rewards().await;
let credential_rewards = self.calculate_credential_rewards().await;
EpochRewards {
epoch: self.current_epoch,
signing: signing_rewards,
credentials: credential_rewards,
total_budget: epoch_budget.clone(),
signing_budget,
credentials_budget,
};
BlockSigningDetails {
epoch: self.current_block_signing_epoch,
results,
budget: self.config.block_signing_epoch_budget(),
}
}
#[instrument(skip(self))]
async fn send_rewards(
async fn send_block_signing_rewards(
&self,
amounts: Vec<(AccountId, Vec<Coin>)>,
) -> Result<Hash, NymRewarderError> {
) -> Result<Option<Hash>, NymRewarderError> {
if self.config.block_signing.monitor_only {
info!("skipping sending rewards, monitoring mode only");
return Ok(Hash::Sha256([0u8; 32]));
return Ok(None);
}
if amounts.is_empty() {
@@ -245,22 +271,57 @@ impl Rewarder {
}
info!("sending rewards");
// warn!("here be tx sending");
// Ok(Some(Hash::Sha256([0u8; 32])))
self.nyxd_client
.send_rewards(self.current_epoch, amounts)
.send_rewards(
format!("sending rewards for {}", self.last_processed_issuance_date),
amounts,
)
.await
.map(Some)
}
async fn calculate_and_send_epoch_rewards(
&mut self,
rewards: &EpochRewards,
) -> Result<RewardingResult, NymRewarderError> {
let rewarding_amounts = rewards.amounts()?;
let total_spent = total_spent(
&rewarding_amounts,
&self.config.rewarding.epoch_budget.denom,
);
#[instrument(skip(self))]
async fn send_ticketbook_issuance_rewards(
&self,
amounts: Vec<(AccountId, Vec<Coin>)>,
) -> Result<Option<Hash>, NymRewarderError> {
if self.config.ticketbook_issuance.monitor_only {
info!("skipping sending rewards, monitoring mode only");
return Ok(None);
}
let rewarding_tx = self.send_rewards(rewarding_amounts).await?;
if amounts.is_empty() {
warn!("no rewards to send");
return Err(NymRewarderError::NoSignersToReward);
}
info!("sending rewards");
// warn!("here be tx sending");
// Ok(Some(Hash::Sha256([0u8; 32])))
self.nyxd_client
.send_rewards(
format!(
"sending rewards issuing ticketbooks with expiration on {}",
self.last_processed_issuance_date
),
amounts,
)
.await
.map(Some)
}
async fn calculate_and_send_block_signing_epoch_rewards(
&mut self,
signed_blocks: &BlockSigningDetails,
) -> Result<RewardingResult, NymRewarderError> {
let rewarding_amounts = signed_blocks.rewarding_amounts()?;
let denom = &self.config.rewarding.daily_budget.denom;
let total_spent = total_spent(&rewarding_amounts, denom);
let rewarding_tx = self.send_block_signing_rewards(rewarding_amounts).await?;
Ok(RewardingResult {
total_spent,
@@ -268,30 +329,112 @@ impl Rewarder {
})
}
async fn handle_epoch_end(&mut self) {
info!("handling the epoch end");
let base_rewards = self.determine_epoch_rewards().await;
async fn calculate_and_send_ticketbook_issuance_rewards(
&mut self,
issued_ticketbooks: &TicketbookIssuanceDetails,
) -> Result<RewardingResult, NymRewarderError> {
let rewarding_amounts = issued_ticketbooks.rewarding_amounts()?;
let denom = &self.config.rewarding.daily_budget.denom;
let total_spent = total_spent(&rewarding_amounts, denom);
let rewarding_tx = self
.send_ticketbook_issuance_rewards(rewarding_amounts)
.await?;
Ok(RewardingResult {
total_spent,
rewarding_tx,
})
}
async fn handle_block_signing_epoch_end(&mut self) {
if !self.config.block_signing.enabled {
return;
}
info!("handling the block signing epoch end");
let details = self.block_signing_details().await;
let rewarding_result = self
.calculate_and_send_epoch_rewards(&base_rewards)
.calculate_and_send_block_signing_epoch_rewards(&details)
.await
.inspect_err(|err| error!("failed to determine and send epoch_rewards: {err}"));
.inspect_err(|err| error!("failed to determine and send block signing rewards: {err}"));
if let Err(err) = self
.storage
.save_rewarding_information(base_rewards, rewarding_result)
.save_block_signing_rewarding_information(details, rewarding_result)
.await
{
error!("failed to persist rewarding information: {err}")
}
self.current_epoch = self.current_epoch.next();
self.current_block_signing_epoch = self.current_block_signing_epoch.next();
}
#[instrument(skip(self))]
async fn ticketbook_issuance_details(&mut self, yesterday: Date) -> TicketbookIssuanceDetails {
info!("calculating reward shares");
let results = if let Some(ticketbook_issuance) = &mut self.ticketbook_issuance {
Some(
ticketbook_issuance
.get_issued_ticketbooks_results(yesterday)
.await,
)
} else {
None
};
TicketbookIssuanceDetails {
expiration_date: yesterday,
results,
total_budget: self.config.ticketbook_issuance_daily_budget(),
whitelist_size: self.config.ticketbook_issuance.whitelist.len(),
per_operator_budget: self.config.ticketbook_per_operator_daily_budget(),
}
}
async fn handle_next_ticketbook_issuance_day(&mut self) {
if !self.config.ticketbook_issuance.enabled {
return;
}
// sanity check to make sure it's actually after midnight
let today = ecash_today();
assert_eq!(today.hour(), 0);
// safety: this software is not run in 1 AD...
#[allow(clippy::unwrap_used)]
let yesterday = today.ecash_date().previous_day().unwrap();
// in case we crashed or something
if self.last_processed_issuance_date >= yesterday {
info!("we have already processed issuance for expiration at {yesterday}");
return;
}
let details = self.ticketbook_issuance_details(yesterday).await;
let rewarding_result = self
.calculate_and_send_ticketbook_issuance_rewards(&details)
.await
.inspect_err(|err| {
error!("failed to determine and send ticketbook issuance rewards: {err}")
});
if let Err(err) = self
.storage
.save_ticketbook_issuance_rewarding_information(details, rewarding_result)
.await
{
error!("failed to persist rewarding information: {err}")
}
self.last_processed_issuance_date = yesterday;
}
async fn ensure_has_epoch_blocks(&self) -> Result<(), NymRewarderError> {
// make sure we at least have a single block processed within the epoch
let epoch_start = self.current_epoch.start_time;
let epoch_end = self.current_epoch.end_time;
let epoch_start = self.current_block_signing_epoch.start_time;
let epoch_end = self.current_block_signing_epoch.end_time;
if let Some(epoch_signing) = &self.epoch_signing {
if epoch_signing
@@ -302,7 +445,7 @@ impl Rewarder {
.is_none()
{
return Err(NymRewarderError::NoBlocksProcessedInEpoch {
epoch: self.current_epoch,
epoch: self.current_block_signing_epoch,
});
}
@@ -314,7 +457,7 @@ impl Rewarder {
.is_none()
{
return Err(NymRewarderError::NoBlocksProcessedInEpoch {
epoch: self.current_epoch,
epoch: self.current_block_signing_epoch,
});
}
}
@@ -324,72 +467,49 @@ impl Rewarder {
async fn startup_resync(&mut self) -> Result<(), NymRewarderError> {
// no sync required
if !self.current_epoch.has_finished() {
if !self.current_block_signing_epoch.has_finished() {
return Ok(());
}
info!("attempting to distribute missed rewards");
while self.current_epoch.has_finished() {
info!("processing epoch {}", self.current_epoch);
self.ensure_has_epoch_blocks().await?;
if self.config.block_signing.enabled {
info!("attempting to distribute missed rewards");
while self.current_block_signing_epoch.has_finished() {
info!("processing epoch {}", self.current_block_signing_epoch);
self.ensure_has_epoch_blocks().await?;
// we need to perform rewarding from the 'current' epoch until the actual current epoch
self.handle_epoch_end().await
// we need to perform rewarding from the 'current' epoch until the actual current epoch
self.handle_block_signing_epoch_end().await
}
}
Ok(())
}
pub async fn run(mut self) -> Result<(), NymRewarderError> {
info!("Starting nym validators rewarder");
// setup shutdowns
let mut task_manager = TaskManager::new(5);
if let Some(ref credential_issuance) = self.credential_issuance {
credential_issuance.start_monitor(
self.config.issuance_monitor.clone(),
self.nyxd_client.clone(),
task_manager.subscribe(),
);
}
let mut scraper_cancellation: OptionFuture<_> =
async fn setup_tasks(&self) -> Result<impl FusedFuture, NymRewarderError> {
let scraper_cancellation: OptionFuture<_> =
if let Some(epoch_signing) = &self.epoch_signing {
let cancellation_token = epoch_signing.nyxd_scraper.cancel_token();
epoch_signing.nyxd_scraper.start().await?;
epoch_signing.nyxd_scraper.wait_for_startup_sync().await;
Some(Box::pin(async move { cancellation_token.cancelled().await }).fuse())
Some(Box::pin(async move { cancellation_token.cancelled_owned().await }).fuse())
} else {
None
}
.into();
Ok(scraper_cancellation)
}
let until_end = self.current_epoch.until_end();
async fn main_loop(
mut self,
mut task_manager: TaskManager,
mut scraper_cancellation: impl FusedFuture + Unpin,
) {
let mut block_signing_epoch_ticker = self
.current_block_signing_epoch
.epoch_ticker(self.config.block_signing.epoch_duration);
if let Err(err) = self.startup_resync().await {
error!("failed to perform startup sync: {err}");
error!("if the failure was due to insufficient number of blocks, your course of action is as follows:");
error!("(ideally it would have been automatically resolved in this very method, but that'd require some serious refactoring)");
error!(
"1. determine height of the first block of the epoch (doesn't have to be exact)"
);
error!("2. run the following subcommand of the rewarder: `nym-validator-rewarder process-until --start-height=$STARTING_BLOCK");
error!("3. !!IMPORTANT!! go to config.toml and temporarily disable block pruning, i.e. `pruning.strategy=nothing`");
error!("4. restart nym-validator-rewarder as normal until it sends missing rewards");
error!("5. re-enable pruning and restart the nym-validator rewarder");
return Err(err);
}
info!(
"the initial epoch (id: {}) will finish in {} secs",
self.current_epoch.id,
until_end.as_secs()
);
let mut epoch_ticker = interval_at(
Instant::now().add(until_end),
self.config.rewarding.epoch_duration,
);
// runs daily
let mut ticketbook_issuance_ticker = end_of_day_ticker();
let shutdown_future = task_manager.catch_interrupt();
pin!(shutdown_future);
@@ -408,13 +528,38 @@ impl Rewarder {
warn!("the nyxd scraper has been cancelled");
break
}
_ = epoch_ticker.tick() => self.handle_epoch_end().await
_ = block_signing_epoch_ticker.tick() => self.handle_block_signing_epoch_end().await,
_ = ticketbook_issuance_ticker.tick() => self.handle_next_ticketbook_issuance_day().await,
}
}
if let Some(epoch_signing) = self.epoch_signing {
epoch_signing.nyxd_scraper.stop().await;
}
}
pub async fn run(mut self) -> Result<(), NymRewarderError> {
info!("Starting nym validators rewarder");
// setup shutdowns
let task_manager = TaskManager::new(5);
let scraper_cancellation = self.setup_tasks().await?;
if let Err(err) = self.startup_resync().await {
error!("failed to perform startup sync: {err}");
error!("if the failure was due to insufficient number of blocks, your course of action is as follows:");
error!("(ideally it would have been automatically resolved in this very method, but that'd require some serious refactoring)");
error!(
"1. determine height of the first block of the epoch (doesn't have to be exact)"
);
error!("2. run the following subcommand of the rewarder: `nym-validator-rewarder process-until --start-height=$STARTING_BLOCK");
error!("3. !!IMPORTANT!! go to config.toml and temporarily disable block pruning, i.e. `pruning.strategy=nothing`");
error!("4. restart nym-validator-rewarder as normal until it sends missing rewards");
error!("5. re-enable pruning and restart the nym-validator rewarder");
return Err(err);
}
self.main_loop(task_manager, scraper_cancellation).await;
Ok(())
}
@@ -3,26 +3,25 @@
use crate::config::Config;
use crate::error::NymRewarderError;
use crate::rewarder::credential_issuance::types::{addr_to_account_id, CredentialIssuer};
use crate::rewarder::ticketbook_issuance::types::{addr_to_account_id, CredentialIssuer};
use nym_coconut_dkg_common::types::Epoch;
use nym_compact_ecash::{Base58, VerificationKeyAuth};
use nym_crypto::asymmetric::ed25519;
use nym_network_defaults::NymNetworkDetails;
use nym_validator_client::nyxd::contract_traits::ecash_query_client::{Deposit, DepositId};
use nym_validator_client::nyxd::contract_traits::{
DkgQueryClient, EcashQueryClient, PagedDkgQueryClient,
};
use nym_validator_client::nyxd::contract_traits::{DkgQueryClient, PagedDkgQueryClient};
use nym_validator_client::nyxd::module_traits::staking::{
QueryHistoricalInfoResponse, QueryValidatorsResponse,
};
use nym_validator_client::nyxd::{
AccountId, Coin, CosmWasmClient, Hash, PageRequest, StakingQueryClient,
};
use nym_validator_client::{nyxd, DirectSigningHttpRpcNyxdClient};
use nym_validator_client::{nyxd, DirectSigningHttpRpcNyxdClient, NymApiClient};
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::warn;
use url::Url;
#[derive(Clone)]
pub struct NyxdClient {
@@ -59,13 +58,13 @@ impl NyxdClient {
pub(crate) async fn send_rewards(
&self,
epoch: crate::rewarder::Epoch,
memo: impl Into<String> + Send + 'static,
amounts: Vec<(AccountId, Vec<Coin>)>,
) -> Result<Hash, NymRewarderError> {
self.inner
.write()
.await
.send_multiple(amounts, format!("sending rewards for {epoch:?}"), None)
.send_multiple(amounts, memo, None)
.await
.map(|res| res.hash)
.map_err(Into::into)
@@ -90,44 +89,57 @@ impl NyxdClient {
Ok(self.inner.read().await.get_current_epoch().await?)
}
pub(crate) async fn get_credential_issuers(
pub(crate) async fn get_current_ticketbook_issuers(
&self,
dkg_epoch: u64,
) -> Result<Vec<CredentialIssuer>, NymRewarderError> {
let current_dkg_epoch = self.dkg_epoch().await?;
let guard = self.inner.read().await;
let mut dealers_map = HashMap::new();
let dealers = guard.get_all_current_dealers().await?;
for dealer in dealers {
dealers_map.insert(dealer.address.to_string(), dealer);
}
let vk_shares = guard.get_all_verification_key_shares(dkg_epoch).await?;
let vk_shares = guard
.get_all_verification_key_shares(current_dkg_epoch.epoch_id)
.await?;
let mut issuers = Vec::with_capacity(vk_shares.len());
for share in vk_shares {
if let Some(info) = dealers_map.remove(&share.owner.to_string()) {
issuers.push(CredentialIssuer {
public_key: ed25519::PublicKey::from_base58_string(&info.ed25519_identity)?,
operator_account: addr_to_account_id(share.owner),
api_runner: share.announce_address,
verification_key: VerificationKeyAuth::try_from_bs58(share.share).map_err(
|source| NymRewarderError::MalformedPartialVerificationKey {
if !share.verified {
warn!("share of {} was not verified", info.address);
continue;
}
// information in the contract MUST BE correct for everyone - it is not we shouldn't reward anyone until it's resolved
let verification_key =
VerificationKeyAuth::try_from_bs58(share.share).map_err(|source| {
NymRewarderError::MalformedPartialVerificationKey {
runner: info.address.to_string(),
source,
},
)?,
}
})?;
let Ok(api_address) = Url::parse(&share.announce_address) else {
warn!("{} provided invalid api url", info.address);
continue;
};
let Ok(public_key) = ed25519::PublicKey::from_base58_string(&info.ed25519_identity)
else {
warn!("{} provided invalid ed25519 identity", info.address);
continue;
};
issuers.push(CredentialIssuer {
public_key,
operator_account: addr_to_account_id(share.owner),
api_client: NymApiClient::new(api_address),
verification_key,
node_id: info.assigned_index,
})
}
}
Ok(issuers)
}
pub(crate) async fn get_deposit_details(
&self,
deposit_id: DepositId,
) -> Result<Deposit, NymRewarderError> {
let res = self.inner.read().await.get_deposit(deposit_id).await?;
res.deposit
.ok_or(NymRewarderError::DepositNotFound { deposit_id })
}
}
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::rewarder::epoch::Epoch;
use nym_validator_client::nyxd::contract_traits::ecash_query_client::DepositId;
use time::{Date, OffsetDateTime};
#[derive(Clone)]
pub(crate) struct StorageManager {
@@ -10,11 +10,13 @@ pub(crate) struct StorageManager {
}
impl StorageManager {
pub(crate) async fn load_last_rewarding_epoch(&self) -> Result<Option<Epoch>, sqlx::Error> {
pub(crate) async fn load_last_block_signing_rewarding_epoch(
&self,
) -> Result<Option<Epoch>, sqlx::Error> {
sqlx::query_as(
r#"
SELECT id, start_time, end_time
FROM rewarding_epoch
FROM block_signing_rewarding_epoch
ORDER BY id DESC
LIMIT 1
"#,
@@ -23,54 +25,159 @@ impl StorageManager {
.await
}
pub(crate) async fn insert_rewarding_epoch(
pub(crate) async fn load_last_ticketbook_issuance_expiration_date(
&self,
) -> Result<Option<Date>, sqlx::Error> {
Ok(sqlx::query!(
r#"
SELECT expiration_date as "expiration_date: Date"
FROM ticketbook_issuance_epoch
ORDER BY expiration_date DESC
LIMIT 1
"#,
)
.fetch_optional(&self.connection_pool)
.await?
.map(|record| record.expiration_date))
}
pub(crate) async fn load_banned_ticketbook_issuers(&self) -> Result<Vec<String>, sqlx::Error> {
Ok(sqlx::query!(
r#"
SELECT operator_account
FROM banned_ticketbook_issuer
"#,
)
.fetch_all(&self.connection_pool)
.await?
.into_iter()
.map(|record| record.operator_account)
.collect())
}
pub(crate) async fn insert_block_signing_rewarding_epoch(
&self,
epoch: Epoch,
rewarding_budget: String,
total_spent: String,
rewarding_tx: Option<String>,
rewarding_error: Option<String>,
block_signing_budget: String,
disabled: bool,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO rewarding_epoch (id, start_time, end_time, budget, spent, rewarding_tx, rewarding_error)
VALUES (?, ?, ? ,?, ?, ?, ?)
INSERT INTO block_signing_rewarding_epoch (id, start_time, end_time, budget, disabled)
VALUES (?, ?, ?, ?, ?)
"#,
epoch.id,
epoch.start_time,
epoch.end_time,
rewarding_budget,
total_spent: String,
rewarding_tx,
rewarding_error
block_signing_budget,
disabled
).execute(&self.connection_pool).await?;
Ok(())
}
pub(crate) async fn insert_rewarding_epoch_block_signing(
pub(crate) async fn insert_ticketbook_issuance_epoch(
&self,
epoch: i64,
total_voting_power_at_epoch_start: i64,
num_blocks: i64,
budget: String,
expiration_date: Date,
total_budget: String,
whitelist_size: u32,
budget_per_operator: String,
disabled: bool,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO epoch_block_signing (rewarding_epoch_id, total_voting_power_at_epoch_start, num_blocks, budget)
VALUES (?, ?, ?, ?)
INSERT INTO ticketbook_issuance_epoch(
expiration_date,
total_budget,
whitelist_size,
budget_per_operator,
disabled
) VALUES (?, ?, ?, ?, ?)
"#,
epoch,
total_voting_power_at_epoch_start,
num_blocks,
budget,
).execute(&self.connection_pool).await?;
expiration_date,
total_budget,
whitelist_size,
budget_per_operator,
disabled
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_rewarding_epoch_block_signing_reward(
pub(crate) async fn insert_block_signing_rewarding_details(
&self,
epoch: i64,
total_voting_power_at_epoch_start: i64,
num_blocks: i64,
total_spent: String,
rewarding_tx: Option<String>,
rewarding_error: Option<String>,
monitor_only: bool,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO block_signing_rewarding_details(
rewarding_epoch_id,
total_voting_power_at_epoch_start,
num_blocks,
spent,
rewarding_tx,
rewarding_error,
monitor_only
) VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
epoch,
total_voting_power_at_epoch_start,
num_blocks,
total_spent,
rewarding_tx,
rewarding_error,
monitor_only
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn insert_ticketbook_issuance_rewarding_details(
&self,
ticketbook_expiration_date: Date,
approximate_deposits: i64,
total_spent: String,
rewarding_tx: Option<String>,
rewarding_error: Option<String>,
monitor_only: bool,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO ticketbook_issuance_rewarding_details(
ticketbook_expiration_date,
approximate_deposits,
spent,
rewarding_tx,
rewarding_error,
monitor_only
) VALUES (?, ?, ?, ?, ?, ?)
"#,
ticketbook_expiration_date,
approximate_deposits,
total_spent,
rewarding_tx,
rewarding_error,
monitor_only
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_block_signing_reward(
&self,
epoch: i64,
consensus_address: String,
@@ -112,209 +219,82 @@ impl StorageManager {
Ok(())
}
pub(crate) async fn insert_rewarding_epoch_credential_issuance(
&self,
epoch: i64,
starting_dkg_epoch: i64,
ending_dkg_epoch: i64,
total_issued_partial_credentials: i64,
budget: String,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO epoch_credential_issuance (
rewarding_epoch_id,
starting_dkg_epoch,
ending_dkg_epoch,
total_issued_partial_credentials,
budget
)
VALUES (?, ?, ?, ?, ?)
"#,
epoch,
starting_dkg_epoch,
ending_dkg_epoch,
total_issued_partial_credentials,
budget,
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_rewarding_epoch_credential_issuance_reward(
pub(crate) async fn insert_ticketbook_issuance_reward(
&self,
epoch: i64,
ticketbook_expiration_date: Date,
api_endpoint: String,
operator_account: String,
whitelisted: bool,
banned: bool,
amount: String,
api_endpoint: String,
issued_partial_credentials: u32,
issued_credentials_share: String,
validated_issued_credentials: u32,
issued_partial_ticketbooks: u32,
share_of_issued_ticketbooks: f32,
skipped_verification: bool,
subsample_size: u32,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO credential_issuance_reward (
rewarding_epoch_id,
INSERT INTO ticketbook_issuance_reward(
ticketbook_expiration_date,
api_endpoint,
operator_account,
whitelisted,
banned,
amount,
api_endpoint,
issued_partial_credentials,
issued_credentials_share,
validated_issued_credentials
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
issued_partial_ticketbooks,
share_of_issued_ticketbooks,
skipped_verification,
subsample_size
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
epoch,
ticketbook_expiration_date,
api_endpoint,
operator_account,
whitelisted,
banned,
amount,
issued_partial_ticketbooks,
share_of_issued_ticketbooks,
skipped_verification,
subsample_size,
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn insert_banned_ticketbook_issuer(
&self,
operator_account: String,
api_endpoint: String,
banned_on: OffsetDateTime,
associated_ticketbook_expiration_date: Date,
reason: String,
evidence: Vec<u8>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO banned_ticketbook_issuer(
operator_account,
api_endpoint,
banned_on,
associated_ticketbook_expiration_date,
reason,
evidence
) VALUES (?, ?, ?, ?, ?, ?)
"#,
operator_account,
api_endpoint,
issued_partial_credentials,
issued_credentials_share,
validated_issued_credentials,
banned_on,
associated_ticketbook_expiration_date,
reason,
evidence,
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn insert_validated_deposit(
&self,
operator_identity_bs58: String,
credential_id: i64,
deposit_id: DepositId,
signed_plaintext: Vec<u8>,
signature_bs58: String,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO validated_deposit (
operator_identity_bs58,
credential_id,
deposit_id,
signed_plaintext,
signature_bs58
) VALUES (?, ?, ?, ?, ?)
"#,
operator_identity_bs58,
credential_id,
deposit_id,
signed_plaintext,
signature_bs58
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn get_deposit_credential_id(
&self,
operator_identity_bs58: String,
deposit_id: DepositId,
) -> Result<Option<i64>, sqlx::Error> {
Ok(sqlx::query!(
r#"
SELECT credential_id
FROM validated_deposit
WHERE operator_identity_bs58 = ? AND deposit_id = ?
"#,
operator_identity_bs58,
deposit_id
)
.fetch_optional(&self.connection_pool)
.await?
.map(|record| record.credential_id))
}
pub(crate) async fn insert_double_signing_evidence(
&self,
operator_identity_bs58: String,
credential_id: i64,
original_credential_id: i64,
deposit_id: DepositId,
signed_plaintext: Vec<u8>,
signature_bs58: String,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO double_signing_evidence (
operator_identity_bs58,
credential_id,
original_credential_id,
deposit_id,
signed_plaintext,
signature_bs58
) VALUES (?, ?, ?, ?, ?, ?)
"#,
operator_identity_bs58,
credential_id,
original_credential_id,
deposit_id,
signed_plaintext,
signature_bs58
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn insert_foul_play_evidence(
&self,
operator_account: String,
operator_identity_bs58: String,
credential_id: i64,
signed_plaintext: Vec<u8>,
signature_bs58: String,
failure_message: String,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO issuance_evidence (
operator_account,
operator_identity_bs58,
credential_id,
signed_plaintext,
signature_bs58,
failure_message
) VALUES (?, ?, ?, ?, ?, ?)
"#,
operator_account,
operator_identity_bs58,
credential_id,
signed_plaintext,
signature_bs58,
failure_message,
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn insert_validation_failure_info(
&self,
operator_account: String,
operator_identity_bs58: String,
failure_message: String,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO issuance_validation_failure (
operator_account,
operator_identity_bs58,
failure_message
) VALUES (?, ?, ?)
"#,
operator_account,
operator_identity_bs58,
failure_message,
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
}
+177 -203
View File
@@ -1,19 +1,15 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::rewarder::{extract_rewarding_results, BlockSigningDetails, TicketbookIssuanceDetails};
use crate::{
error::NymRewarderError,
rewarder::{
credential_issuance::types::CredentialIssuer, epoch::Epoch,
storage::manager::StorageManager, EpochRewards, RewardingResult,
},
};
use nym_validator_client::{
nym_api::IssuedTicketbookBody,
nyxd::{contract_traits::ecash_query_client::DepositId, Coin},
rewarder::{epoch::Epoch, storage::manager::StorageManager, RewardingResult},
};
use nym_contracts_common::types::NaiveFloat;
use sqlx::ConnectOptions;
use std::{fmt::Debug, path::Path};
use time::{Date, OffsetDateTime};
use tracing::{error, info, instrument};
mod manager;
@@ -54,234 +50,212 @@ impl RewarderStorage {
Ok(storage)
}
pub(crate) async fn load_last_rewarding_epoch(
pub(crate) async fn load_last_block_signing_rewarding_epoch(
&self,
) -> Result<Option<Epoch>, NymRewarderError> {
Ok(self.manager.load_last_rewarding_epoch().await?)
}
async fn insert_failed_rewarding_epoch_block_signing(
&self,
epoch: i64,
budget: &Coin,
) -> Result<(), NymRewarderError> {
Ok(self
.manager
.insert_rewarding_epoch_block_signing(epoch, -1, -1, budget.to_string())
.load_last_block_signing_rewarding_epoch()
.await?)
}
async fn insert_failed_rewarding_epoch_credential_issuance(
pub(crate) async fn load_last_ticketbook_issuance_expiration_date(
&self,
epoch: i64,
budget: &Coin,
) -> Result<(), NymRewarderError> {
) -> Result<Option<Date>, NymRewarderError> {
Ok(self
.manager
.insert_rewarding_epoch_credential_issuance(epoch, -1, -1, -1, budget.to_string())
.load_last_ticketbook_issuance_expiration_date()
.await?)
}
pub(crate) async fn get_deposit_credential_id(
pub(crate) async fn load_banned_ticketbook_issuers(
&self,
operator_identity_bs58: String,
deposit_id: DepositId,
) -> Result<Option<i64>, NymRewarderError> {
Ok(self
.manager
.get_deposit_credential_id(operator_identity_bs58, deposit_id)
.await?)
) -> Result<Vec<String>, NymRewarderError> {
Ok(self.manager.load_banned_ticketbook_issuers().await?)
}
pub(crate) async fn insert_validated_deposit(
pub(crate) async fn save_block_signing_rewarding_information(
&self,
operator_identity_bs58: String,
credential_info: &IssuedTicketbookBody,
) -> Result<(), NymRewarderError> {
self.manager
.insert_validated_deposit(
operator_identity_bs58,
credential_info.credential.id,
credential_info.credential.deposit_id,
credential_info.credential.signable_plaintext(),
credential_info.signature.to_base58_string(),
)
.await?;
Ok(())
}
pub(crate) async fn insert_double_signing_evidence(
&self,
operator_identity_bs58: String,
original_credential_id: i64,
credential_info: &IssuedTicketbookBody,
) -> Result<(), NymRewarderError> {
self.manager
.insert_double_signing_evidence(
operator_identity_bs58,
credential_info.credential.id,
original_credential_id,
credential_info.credential.deposit_id,
credential_info.credential.signable_plaintext(),
credential_info.signature.to_base58_string(),
)
.await?;
Ok(())
}
pub(crate) async fn insert_issuance_foul_play_evidence(
&self,
issuer: &CredentialIssuer,
credential_info: &IssuedTicketbookBody,
error_message: String,
) -> Result<(), NymRewarderError> {
self.manager
.insert_foul_play_evidence(
issuer.operator_account.to_string(),
issuer.public_key.to_base58_string(),
credential_info.credential.id,
credential_info.credential.signable_plaintext(),
credential_info.signature.to_base58_string(),
error_message,
)
.await?;
Ok(())
}
pub(crate) async fn insert_issuance_validation_failure_info(
&self,
issuer: &CredentialIssuer,
error_message: String,
) -> Result<(), NymRewarderError> {
self.manager
.insert_validation_failure_info(
issuer.operator_account.to_string(),
issuer.public_key.to_base58_string(),
error_message,
)
.await?;
Ok(())
}
pub(crate) async fn save_rewarding_information(
&self,
reward: EpochRewards,
details: BlockSigningDetails,
rewarding_result: Result<RewardingResult, NymRewarderError>,
// total_spent: Coin,
// rewarding_tx: Result<Hash, NymRewarderError>,
) -> Result<(), NymRewarderError> {
info!("persisting reward details");
let denom = &reward.total_budget.denom;
info!("persisting block signing reward details");
let denom = &details.budget.denom;
let (reward_tx, total_spent, reward_err) = match rewarding_result {
Ok(res) => (Some(res.rewarding_tx.to_string()), res.total_spent, None),
Err(err) => (None, Coin::new(0, denom), Some(err.to_string())),
};
let epoch_id = reward.epoch.id;
let extracted_results = extract_rewarding_results(rewarding_result, denom);
let epoch_id = details.epoch.id;
// general epoch info
self.manager
.insert_rewarding_epoch(
reward.epoch,
reward.total_budget.to_string(),
total_spent.to_string(),
reward_tx,
reward_err,
.insert_block_signing_rewarding_epoch(
details.epoch,
details.budget.to_string(),
details.results.is_none(),
)
.await?;
// block signing info
if let Ok(block_signing) = reward.signing {
self.manager
.insert_rewarding_epoch_block_signing(
epoch_id,
block_signing
.as_ref()
.map(|s| s.total_voting_power_at_epoch_start)
.unwrap_or_default(),
block_signing.as_ref().map(|s| s.blocks).unwrap_or_default(),
reward.signing_budget.to_string(),
)
.await?;
if let Some(signing) = block_signing {
for validator in signing.validators {
let reward_amount = validator.reward_amount(&reward.signing_budget).to_string();
self.manager
.insert_rewarding_epoch_block_signing_reward(
epoch_id,
validator.validator.consensus_address,
validator.operator_account.to_string(),
validator.whitelisted,
reward_amount,
validator.voting_power_at_epoch_start,
validator.voting_power_ratio.to_string(),
validator.signed_blocks,
validator.ratio_signed.to_string(),
)
.await?;
let Some(results) = details.results else {
// no information to save as it's disabled
return Ok(());
};
let results = match results {
Ok(results) => results,
Err(err) => {
// we didn't manage to calculate or send rewards for anyone.
// save the failure information and continue
if extracted_results.total_spent.amount != 0 {
error!(
"BROKEN INVARIANT: failed to send rewards yet we spent a non-zero amount!"
);
error!("the rewards weren't sent because of: {err}");
return Ok(());
}
self.manager
.insert_block_signing_rewarding_details(
epoch_id,
-1,
-1,
extracted_results.total_spent.to_string(),
None,
Some(err.to_string()),
extracted_results.monitor_only,
)
.await?;
return Ok(());
}
} else {
self.insert_failed_rewarding_epoch_block_signing(epoch_id, &reward.signing_budget)
.await?;
}
};
// credential info
if let Ok(credential_issuance) = reward.credentials {
// safety: we must have at least a single value here
#[allow(clippy::unwrap_used)]
let dkg_epoch_start = credential_issuance
.as_ref()
.and_then(|c| c.dkg_epochs.first().copied())
.unwrap_or_default() as i64;
#[allow(clippy::unwrap_used)]
let dkg_epoch_end = credential_issuance
.as_ref()
.and_then(|c| c.dkg_epochs.last().copied())
.unwrap_or_default() as i64;
self.manager
.insert_rewarding_epoch_credential_issuance(
epoch_id,
dkg_epoch_start,
dkg_epoch_end,
credential_issuance
.as_ref()
.map(|c| c.total_issued_partial_credentials)
.unwrap_or_default() as i64,
reward.credentials_budget.to_string(),
)
.await?;
if let Some(credentials) = credential_issuance {
for api_runner in credentials.api_runners {
let reward_amount = api_runner
.reward_amount(&reward.credentials_budget)
.to_string();
self.manager
.insert_rewarding_epoch_credential_issuance_reward(
epoch_id,
api_runner.runner_account.to_string(),
api_runner.whitelisted,
reward_amount,
api_runner.api_runner,
api_runner.issued_credentials,
api_runner.issued_ratio.to_string(),
api_runner.validated_credentials,
)
.await?;
}
}
} else {
self.insert_failed_rewarding_epoch_credential_issuance(
self.manager
.insert_block_signing_rewarding_details(
epoch_id,
&reward.credentials_budget,
results.total_voting_power_at_epoch_start,
results.blocks,
extracted_results.total_spent.to_string(),
extracted_results.rewarding_tx,
extracted_results.rewarding_err,
extracted_results.monitor_only,
)
.await?;
for validator in results.validators {
let reward_amount = validator.reward_amount(&details.budget).to_string();
self.manager
.insert_block_signing_reward(
epoch_id,
validator.validator.consensus_address,
validator.operator_account.to_string(),
validator.whitelisted,
reward_amount,
validator.voting_power_at_epoch_start,
validator.voting_power_ratio.to_string(),
validator.signed_blocks,
validator.ratio_signed.to_string(),
)
.await?;
}
Ok(())
}
pub(crate) async fn save_ticketbook_issuance_rewarding_information(
&self,
details: TicketbookIssuanceDetails,
rewarding_result: Result<RewardingResult, NymRewarderError>,
) -> Result<(), NymRewarderError> {
info!("persisting ticketbook issuance reward details");
let denom = &details.total_budget.denom;
let extracted_results = extract_rewarding_results(rewarding_result, denom);
let expiration_date = details.expiration_date;
// general info for the epoch as marked by the ticketbook expiration date
self.manager
.insert_ticketbook_issuance_epoch(
details.expiration_date,
details.total_budget.to_string(),
details.whitelist_size as u32,
details.per_operator_budget.to_string(),
details.results.is_none(),
)
.await?;
let Some(results) = details.results else {
// no information to save as it's disabled
return Ok(());
};
let results = match results {
Ok(results) => results,
Err(err) => {
// we didn't manage to calculate or send rewards for anyone.
// save the failure information and continue
if extracted_results.total_spent.amount != 0 {
error!(
"BROKEN INVARIANT: failed to send rewards yet we spent a non-zero amount!"
);
error!("the rewards weren't sent because of: {err}");
return Ok(());
}
self.manager
.insert_ticketbook_issuance_rewarding_details(
expiration_date,
-1,
extracted_results.total_spent.to_string(),
None,
Some(err.to_string()),
extracted_results.monitor_only,
)
.await?;
return Ok(());
}
};
self.manager
.insert_ticketbook_issuance_rewarding_details(
expiration_date,
results.approximate_deposits as i64,
extracted_results.total_spent.to_string(),
extracted_results.rewarding_tx,
extracted_results.rewarding_err,
extracted_results.monitor_only,
)
.await?;
for issuer in results.api_runners {
let reward_amount = issuer
.reward_amount(&details.per_operator_budget)
.to_string();
self.manager
.insert_ticketbook_issuance_reward(
expiration_date,
issuer.api_runner.clone(),
issuer.runner_account.to_string(),
issuer.whitelisted,
issuer.pre_banned || issuer.issuer_ban.is_some(),
reward_amount,
issuer.issued_ticketbooks,
issuer.issued_ratio.naive_to_f64() as f32,
issuer.skipped_verification,
issuer.subsample_size,
)
.await?;
if let Some(cheating) = issuer.issuer_ban {
self.manager
.insert_banned_ticketbook_issuer(
issuer.api_runner,
issuer.runner_account.to_string(),
OffsetDateTime::now_utc(),
expiration_date,
cheating.reason,
cheating.serialised_evidence,
)
.await?;
}
}
Ok(())
}
}
@@ -0,0 +1,37 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::ops::Add;
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use tokio::time::{interval_at, Instant, Interval};
use tracing::info;
pub fn end_of_day_ticker() -> Interval {
let now = OffsetDateTime::now_utc();
// safety: we're not running this in year 9999...
#[allow(clippy::unwrap_used)]
let next_day = now.date().next_day().unwrap().midnight().assume_utc();
// safety: the duration is guaranteed to be positive
#[allow(clippy::unwrap_used)]
let until_next_day: Duration = (next_day - now).try_into().unwrap();
// add extra 2h to account for leeway with issuance at the beginning of a day
let until_next_rewarding = until_next_day.add(Duration::from_secs(2 * 60 * 60));
// safety: we're using well-defined format provided by the library
#[allow(clippy::unwrap_used)]
let next_rewarding_rfc3339 = (now + until_next_rewarding).format(&Rfc3339).unwrap();
info!(
"if enabled, the next ticketbook issuance rewarding will happen on {next_rewarding_rfc3339} ({} secs remaining)",
until_next_rewarding.as_secs()
);
interval_at(
Instant::now().add(until_next_rewarding),
Duration::from_secs(24 * 60 * 60),
)
}
@@ -0,0 +1,63 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::NymRewarderError;
use crate::rewarder::nyxd_client::NyxdClient;
use crate::rewarder::storage::RewarderStorage;
use crate::rewarder::ticketbook_issuance::types::TicketbookIssuanceResults;
use crate::rewarder::ticketbook_issuance::verifier::TicketbookIssuanceVerifier;
use nym_validator_client::nyxd::AccountId;
use time::Date;
use tracing::{debug, info};
pub(crate) use crate::rewarder::ticketbook_issuance::verifier::VerificationConfig;
pub mod helpers;
// mod monitor;
pub mod types;
pub mod verifier;
pub struct TicketbookIssuance {
pub(crate) config: VerificationConfig,
pub(crate) nyxd_client: NyxdClient,
pub(crate) storage: RewarderStorage,
pub(crate) whitelist: Vec<AccountId>,
}
impl TicketbookIssuance {
pub(crate) fn new(
config: VerificationConfig,
storage: RewarderStorage,
nyxd_client: &NyxdClient,
whitelist: &[AccountId],
) -> Self {
TicketbookIssuance {
config,
nyxd_client: nyxd_client.clone(),
storage,
whitelist: whitelist.to_vec(),
}
}
pub(crate) async fn get_issued_ticketbooks_results(
&self,
expiration_date: Date,
) -> Result<TicketbookIssuanceResults, NymRewarderError> {
info!("checking for all issued ticketbooks on {expiration_date}");
// 1. get all ecash issuers
let issuers = self.nyxd_client.get_current_ticketbook_issuers().await?;
debug!("retrieved {} ticketbook issuers", issuers.len());
// 2. load all banned issuers to skip them completely
let banned = self.storage.load_banned_ticketbook_issuers().await?;
debug!("retrieved {} banned ticketbook issuers", banned.len());
let mut verifier =
TicketbookIssuanceVerifier::new(self.config, &self.whitelist, banned, expiration_date);
// 3. go around and check the specified issuers
verifier.check_issuers(issuers).await
}
}
@@ -0,0 +1,93 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::rewarder::ticketbook_issuance::verifier::IssuerBan;
use cosmwasm_std::{Addr, Decimal, Uint128};
use nym_coconut_dkg_common::types::NodeIndex;
use nym_compact_ecash::VerificationKeyAuth;
use nym_crypto::asymmetric::ed25519;
use nym_validator_client::nyxd::{AccountId, Coin};
use nym_validator_client::NymApiClient;
use std::fmt::{Display, Formatter};
use tracing::info;
pub struct OperatorIssuing {
pub api_runner: String,
pub whitelisted: bool,
pub pre_banned: bool,
pub runner_account: AccountId,
// TODO: split reward into 1/whitelist size
// then issued ratio is ratio of deposits in that time interval and thus your slice of the 1/whitelist
pub issued_ratio: Decimal,
pub skipped_verification: bool,
pub subsample_size: u32,
pub issued_ticketbooks: u32,
pub issuer_ban: Option<IssuerBan>,
}
impl OperatorIssuing {
pub fn reward_amount(&self, operator_budget: &Coin) -> Coin {
if !self.whitelisted || self.issuer_ban.is_some() {
return Coin::new(0, &operator_budget.denom);
}
let amount = Uint128::new(operator_budget.amount) * self.issued_ratio;
Coin::new(amount.u128(), &operator_budget.denom)
}
}
pub struct TicketbookIssuanceResults {
pub approximate_deposits: u32,
pub api_runners: Vec<OperatorIssuing>,
}
impl TicketbookIssuanceResults {
pub fn rewarding_amounts(&self, per_operator_budget: &Coin) -> Vec<(AccountId, Vec<Coin>)> {
info!("our budget per operator is: {per_operator_budget}");
let mut amounts = Vec::with_capacity(self.api_runners.len());
for api_runner in &self.api_runners {
let amount = api_runner.reward_amount(per_operator_budget);
info!(
"operator {} will receive {amount} at address {} for ticketbook issuance work (whitelisted: {}, ticketbooks issued: {})",
api_runner.api_runner,
api_runner.runner_account,
api_runner.whitelisted,
api_runner.issued_ticketbooks
);
amounts.push((api_runner.runner_account.clone(), vec![amount]))
}
amounts
}
}
#[derive(Clone)]
pub struct CredentialIssuer {
pub public_key: ed25519::PublicKey,
pub operator_account: AccountId,
pub api_client: NymApiClient,
pub verification_key: VerificationKeyAuth,
pub node_id: NodeIndex,
}
impl Display for CredentialIssuer {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[id: {}] {} @ {}",
self.node_id,
self.operator_account,
self.api_client.api_url()
)
}
}
// safety: we're converting between different wrappers for bech32 addresses
// and we trust (reasonably so), the values coming out of registered dealers in the DKG contract
pub(crate) fn addr_to_account_id(addr: Addr) -> AccountId {
#[allow(clippy::unwrap_used)]
addr.as_str().parse().unwrap()
}
@@ -0,0 +1,610 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::NymRewarderError;
use crate::rewarder::ticketbook_issuance::types::{
CredentialIssuer, OperatorIssuing, TicketbookIssuanceResults,
};
use cosmwasm_std::Decimal;
use nym_compact_ecash::scheme::withdrawal::verify_partial_blind_signature;
use nym_compact_ecash::{date_scalar, type_scalar, CompactEcashError};
use nym_crypto::asymmetric::ed25519::{self, serde_helpers::bs58_ed25519_pubkey};
use nym_ecash_time::EcashTime;
use nym_ticketbooks_merkle::{IssuedTicketbook, MerkleLeaf};
use nym_validator_client::ecash::models::{
CommitedDeposit, DepositId, IssuedTicketbooksChallengeResponse,
IssuedTicketbooksChallengeResponseBody, IssuedTicketbooksForResponse,
};
use nym_validator_client::nyxd::AccountId;
use rand::distributions::{Distribution, WeightedIndex};
use rand::prelude::SliceRandom;
use rand::thread_rng;
use serde::{Deserialize, Serialize};
use std::cmp::max;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use time::Date;
use tracing::{debug, info, instrument, warn};
#[derive(Error, Debug)]
enum PartialTicketbookVerificationFailure {
#[error("failed to deserialise associated blinded signature: {0}")]
MalformedBlindedSignature(CompactEcashError),
#[error("failed to deserialise private attributes commitments: {0}")]
MalformedPrivateAttributesCommitments(CompactEcashError),
#[error("the associated blinded signature failed to get verified")]
InvalidSignature,
}
#[derive(Serialize, Deserialize)]
pub struct Empty {}
#[derive(Serialize, Deserialize)]
pub struct RegisteredPubKey {
#[serde(with = "bs58_ed25519_pubkey")]
registered_pub_key: ed25519::PublicKey,
}
#[derive(Serialize, Deserialize)]
pub struct MismatchResponse<T> {
requested: T,
received: T,
}
#[derive(Serialize, Deserialize)]
pub struct MismatchClaim<T> {
claimed: T,
actual: T,
}
#[derive(Serialize, Deserialize)]
pub struct GenericError {
error: String,
}
pub struct IssuerBan {
pub reason: String,
pub serialised_evidence: Vec<u8>,
}
#[derive(Serialize, Deserialize)]
pub struct CheatingEvidence<T = Empty> {
commitment: Option<IssuedTicketbooksForResponse>,
requested_challenge: Vec<DepositId>,
challenge_response: Option<IssuedTicketbooksChallengeResponse>,
#[serde(flatten)]
inner: T,
}
pub struct IssuerUnderTest {
details: CredentialIssuer,
verification_skipped: bool,
issuer_ban: Option<IssuerBan>,
issued_commitment: Option<IssuedTicketbooksForResponse>,
sampled_deposits: HashMap<DepositId, CommitedDeposit>,
challenge_response: Option<IssuedTicketbooksChallengeResponse>,
}
impl IssuerUnderTest {
fn new(details: CredentialIssuer) -> Self {
IssuerUnderTest {
details,
verification_skipped: false,
issuer_ban: None,
issued_commitment: None,
sampled_deposits: HashMap::new(),
challenge_response: None,
}
}
fn caught_cheating(&self) -> bool {
self.issuer_ban.is_some()
}
fn produce_basic_cheating_evidence(&self) -> CheatingEvidence {
self.produce_cheating_evidence(Empty {})
}
fn produce_cheating_evidence<T>(&self, additional_context: T) -> CheatingEvidence<T> {
CheatingEvidence {
commitment: self.issued_commitment.clone(),
requested_challenge: self.sampled_deposits.keys().copied().collect(),
challenge_response: self.challenge_response.clone(),
inner: additional_context,
}
}
// SAFETY: we're using stable serialisation
#[allow(clippy::unwrap_used)]
fn set_banned_issuer<T>(&mut self, reason: impl Into<String>, evidence: CheatingEvidence<T>)
where
T: Serialize,
{
let reason = reason.into();
warn!(
"[CHEATING] banning {} for cheating because of: {reason}",
self.details
);
self.issuer_ban = Some(IssuerBan {
reason,
serialised_evidence: serde_json::to_vec(&evidence).unwrap(),
})
}
async fn get_issued_commitment(&mut self, expiration_date: Date) {
debug!(
"getting issued ticketbooks information of {}...",
self.details
);
let issued_ticketbooks = match self
.details
.api_client
.issued_ticketbooks_for(expiration_date)
.await
{
Ok(res) => res,
Err(err) => {
warn!("failed to obtain issued ticketbooks information from {}. it might be running an outdated api. the error was: {err}", self.details);
return;
}
};
// verify the signature on the response
if !issued_ticketbooks.verify_signature(&self.details.public_key) {
let evidence = self.produce_cheating_evidence(RegisteredPubKey {
registered_pub_key: self.details.public_key,
});
self.set_banned_issuer(
format!("bad signature on the issued ticketbooks for {expiration_date}"),
evidence,
);
return;
}
if expiration_date != issued_ticketbooks.body.expiration_date {
let evidence = self.produce_cheating_evidence(MismatchResponse {
requested: expiration_date,
received: issued_ticketbooks.body.expiration_date,
});
self.set_banned_issuer(
format!("bad ticketbook commitments for {expiration_date}"),
evidence,
);
return;
}
self.issued_commitment = Some(issued_ticketbooks)
}
async fn issue_deposit_challenge(&mut self, expiration_date: Date) {
// no point in continuing
if self.caught_cheating() {
return;
}
// nothing to challenge on
if self.sampled_deposits.is_empty() {
return;
}
let sampled = self.sampled_deposits.keys().copied().collect::<Vec<_>>();
let challenge_response = match self
.details
.api_client
.issued_ticketbooks_challenge(expiration_date, sampled.clone())
.await
{
Ok(res) => res,
Err(err) => {
// they can't fail to respond now. what if they received "unfavourable" deposit id?
// we have to assume they're cheating
let evidence = self.produce_cheating_evidence(GenericError {
error: err.to_string(),
});
self.set_banned_issuer(
format!("no response for issued ticketbook challenge for {expiration_date}"),
evidence,
);
return;
}
};
// verify the signature on the response
if !challenge_response.verify_signature(&self.details.public_key) {
let evidence = self.produce_cheating_evidence(RegisteredPubKey {
registered_pub_key: self.details.public_key,
});
self.set_banned_issuer(
format!("bad signature on the challenge response for {expiration_date}"),
evidence,
);
return;
}
if expiration_date != challenge_response.body.expiration_date {
let evidence = self.produce_cheating_evidence(MismatchResponse {
requested: expiration_date,
received: challenge_response.body.expiration_date,
});
self.set_banned_issuer(
format!("invalid deposits challenge response for {expiration_date}"),
evidence,
);
return;
}
self.challenge_response = Some(challenge_response)
}
fn verify_partial_ticketbook(
&self,
partial_ticketbook: &IssuedTicketbook,
) -> Result<(), PartialTicketbookVerificationFailure> {
let blinded_sig = match IssuedTicketbooksChallengeResponseBody::try_get_partial_credential(
partial_ticketbook,
) {
Ok(sig) => sig,
Err(err) => {
return Err(PartialTicketbookVerificationFailure::MalformedBlindedSignature(err))
}
};
let commitments =
match IssuedTicketbooksChallengeResponseBody::try_get_private_attributes_commitments(
partial_ticketbook,
) {
Ok(cms) => cms,
Err(err) => {
return Err(
PartialTicketbookVerificationFailure::MalformedPrivateAttributesCommitments(
err,
),
)
}
};
let public_attributes = [
date_scalar(partial_ticketbook.expiration_date.ecash_unix_timestamp()),
type_scalar(partial_ticketbook.ticketbook_type.encode()),
];
#[allow(clippy::map_identity)]
let attributes_refs = public_attributes.iter().collect::<Vec<_>>();
// actually do verify the credential now
if !verify_partial_blind_signature(
&commitments,
&attributes_refs,
&blinded_sig,
&self.details.verification_key,
) {
return Err(PartialTicketbookVerificationFailure::InvalidSignature);
}
Ok(())
}
fn verify_challenge_response(&mut self, expiration_date: Date) {
// no point in continuing
if self.caught_cheating() {
return;
}
let Some(issued) = &self.issued_commitment else {
return;
};
let Some(challenge) = &self.challenge_response else {
return;
};
let partial_ticketbooks = &challenge.body.partial_ticketbooks;
let merkle_proof = &challenge.body.merkle_proof;
// 1. check if the response actually contains all the requested deposits
for &deposit_id in self.sampled_deposits.keys() {
if !partial_ticketbooks.contains_key(&deposit_id) {
let evidence = self.produce_basic_cheating_evidence();
self.set_banned_issuer(
format!("requested deposit {deposit_id} is missing in challenge response"),
evidence,
);
return;
}
}
// 2. check if the provided merkle proof has the same number of deposits as initially committed to
if merkle_proof.total_leaves() != issued.body.deposits.len() {
let evidence = self.produce_cheating_evidence(MismatchClaim {
actual: merkle_proof.total_leaves(),
claimed: issued.body.deposits.len(),
});
self.set_banned_issuer("inconsistent number of merkle leaves", evidence);
return;
}
// 3. attempt to extract the merkle root
let merkle_root = match issued.body.merkle_root {
None => {
if !issued.body.deposits.is_empty() {
let evidence = self.produce_basic_cheating_evidence();
self.set_banned_issuer("unexpected empty merkle root", evidence);
return;
}
return;
}
Some(root) => root,
};
// 4. verify the actual proof
if !merkle_proof.verify(merkle_root) {
let evidence = self.produce_basic_cheating_evidence();
self.set_banned_issuer("invalid merkle proof", evidence);
return;
}
// 5. go through all requested partial ticketbooks and perform verification on them...
for (&deposit_id, partial_ticketbook) in partial_ticketbooks {
// 5.1 does the deposit id match?
if partial_ticketbook.deposit_id != deposit_id {
let evidence = self.produce_cheating_evidence(MismatchClaim {
actual: partial_ticketbook.deposit_id,
claimed: deposit_id,
});
self.set_banned_issuer("inconsistent partial ticketbook deposit id", evidence);
return;
}
// 5.2 does the expiration date match?
if partial_ticketbook.expiration_date != expiration_date {
let evidence = self.produce_cheating_evidence(MismatchClaim {
actual: partial_ticketbook.expiration_date,
claimed: expiration_date,
});
self.set_banned_issuer("inconsistent partial ticketbook expiration date", evidence);
return;
}
let recomputed_hash = partial_ticketbook.hash_to_merkle_leaf();
// SAFETY: we already checked every deposit is included in the response
#[allow(clippy::unwrap_used)]
let expected_index = self.sampled_deposits.get(&deposit_id).unwrap().merkle_index;
let expected_leaf = MerkleLeaf {
hash: recomputed_hash.to_vec(),
index: expected_index,
};
// 5.3 is this ticketbook actually included in the merkle proof?
if !merkle_proof.contains_full_leaf(&expected_leaf) {
let evidence = self.produce_cheating_evidence(expected_leaf);
self.set_banned_issuer("missing partial ticketbook merkle leaf", evidence);
return;
}
// 5.4 is that partial ticketbook actually cryptographically valid?
if let Err(verification_failure) = self.verify_partial_ticketbook(partial_ticketbook) {
let evidence = self.produce_cheating_evidence(GenericError {
error: verification_failure.to_string(),
});
self.set_banned_issuer("cryptographically malformed ticketbook", evidence);
return;
}
}
}
fn sample_deposits_for_challenge(&mut self, desired_amount: usize) {
// no point in continuing
if self.caught_cheating() {
return;
}
if let Some(issued) = &self.issued_commitment {
if desired_amount >= issued.body.deposits.len() {
self.sampled_deposits = issued
.body
.deposits
.iter()
.cloned()
.map(|d| (d.deposit_id, d))
.collect();
} else {
let mut rng = thread_rng();
self.sampled_deposits = issued
.body
.deposits
.choose_multiple(&mut rng, desired_amount)
.cloned()
.map(|d| (d.deposit_id, d))
.collect();
}
}
}
fn claimed_issued(&self) -> usize {
match &self.issued_commitment {
None => 0,
Some(res) => res.body.deposits.len(),
}
}
}
#[derive(Copy, Clone)]
pub struct VerificationConfig {
/// Defines the minimum number of ticketbooks the monitor will validate
/// regardless of the sampling rate
pub min_validate_per_issuer: usize,
/// The sampling rate of the issued ticketbooks
pub sampling_rate: f64,
/// Ratio of issuers that will undergo full verification as opposed to being let through.
pub full_verification_ratio: f64,
}
pub struct TicketbookIssuanceVerifier<'a> {
config: VerificationConfig,
whitelist: &'a [AccountId],
banned_addresses: Vec<String>,
expiration_date: Date,
made_deposits: HashSet<DepositId>,
}
impl<'a> TicketbookIssuanceVerifier<'a> {
pub fn new(
config: VerificationConfig,
whitelist: &'a [AccountId],
banned_addresses: Vec<String>,
expiration_date: Date,
) -> Self {
TicketbookIssuanceVerifier {
config,
whitelist,
banned_addresses,
expiration_date,
made_deposits: Default::default(),
}
}
fn is_banned(&self, issuer: &CredentialIssuer) -> bool {
self.banned_addresses
.contains(&issuer.operator_account.to_string())
}
fn to_prebanned(&self, issuer: &CredentialIssuer) -> OperatorIssuing {
let whitelisted = self.whitelist.contains(&issuer.operator_account);
OperatorIssuing {
api_runner: issuer.api_client.api_url().to_string(),
whitelisted,
pre_banned: true,
runner_account: issuer.operator_account.clone(),
issued_ratio: Default::default(),
skipped_verification: false,
subsample_size: 0,
issued_ticketbooks: 0,
issuer_ban: None,
}
}
fn to_result(&self, issuer: IssuerUnderTest) -> OperatorIssuing {
let whitelisted = self.whitelist.contains(&issuer.details.operator_account);
let total_deposits = self.made_deposits.len();
let issued_ratio = if total_deposits == 0 {
Decimal::zero()
} else {
Decimal::from_ratio(issuer.claimed_issued() as u32, total_deposits as u32)
};
OperatorIssuing {
api_runner: issuer.details.api_client.api_url().to_string(),
whitelisted,
issued_ratio,
issued_ticketbooks: issuer.claimed_issued() as u32,
skipped_verification: issuer.verification_skipped,
subsample_size: issuer.sampled_deposits.len() as u32,
runner_account: issuer.details.operator_account,
issuer_ban: issuer.issuer_ban,
pre_banned: false,
}
}
fn should_perform_full_verification(&self) -> bool {
let mut rng = thread_rng();
let choices = [true, false];
let weights = [
self.config.full_verification_ratio,
1. - self.config.full_verification_ratio,
];
#[allow(clippy::unwrap_used)]
let verify_dist = WeightedIndex::new(weights).unwrap();
let coin_toss_res = choices[verify_dist.sample(&mut rng)];
debug!(
"tossed a coin to see if the issuer should be fully verified, result: {coin_toss_res}"
);
coin_toss_res
}
#[instrument(
skip_all,
fields(
ticketbook_expiration = %self.expiration_date,
)
)]
pub async fn check_issuers(
&mut self,
issuers: Vec<CredentialIssuer>,
) -> Result<TicketbookIssuanceResults, NymRewarderError> {
info!("checking {} ticketbook issuers", issuers.len());
let mut issuers_being_tested = Vec::with_capacity(issuers.len());
let mut results = Vec::with_capacity(issuers.len());
// we could parallelize it, but we're running the test so infrequently (relatively speaking)
// that doing it sequentially is fine (probably...)
for issuer in issuers {
if self.is_banned(&issuer) {
info!("not testing {issuer} as it's already been banned");
results.push(self.to_prebanned(&issuer));
continue;
}
let mut being_tested = IssuerUnderTest::new(issuer);
// 1. try to obtain commitments for issued ticketbooks (merkle root + deposit ids)
being_tested
.get_issued_commitment(self.expiration_date)
.await;
issuers_being_tested.push(being_tested);
}
for issuer in issuers_being_tested.iter_mut() {
// 2. toss a coin to see if we have to go through the full verification procedure
if !self.should_perform_full_verification() {
issuer.verification_skipped = true;
continue;
}
// 3. sample deposits for the challenge (if applicable)
// we want to sample at least the minimum specified amount or the desired ratio of all issued
let desired_amount = max(
self.config.min_validate_per_issuer,
(issuer.claimed_issued() as f64 * self.config.sampling_rate) as usize,
);
issuer.sample_deposits_for_challenge(desired_amount);
// 4. issue the challenge to the issuer (if applicable)
issuer.issue_deposit_challenge(self.expiration_date).await;
// 5. verify the response (if applicable)
issuer.verify_challenge_response(self.expiration_date);
// if issuer produced valid results, try to update global deposit ids
if !issuer.caught_cheating() && issuer.claimed_issued() > 0 {
if let Some(commitment) = &issuer.issued_commitment {
for deposit in &commitment.body.deposits {
self.made_deposits.insert(deposit.deposit_id);
}
}
}
}
// 6. try to create summary of results produced
for issuer in issuers_being_tested {
results.push(self.to_result(issuer))
}
Ok(TicketbookIssuanceResults {
approximate_deposits: self.made_deposits.len() as u32,
api_runners: results,
})
}
}
+26 -3
View File
@@ -3085,6 +3085,7 @@ dependencies = [
"nym-network-defaults",
"nym-node-requests",
"nym-serde-helpers",
"nym-ticketbooks-merkle",
"schemars",
"serde",
"serde_json",
@@ -3383,6 +3384,19 @@ dependencies = [
"zeroize",
]
[[package]]
name = "nym-ticketbooks-merkle"
version = "0.1.0"
dependencies = [
"nym-credentials-interface",
"nym-serde-helpers",
"rs_merkle",
"schemars",
"serde",
"sha2 0.10.8",
"time",
]
[[package]]
name = "nym-types"
version = "1.0.0"
@@ -4357,7 +4371,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.8",
"regex-automata 0.4.9",
"regex-syntax 0.8.5",
]
@@ -4372,9 +4386,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.8"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
@@ -4537,6 +4551,15 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "rs_merkle"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b241d2e59b74ef9e98d94c78c47623d04c8392abaf82014dfd372a16041128f"
dependencies = [
"sha2 0.10.8",
]
[[package]]
name = "rustc-demangle"
version = "0.1.23"