From a348ff43b097384e6b1ec2a271a7728fadfd0632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 14 Nov 2024 16:55:02 +0000 Subject: [PATCH] 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 --- Cargo.lock | 35 +- Cargo.toml | 2 + .../validator-client/src/client.rs | 28 +- .../validator-client/src/nym_api/mod.rs | 56 +- .../validator-client/src/nym_api/routes.rs | 5 +- .../contract_traits/ecash_query_client.rs | 18 +- .../src/nyxd/cosmwasm_client/mod.rs | 2 +- .../validator-client/src/nyxd/mod.rs | 2 +- .../validator-client/src/rpc/mod.rs | 2 +- .../commands/src/ecash/issue_ticket_book.rs | 2 + .../ecash-contract/src/deposit.rs | 6 + .../ecash-contract/src/msg.rs | 7 +- common/ticketbooks-merkle/Cargo.toml | 26 + common/ticketbooks-merkle/src/lib.rs | 366 +++++++++++ contracts/ecash/schema/nym-ecash.json | 63 ++ contracts/ecash/schema/raw/query.json | 13 + .../raw/response_to_get_latest_deposit.json | 50 ++ contracts/ecash/src/contract/mod.rs | 23 +- contracts/ecash/src/deposit.rs | 32 + nym-api/Cargo.toml | 1 + .../20241029120000_issued_ticketbooks.sql | 28 + nym-api/nym-api-requests/Cargo.toml | 5 + nym-api/nym-api-requests/src/ecash/helpers.rs | 34 - nym-api/nym-api-requests/src/ecash/mod.rs | 6 +- nym-api/nym-api-requests/src/ecash/models.rs | 235 +++++-- nym-api/src/ecash/api_routes/helpers.rs | 26 - nym-api/src/ecash/api_routes/issued.rs | 142 ++-- nym-api/src/ecash/api_routes/mod.rs | 7 +- .../src/ecash/api_routes/partial_signing.rs | 2 +- nym-api/src/ecash/error.rs | 74 +-- nym-api/src/ecash/helpers.rs | 4 + nym-api/src/ecash/state/local.rs | 130 ++++ nym-api/src/ecash/state/mod.rs | 287 ++++++-- nym-api/src/ecash/storage/manager.rs | 409 ++++-------- nym-api/src/ecash/storage/mod.rs | 225 +++---- nym-api/src/ecash/storage/models.rs | 127 +--- nym-api/src/ecash/tests/issued_credentials.rs | 249 ------- nym-api/src/ecash/tests/issued_ticketbooks.rs | 172 +++++ nym-api/src/ecash/tests/mod.rs | 157 +++-- nym-api/src/main.rs | 1 - nym-api/src/node_status_api/models.rs | 11 +- nym-api/src/support/cli/run.rs | 6 +- nym-api/src/support/config/mod.rs | 7 + nym-api/src/support/http/openapi.rs | 15 +- nym-api/src/support/storage/mod.rs | 1 + .../m001_directory_services_v2_1.rs} | 2 +- .../m002_issued_credentials_commitments.rs | 2 + .../support/storage/runtime_migrations/mod.rs | 5 + nym-credential-proxy/Cargo.lock | 23 + nym-validator-rewarder/Cargo.toml | 8 +- .../04_ecash_credential_issuance_revamp.sql | 141 ++++ nym-validator-rewarder/src/cli/mod.rs | 28 +- nym-validator-rewarder/src/config/mod.rs | 163 +++-- nym-validator-rewarder/src/config/override.rs | 32 +- nym-validator-rewarder/src/config/template.rs | 41 +- nym-validator-rewarder/src/error.rs | 14 +- .../src/rewarder/block_signing/mod.rs | 6 + .../src/rewarder/block_signing/types.rs | 19 +- .../src/rewarder/credential_issuance/mod.rs | 69 -- .../rewarder/credential_issuance/monitor.rs | 382 ----------- .../src/rewarder/credential_issuance/types.rs | 352 ---------- nym-validator-rewarder/src/rewarder/epoch.rs | 15 + .../src/rewarder/helpers.rs | 17 - nym-validator-rewarder/src/rewarder/mod.rs | 481 +++++++++----- .../src/rewarder/nyxd_client.rs | 68 +- .../src/rewarder/storage/manager.rs | 400 ++++++------ .../src/rewarder/storage/mod.rs | 380 +++++------ .../rewarder/ticketbook_issuance/helpers.rs | 37 ++ .../src/rewarder/ticketbook_issuance/mod.rs | 63 ++ .../src/rewarder/ticketbook_issuance/types.rs | 93 +++ .../rewarder/ticketbook_issuance/verifier.rs | 610 ++++++++++++++++++ nym-wallet/Cargo.lock | 29 +- 72 files changed, 3768 insertions(+), 2811 deletions(-) create mode 100644 common/ticketbooks-merkle/Cargo.toml create mode 100644 common/ticketbooks-merkle/src/lib.rs create mode 100644 contracts/ecash/schema/raw/response_to_get_latest_deposit.json create mode 100644 nym-api/migrations/20241029120000_issued_ticketbooks.sql delete mode 100644 nym-api/nym-api-requests/src/ecash/helpers.rs delete mode 100644 nym-api/src/ecash/tests/issued_credentials.rs create mode 100644 nym-api/src/ecash/tests/issued_ticketbooks.rs rename nym-api/src/{v3_migration.rs => support/storage/runtime_migrations/m001_directory_services_v2_1.rs} (98%) create mode 100644 nym-api/src/support/storage/runtime_migrations/m002_issued_credentials_commitments.rs create mode 100644 nym-api/src/support/storage/runtime_migrations/mod.rs create mode 100644 nym-validator-rewarder/migrations/04_ecash_credential_issuance_revamp.sql delete mode 100644 nym-validator-rewarder/src/rewarder/credential_issuance/mod.rs delete mode 100644 nym-validator-rewarder/src/rewarder/credential_issuance/monitor.rs delete mode 100644 nym-validator-rewarder/src/rewarder/credential_issuance/types.rs create mode 100644 nym-validator-rewarder/src/rewarder/ticketbook_issuance/helpers.rs create mode 100644 nym-validator-rewarder/src/rewarder/ticketbook_issuance/mod.rs create mode 100644 nym-validator-rewarder/src/rewarder/ticketbook_issuance/types.rs create mode 100644 nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs diff --git a/Cargo.lock b/Cargo.lock index c8015cdf21..98af870502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 7a9c4d623d..4a40d97281 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 071fb597bf..0e8864edfe 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -1,6 +1,7 @@ // Copyright 2021-2023 - Nym Technologies SA // 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 { Ok(self.nym_api.master_verification_key(epoch_id).await?) } + + pub async fn issued_ticketbooks_for( + &self, + expiration_date: Date, + ) -> Result { + Ok(self.nym_api.issued_ticketbooks_for(expiration_date).await?) + } + + pub async fn issued_ticketbooks_challenge( + &self, + expiration_date: Date, + deposits: Vec, + ) -> Result { + Ok(self + .nym_api + .issued_ticketbooks_challenge(expiration_date, deposits) + .await?) + } } diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index dc85fe3f31..d23916bf5b 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -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, ], ¶ms, ) .await } - #[instrument(level = "debug", skip(self))] - async fn epoch_credentials( + async fn issued_ticketbooks_for( &self, - dkg_epoch: EpochId, - ) -> Result { + expiration_date: Date, + ) -> Result { 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 { - 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, - ) -> Result { + expiration_date: Date, + deposits: Vec, + ) -> Result { 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 diff --git a/common/client-libs/validator-client/src/nym_api/routes.rs b/common/client-libs/validator-client/src/nym_api/routes.rs index e0325c44cf..3553d2ed79 100644 --- a/common/client-libs/validator-client/src/nym_api/routes.rs +++ b/common/client-libs/validator-client/src/nym_api/routes.rs @@ -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"; diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs index 7c4c6c6b4e..2a799b5da4 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs @@ -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 { + self.query_ecash_contract(EcashQueryMsg::GetLatestDeposit {}) + .await + } + async fn get_deposits_paged( &self, start_after: Option, @@ -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(), }; } } diff --git a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs index af33e8795c..a0b65bfdd9 100644 --- a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs @@ -303,7 +303,7 @@ where feature = "tendermint-rpc-http-client", feature = "tendermint-rpc-websocket-client" ))] - async fn wait_until_healthy(&self, timeout: T) -> Result<(), Error> + async fn wait_until_healthy(&self, timeout: T) -> Result<(), TendermintRpcError> where T: Into + Send, { diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index 60fc03fb38..14a91a4459 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -823,7 +823,7 @@ where feature = "tendermint-rpc-http-client", feature = "tendermint-rpc-websocket-client" ))] - async fn wait_until_healthy(&self, timeout: T) -> Result<(), Error> + async fn wait_until_healthy(&self, timeout: T) -> Result<(), TendermintRpcError> where T: Into + Send, { diff --git a/common/client-libs/validator-client/src/rpc/mod.rs b/common/client-libs/validator-client/src/rpc/mod.rs index 8db7575606..e94fd5696d 100644 --- a/common/client-libs/validator-client/src/rpc/mod.rs +++ b/common/client-libs/validator-client/src/rpc/mod.rs @@ -523,7 +523,7 @@ mod non_wasm { ))] async fn wait_until_healthy(&self, timeout: T) -> Result<(), Error> where - T: Into + Send, + T: Into + Send, { self.wait_until_healthy(timeout).await } diff --git a/common/commands/src/ecash/issue_ticket_book.rs b/common/commands/src/ecash/issue_ticket_book.rs index 0097f4676a..71c1d87982 100644 --- a/common/commands/src/ecash/issue_ticket_book.rs +++ b/common/commands/src/ecash/issue_ticket_book.rs @@ -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 { diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs index 86efdbd5fa..6992e87a0c 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs @@ -47,6 +47,12 @@ impl Deposit { } } +#[cw_serde] +#[derive(Default)] +pub struct LatestDepositResponse { + pub deposit: Option, +} + #[cw_serde] pub struct DepositResponse { pub id: DepositId, diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs index 43bd4ca823..3341ae0afd 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs @@ -1,4 +1,4 @@ -// Copyright 2021 - Nym Technologies SA +// Copyright 2024 - Nym Technologies SA // 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, diff --git a/common/ticketbooks-merkle/Cargo.toml b/common/ticketbooks-merkle/Cargo.toml new file mode 100644 index 0000000000..5b6576da78 --- /dev/null +++ b/common/ticketbooks-merkle/Cargo.toml @@ -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 } \ No newline at end of file diff --git a/common/ticketbooks-merkle/src/lib.rs b/common/ticketbooks-merkle/src/lib.rs new file mode 100644 index 0000000000..f031a3dfc4 --- /dev/null +++ b/common/ticketbooks-merkle/src/lib.rs @@ -0,0 +1,366 @@ +// Copyright 2024 - Nym Technologies SA +// 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, + + // 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, + + #[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 { + 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, + 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, + pub index: usize, +} + +#[derive(Default, Clone)] +pub struct IssuedTicketbooksMerkleTree { + inner: MerkleTree, +} + +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> { + 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 { + 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, + + included_leaves: Vec, + + total_leaves: usize, + + #[schemars(with = "String")] + #[serde(with = "nym_serde_helpers::hex")] + root: Vec, +} + +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( + proof: &MerkleProof, + serializer: S, + ) -> Result { + let bytes = proof.serialize::(); + nym_serde_helpers::base64::serialize(&bytes, serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let bytes = nym_serde_helpers::base64::deserialize(deserializer)?; + MerkleProof::::deserialize::(&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); + } +} diff --git a/contracts/ecash/schema/nym-ecash.json b/contracts/ecash/schema/nym-ecash.json index ac73be5fb1..33fa704a08 100644 --- a/contracts/ecash/schema/nym-ecash.json +++ b/contracts/ecash/schema/nym-ecash.json @@ -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", diff --git a/contracts/ecash/schema/raw/query.json b/contracts/ecash/schema/raw/query.json index 646ddfa84f..f0a9ef71b1 100644 --- a/contracts/ecash/schema/raw/query.json +++ b/contracts/ecash/schema/raw/query.json @@ -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": [ diff --git a/contracts/ecash/schema/raw/response_to_get_latest_deposit.json b/contracts/ecash/schema/raw/response_to_get_latest_deposit.json new file mode 100644 index 0000000000..016a159498 --- /dev/null +++ b/contracts/ecash/schema/raw/response_to_get_latest_deposit.json @@ -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 + } + } +} diff --git a/contracts/ecash/src/contract/mod.rs b/contracts/ecash/src/contract/mod.rs index bb01ff9c71..e838751479 100644 --- a/contracts/ecash/src/contract/mod.rs +++ b/contracts/ecash/src/contract/mod.rs @@ -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 { + 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, diff --git a/contracts/ecash/src/deposit.rs b/contracts/ecash/src/deposit.rs index 310b4a0a26..355593ad19 100644 --- a/contracts/ecash/src/deposit.rs +++ b/contracts/ecash/src/deposit.rs @@ -19,6 +19,15 @@ impl<'a> DepositStorage<'a> { } } + pub fn latest_deposit( + &self, + storage: &dyn Storage, + ) -> Result, EcashContractError> { + self.deposit_id_counter + .may_load(storage) + .map_err(Into::into) + } + fn next_id(&self, store: &mut dyn Storage) -> Result { 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(); diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 246d8f60da..4ce3a27e34 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -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] diff --git a/nym-api/migrations/20241029120000_issued_ticketbooks.sql b/nym-api/migrations/20241029120000_issued_ticketbooks.sql new file mode 100644 index 0000000000..d13089f2ac --- /dev/null +++ b/nym-api/migrations/20241029120000_issued_ticketbooks.sql @@ -0,0 +1,28 @@ +/* + * Copyright 2024 - Nym Technologies SA + * 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); \ No newline at end of file diff --git a/nym-api/nym-api-requests/Cargo.toml b/nym-api/nym-api-requests/Cargo.toml index 84aed236ec..27e70e9361 100644 --- a/nym-api/nym-api-requests/Cargo.toml +++ b/nym-api/nym-api-requests/Cargo.toml @@ -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] diff --git a/nym-api/nym-api-requests/src/ecash/helpers.rs b/nym-api/nym-api-requests/src/ecash/helpers.rs deleted file mode 100644 index 936fced5dd..0000000000 --- a/nym-api/nym-api-requests/src/ecash/helpers.rs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// 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], - expiration_date: Date, - ticketbook_type: TicketType, -) -> Vec { - 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() -} diff --git a/nym-api/nym-api-requests/src/ecash/mod.rs b/nym-api/nym-api-requests/src/ecash/mod.rs index 60f3c1c41c..41333a9f58 100644 --- a/nym-api/nym-api-requests/src/ecash/mod.rs +++ b/nym-api/nym-api-requests/src/ecash/mod.rs @@ -1,11 +1,9 @@ // Copyright 2022 - Nym Technologies SA // 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, }; diff --git a/nym-api/nym-api-requests/src/ecash/models.rs b/nym-api/nym-api-requests/src/ecash/models.rs index 35d6df7a07..ca18f8c15b 100644 --- a/nym-api/nym-api-requests/src/ecash/models.rs +++ b/nym-api/nym-api-requests/src/ecash/models.rs @@ -1,18 +1,19 @@ // Copyright 2023-2024 - Nym Technologies SA // 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> { + pub fn encode_join_commitments(&self) -> Vec { 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, 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 { pub limit: Option, } -#[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, - - /// Pagination settings for retrieving credentials. Note: it can't be set alongside explicit ids. - pub pagination: Option>, -} - #[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, - 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, +pub struct IssuedTicketbooksForResponseBody { + #[schemars(with = "String")] + #[serde(with = "crate::helpers::date_serde")] + pub expiration_date: Date, + pub deposits: Vec, + pub merkle_root: Option<[u8; 32]>, +} + +impl IssuedTicketbooksForResponseBody { + pub fn plaintext(&self) -> Vec { + #[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, -} +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>, +pub struct IssuedTicketbooksChallengeRequest { + #[schemars(with = "String")] + #[serde(with = "crate::helpers::date_serde")] + pub expiration_date: Date, + pub deposits: Vec, +} +#[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, + 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 { - 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 { + #[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::from_bytes(&issued.blinded_partial_credential) + } + + pub fn try_get_private_attributes_commitments( + issued: &IssuedTicketbook, + ) -> Result, 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()); + } } diff --git a/nym-api/src/ecash/api_routes/helpers.rs b/nym-api/src/ecash/api_routes/helpers.rs index 6fba7d43dd..0e7afa9b39 100644 --- a/nym-api/src/ecash/api_routes/helpers.rs +++ b/nym-api/src/ecash/api_routes/helpers.rs @@ -1,32 +1,6 @@ // Copyright 2023 - Nym Technologies SA // 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, -) -> Result { - 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 { diff --git a/nym-api/src/ecash/api_routes/issued.rs b/nym-api/src/ecash/api_routes/issued.rs index 1593e5e9e1..023b57f6a3 100644 --- a/nym-api/src/ecash/api_routes/issued.rs +++ b/nym-api/src/ecash/api_routes/issued.rs @@ -1,156 +1,98 @@ // Copyright 2024 - Nym Technologies SA // 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) -> Router { 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, +async fn issued_ticketbooks_for( + Path(ExpirationDatePathParam { expiration_date }): Path, state: Arc, -) -> AxumResult> { +) -> AxumResult> { 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, - state: Arc, -) -> AxumResult> { - 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, +async fn issued_ticketbooks_challenge( + Json(challenge): Json, state: Arc, -) -> AxumResult> { +) -> AxumResult> { + 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()), + )) } diff --git a/nym-api/src/ecash/api_routes/mod.rs b/nym-api/src/ecash/api_routes/mod.rs index 6e1db9ffca..18bdf01d4e 100644 --- a/nym-api/src/ecash/api_routes/mod.rs +++ b/nym-api/src/ecash/api_routes/mod.rs @@ -1,14 +1,9 @@ // Copyright 2023-2024 - Nym Technologies SA // 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; diff --git a/nym-api/src/ecash/api_routes/partial_signing.rs b/nym-api/src/ecash/api_routes/partial_signing.rs index 70749b1454..7deb919d4e 100644 --- a/nym-api/src/ecash/api_routes/partial_signing.rs +++ b/nym-api/src/ecash/api_routes/partial_signing.rs @@ -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 diff --git a/nym-api/src/ecash/error.rs b/nym-api/src/ecash/error.rs index 3e17fcc6ac..7696234ea2 100644 --- a/nym-api/src/ecash/error.rs +++ b/nym-api/src/ecash/error.rs @@ -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 = std::result::Result; #[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 { diff --git a/nym-api/src/ecash/helpers.rs b/nym-api/src/ecash/helpers.rs index 677b9c355b..62b0955331 100644 --- a/nym-api/src/ecash/helpers.rs +++ b/nym-api/src/ecash/helpers.rs @@ -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); + // } } diff --git a/nym-api/src/ecash/state/local.rs b/nym-api/src/ecash/state/local.rs index b840bab394..cf09e050f6 100644 --- a/nym-api/src/ecash/state/local.rs +++ b/nym-api/src/ecash/state/local.rs @@ -1,17 +1,25 @@ // Copyright 2024 - Nym Technologies SA // 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, +} + +impl DailyMerkleTree { + pub(crate) fn new(initial_leaves: Vec) -> 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::>(); + + 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 { + 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 { + 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>, + + // merkle trees for ticketbooks issued for particular expiration dates + pub(crate) issued_merkle_trees: Arc>>, } 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() + } } diff --git a/nym-api/src/ecash/state/mod.rs b/nym-api/src/ecash/state/mod.rs index 11ed0d3ff7..b27280b6a6 100644 --- a/nym-api/src/ecash/state/mod.rs +++ b/nym-api/src/ecash/state/mod.rs @@ -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( + global_config: &Config, contract_address: AccountId, client: C, identity_keypair: identity::KeyPair, key_pair: KeyPair, comm_channel: D, storage: NymApiStorage, - signer_disabled: bool, ) -> Result 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> { - 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 { @@ -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 { - 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> { + 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>> { + 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 { + // 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 { + 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 { + 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( diff --git a/nym-api/src/ecash/storage/manager.rs b/nym-api/src/ecash/storage/manager.rs index 68d39a6a92..f3b227b5b0 100644 --- a/nym-api/src/ecash/storage/manager.rs +++ b/nym-api/src/ecash/storage/manager.rs @@ -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, 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, 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, sqlx::Error>; + ) -> Result>, 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, 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; + 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, - ) -> Result, 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, sqlx::Error>; + deposits: &[DepositId], + ) -> Result, sqlx::Error>; async fn insert_ticket_provider(&self, gateway_address: &str) -> Result; @@ -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, 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, 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, sqlx::Error> { - sqlx::query_as!( - IssuedTicketbook, + ) -> Result>, 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, 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 { - 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, - ) -> Result, sqlx::Error> { + deposits: &[DepositId], + ) -> Result, 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, 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 { let id = sqlx::query!( "INSERT INTO ticket_providers(gateway_address) VALUES (?)", diff --git a/nym-api/src/ecash/storage/mod.rs b/nym-api/src/ecash/storage/mod.rs index 5c15f2b2f6..019ab43a72 100644 --- a/nym-api/src/ecash/storage/mod.rs +++ b/nym-api/src/ecash/storage/mod.rs @@ -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, 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, NymApiStorageError>; - - async fn get_issued_bandwidth_credential_by_deposit_id( + async fn get_issued_partial_signature( &self, deposit_id: DepositId, - ) -> Result, NymApiStorageError>; + ) -> Result, NymApiStorageError>; + + async fn get_issued_hashes( + &self, + expiration_date: Date, + ) -> Result, 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>, + dkg_epoch_id: u32, + blinded_partial_credential: &[u8], + joined_private_commitments: &[u8], expiration_date: Date, ticketbook_type: TicketType, - ) -> Result; + merkle_leaf: MerkleLeaf, + ) -> Result<(), NymApiStorageError>; - async fn get_issued_credentials( + async fn remove_old_issued_ticketbooks( &self, - credential_ids: Vec, + cutoff_expiration_date: Date, + ) -> Result<(), NymApiStorageError>; + + async fn get_issued_ticketbooks( + &self, + deposits: Vec, ) -> Result, NymApiStorageError>; - async fn get_issued_credentials_paged( - &self, - pagination: Pagination, - ) -> Result, 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, 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, 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, NymApiStorageError> { - Ok(self + ) -> Result, 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, 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>, + dkg_epoch_id: u32, + blinded_partial_credential: &[u8], + joined_private_commitments: &[u8], expiration_date: Date, ticketbook_type: TicketType, - ) -> Result { + 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, - ) -> Result, NymApiStorageError> { - Ok(self.manager.get_issued_ticketbooks(credential_ids).await?) - } - - async fn get_issued_credentials_paged( - &self, - pagination: Pagination, - ) -> Result, 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, + ) -> Result, 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, diff --git a/nym-api/src/ecash/storage/models.rs b/nym-api/src/ecash/storage/models.rs index 6f150f238f..4c084ad707 100644 --- a/nym-api/src/ecash/storage/models.rs +++ b/nym-api/src/ecash/storage/models.rs @@ -1,44 +1,15 @@ // Copyright 2023 - Nym Technologies SA // 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 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, + pub dkg_epoch_id: u32, - /// signature on the issued credential (and the attributes) - pub signature: Vec, + pub blinded_partial_credential: Vec, - // i.e. "'attr1','attr2',..." pub joined_private_commitments: Vec, 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, + + /// index of the leaf under which the data has been inserted + pub merkle_index: u32, +} + +impl TryFrom for IssuedTicketbook { + type Error = NymApiStorageError; + fn try_from(raw: RawIssuedTicketbook) -> Result { + 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 for ApiIssuedCredentialInner { - type Error = EcashError; - - fn try_from(value: IssuedTicketbook) -> Result { - 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 for BlindedSignatureResponse { - type Error = EcashError; - - fn try_from(value: IssuedTicketbook) -> Result { - Ok(BlindedSignatureResponse { - blinded_signature: BlindedSignature::from_bytes(&value.partial_credential)?, - }) - } -} - -impl TryFrom for BlindedSignature { - type Error = EcashError; - - fn try_from(value: IssuedTicketbook) -> Result { - Ok(BlindedSignature::from_bytes(&value.partial_credential)?) - } -} - -pub(crate) fn join_attributes(attrs: Vec>) -> Vec { - // 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) -> Vec> { - assert_eq!(attrs.len() % 48, 0, "database corruption"); - attrs.chunks_exact(48).map(|c| c.to_vec()).collect() -} diff --git a/nym-api/src/ecash/tests/issued_credentials.rs b/nym-api/src/ecash/tests/issued_credentials.rs deleted file mode 100644 index 00dac0d525..0000000000 --- a/nym-api/src/ecash/tests/issued_credentials.rs +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright 2023-2024 - Nym Technologies SA -// 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); -} diff --git a/nym-api/src/ecash/tests/issued_ticketbooks.rs b/nym-api/src/ecash/tests/issued_ticketbooks.rs new file mode 100644 index 0000000000..f9c18a9fd0 --- /dev/null +++ b/nym-api/src/ecash/tests/issued_ticketbooks.rs @@ -0,0 +1,172 @@ +// Copyright 2023-2024 - Nym Technologies SA +// 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 + ); +} diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index 123c0a7930..1fc39706b9 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -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, @@ -1242,6 +1244,7 @@ pub fn voucher_fixture(deposit_id: Option) -> 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 { + 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, + ) -> 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, + ) -> 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(); diff --git a/nym-api/src/main.rs b/nym-api/src/main.rs index 4ee4205ea2..28cf5cd48e 100644 --- a/nym-api/src/main.rs +++ b/nym-api/src/main.rs @@ -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] diff --git a/nym-api/src/node_status_api/models.rs b/nym-api/src/node_status_api/models.rs index bce5d6eea6..756fe37140 100644 --- a/nym-api/src/node_status_api/models.rs +++ b/nym-api/src/node_status_api/models.rs @@ -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 }, + // 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 for NymApiStorageError { impl NymApiStorageError { pub fn database_inconsistency>(reason: S) -> NymApiStorageError { - NymApiStorageError::DatabaseInconsistency { - reason: reason.into(), - } + let reason = reason.into(); + error!("experienced database inconsistency: {reason}"); + + NymApiStorageError::DatabaseInconsistency { reason } } } diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index 4b1f2f0a75..286757fbef 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -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 anyhow::Result Self { EcashSignerDebug { dkg_contract_polling_rate: DEFAULT_DKG_CONTRACT_POLLING_RATE, + issued_ticketbooks_retention_period_days: DEFAULT_MAX_ISSUED_TICKETBOOKS_RETENTION_DAYS, } } } diff --git a/nym-api/src/support/http/openapi.rs b/nym-api/src/support/http/openapi.rs index e4bfff7b09..9c5783cd1b 100644 --- a/nym-api/src/support/http/openapi.rs +++ b/nym-api/src/support/http/openapi.rs @@ -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, diff --git a/nym-api/src/support/storage/mod.rs b/nym-api/src/support/storage/mod.rs index 8f1b495ce1..9c1a6e4372 100644 --- a/nym-api/src/support/storage/mod.rs +++ b/nym-api/src/support/storage/mod.rs @@ -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)] diff --git a/nym-api/src/v3_migration.rs b/nym-api/src/support/storage/runtime_migrations/m001_directory_services_v2_1.rs similarity index 98% rename from nym-api/src/v3_migration.rs rename to nym-api/src/support/storage/runtime_migrations/m001_directory_services_v2_1.rs index 42401ad6fd..5cefd3fdf3 100644 --- a/nym-api/src/v3_migration.rs +++ b/nym-api/src/support/storage/runtime_migrations/m001_directory_services_v2_1.rs @@ -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<()> { diff --git a/nym-api/src/support/storage/runtime_migrations/m002_issued_credentials_commitments.rs b/nym-api/src/support/storage/runtime_migrations/m002_issued_credentials_commitments.rs new file mode 100644 index 0000000000..755fb6cc8b --- /dev/null +++ b/nym-api/src/support/storage/runtime_migrations/m002_issued_credentials_commitments.rs @@ -0,0 +1,2 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 diff --git a/nym-api/src/support/storage/runtime_migrations/mod.rs b/nym-api/src/support/storage/runtime_migrations/mod.rs new file mode 100644 index 0000000000..e147e2a030 --- /dev/null +++ b/nym-api/src/support/storage/runtime_migrations/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod m001_directory_services_v2_1; +pub mod m002_issued_credentials_commitments; diff --git a/nym-credential-proxy/Cargo.lock b/nym-credential-proxy/Cargo.lock index 50e39249dc..7f63bc1496 100644 --- a/nym-credential-proxy/Cargo.lock +++ b/nym-credential-proxy/Cargo.lock @@ -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" diff --git a/nym-validator-rewarder/Cargo.toml b/nym-validator-rewarder/Cargo.toml index ab722f9459..a70f19bf09 100644 --- a/nym-validator-rewarder/Cargo.toml +++ b/nym-validator-rewarder/Cargo.toml @@ -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"] } diff --git a/nym-validator-rewarder/migrations/04_ecash_credential_issuance_revamp.sql b/nym-validator-rewarder/migrations/04_ecash_credential_issuance_revamp.sql new file mode 100644 index 0000000000..fa2454c505 --- /dev/null +++ b/nym-validator-rewarder/migrations/04_ecash_credential_issuance_revamp.sql @@ -0,0 +1,141 @@ +/* + * Copyright 2024 - Nym Technologies SA + * 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 +) \ No newline at end of file diff --git a/nym-validator-rewarder/src/cli/mod.rs b/nym-validator-rewarder/src/cli/mod.rs index 239328ed8a..87c0de5c5a 100644 --- a/nym-validator-rewarder/src/cli/mod.rs +++ b/nym-validator-rewarder/src/cli/mod.rs @@ -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, - - #[clap(long, env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_MONITOR_MIN_VALIDATION")] - pub credential_monitor_min_validation: Option, - - #[clap(long, env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_MONITOR_SAMPLING_RATE")] - pub credential_monitor_sampling_rate: Option, + pub disable_ticketbook_issuance_rewarding: bool, #[clap(long, env = "NYM_VALIDATOR_REWARDER_SCRAPER_ENDPOINT")] pub scraper_endpoint: Option, @@ -86,17 +80,11 @@ pub struct ConfigOverridableArgs { #[clap(long, env = "NYM_VALIDATOR_REWARDER_EPOCH_DURATION")] pub epoch_duration: Option, - #[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, - #[clap(long, env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_ISSUANCE_REWARD_RATIO")] - pub credential_issuance_reward_ratio: Option, - - #[clap( - long, - env = "NYM_VALIDATOR_REWARDER_CREDENTIAL_VERIFICATION_REWARD_RATIO" - )] - pub credential_verification_reward_ratio: Option, + #[clap(long, env = "NYM_VALIDATOR_REWARDER_TICKETBOOK_ISSUANCE_REWARD_RATIO")] + pub ticketbook_issuance_reward_ratio: Option, } #[derive(Subcommand, Debug)] diff --git a/nym-validator-rewarder/src/config/mod.rs b/nym-validator-rewarder/src/config/mod.rs index f0a45069a1..737682cf54 100644 --- a/nym-validator-rewarder/src/config/mod.rs +++ b/nym-validator-rewarder/src/config/mod.rs @@ -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>(&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, } -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![], } } diff --git a/nym-validator-rewarder/src/config/override.rs b/nym-validator-rewarder/src/config/override.rs index f16c6555fb..38d4574be2 100644 --- a/nym-validator-rewarder/src/config/override.rs +++ b/nym-validator-rewarder/src/config/override.rs @@ -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; } } } diff --git a/nym-validator-rewarder/src/config/template.rs b/nym-validator-rewarder/src/config/template.rs index 841e5e2536..152118f198 100644 --- a/nym-validator-rewarder/src/config/template.rs +++ b/nym-validator-rewarder/src/config/template.rs @@ -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 ] diff --git a/nym-validator-rewarder/src/error.rs b/nym-validator-rewarder/src/error.rs index 5e9de91cfb..317e9ca2a2 100644 --- a/nym-validator-rewarder/src/error.rs +++ b/nym-validator-rewarder/src/error.rs @@ -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), #[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, } diff --git a/nym-validator-rewarder/src/rewarder/block_signing/mod.rs b/nym-validator-rewarder/src/rewarder/block_signing/mod.rs index 27f423c089..19b149ce25 100644 --- a/nym-validator-rewarder/src/rewarder/block_signing/mod.rs +++ b/nym-validator-rewarder/src/rewarder/block_signing/mod.rs @@ -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, diff --git a/nym-validator-rewarder/src/rewarder/block_signing/types.rs b/nym-validator-rewarder/src/rewarder/block_signing/types.rs index ebdb24432f..a45f6cdc07 100644 --- a/nym-validator-rewarder/src/rewarder/block_signing/types.rs +++ b/nym-validator-rewarder/src/rewarder/block_signing/types.rs @@ -138,18 +138,19 @@ impl EpochSigningResults { } pub fn rewarding_amounts(&self, budget: &Coin) -> Vec<(AccountId, Vec)> { - 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 } } diff --git a/nym-validator-rewarder/src/rewarder/credential_issuance/mod.rs b/nym-validator-rewarder/src/rewarder/credential_issuance/mod.rs deleted file mode 100644 index 3bd0c6b75b..0000000000 --- a/nym-validator-rewarder/src/rewarder/credential_issuance/mod.rs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// 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 { - 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 { - 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()) - } -} diff --git a/nym-validator-rewarder/src/rewarder/credential_issuance/monitor.rs b/nym-validator-rewarder/src/rewarder/credential_issuance/monitor.rs deleted file mode 100644 index 41bb39a6f8..0000000000 --- a/nym-validator-rewarder/src/rewarder/credential_issuance/monitor.rs +++ /dev/null @@ -1,382 +0,0 @@ -// Copyright 2023-2024 - Nym Technologies SA -// 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 { - 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 { - 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::>() - }; - - 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 { - 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::>(); - - 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(()) - } -} diff --git a/nym-validator-rewarder/src/rewarder/credential_issuance/types.rs b/nym-validator-rewarder/src/rewarder/credential_issuance/types.rs deleted file mode 100644 index dd39df1cd5..0000000000 --- a/nym-validator-rewarder/src/rewarder/credential_issuance/types.rs +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// 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>, -} - -impl MonitoringResults { - pub(crate) async fn new_initial( - initial_epoch: Epoch, - nyxd_client: &NyxdClient, - whitelist: &[AccountId], - ) -> Result { - 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) { - 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, - - // map from operator's account to their results - pub(crate) operators: HashMap, -} - -impl From 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, - ) -> 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, -} - -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, -} - -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, - 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, - pub api_runners: Vec, -} - -impl CredentialIssuanceResults { - pub fn rewarding_amounts(&self, budget: &Coin) -> Vec<(AccountId, Vec)> { - 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() -} diff --git a/nym-validator-rewarder/src/rewarder/epoch.rs b/nym-validator-rewarder/src/rewarder/epoch.rs index c164a1735f..7acc4977e9 100644 --- a/nym-validator-rewarder/src/rewarder/epoch.rs +++ b/nym-validator-rewarder/src/rewarder/epoch.rs @@ -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 { diff --git a/nym-validator-rewarder/src/rewarder/helpers.rs b/nym-validator-rewarder/src/rewarder/helpers.rs index f49d96e5fb..578d49c498 100644 --- a/nym-validator-rewarder/src/rewarder/helpers.rs +++ b/nym-validator-rewarder/src/rewarder/helpers.rs @@ -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 { - 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)) -} diff --git a/nym-validator-rewarder/src/rewarder/mod.rs b/nym-validator-rewarder/src/rewarder/mod.rs index 514262c53b..b423c03dea 100644 --- a/nym-validator-rewarder/src/rewarder/mod.rs +++ b/nym-validator-rewarder/src/rewarder/mod.rs @@ -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, } -pub struct EpochRewards { +pub struct ExtractedRewardingResults { + pub rewarding_tx: Option, + pub total_spent: Coin, + pub rewarding_err: Option, + pub monitor_only: bool, +} + +pub fn extract_rewarding_results( + results: Result, + 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, NymRewarderError>, - pub credentials: Result, NymRewarderError>, - - pub total_budget: Coin, - pub signing_budget: Coin, - pub credentials_budget: Coin, + pub results: Option>, + pub budget: Coin, } -impl EpochRewards { - pub fn amounts(&self) -> Result)>, NymRewarderError> { +impl BlockSigningDetails { + pub fn rewarding_amounts(&self) -> Result)>, 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>, + pub total_budget: Coin, + pub whitelist_size: usize, + pub per_operator_budget: Coin, +} + +impl TicketbookIssuanceDetails { + pub fn rewarding_amounts(&self) -> Result)>, 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)], 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, - credential_issuance: Option, + ticketbook_issuance: Option, } impl Rewarder { pub async fn new(config: Config) -> Result { + // 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, 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, 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)>, - ) -> Result { + ) -> Result, 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 { - 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)>, + ) -> Result, 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 { + 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 { + 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 { + 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(()) } diff --git a/nym-validator-rewarder/src/rewarder/nyxd_client.rs b/nym-validator-rewarder/src/rewarder/nyxd_client.rs index a75aaa0929..a3a7ad6e17 100644 --- a/nym-validator-rewarder/src/rewarder/nyxd_client.rs +++ b/nym-validator-rewarder/src/rewarder/nyxd_client.rs @@ -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 + Send + 'static, amounts: Vec<(AccountId, Vec)>, ) -> Result { 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, 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 { - let res = self.inner.read().await.get_deposit(deposit_id).await?; - res.deposit - .ok_or(NymRewarderError::DepositNotFound { deposit_id }) - } } diff --git a/nym-validator-rewarder/src/rewarder/storage/manager.rs b/nym-validator-rewarder/src/rewarder/storage/manager.rs index 3efa3d9308..9c3218ac73 100644 --- a/nym-validator-rewarder/src/rewarder/storage/manager.rs +++ b/nym-validator-rewarder/src/rewarder/storage/manager.rs @@ -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, sqlx::Error> { + pub(crate) async fn load_last_block_signing_rewarding_epoch( + &self, + ) -> Result, 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, 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, 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, - rewarding_error: Option, + 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, + rewarding_error: Option, + 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, + rewarding_error: Option, + 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, + ) -> 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, - 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, 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, - 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, - 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(()) - } } diff --git a/nym-validator-rewarder/src/rewarder/storage/mod.rs b/nym-validator-rewarder/src/rewarder/storage/mod.rs index 9557a3708d..1d58da9b11 100644 --- a/nym-validator-rewarder/src/rewarder/storage/mod.rs +++ b/nym-validator-rewarder/src/rewarder/storage/mod.rs @@ -1,19 +1,15 @@ -// Copyright 2023 - Nym Technologies SA +// Copyright 2023-2024 - Nym Technologies SA // 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, 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, 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, NymRewarderError> { - Ok(self - .manager - .get_deposit_credential_id(operator_identity_bs58, deposit_id) - .await?) + ) -> Result, 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, - // total_spent: Coin, - // rewarding_tx: Result, ) -> 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, + ) -> 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(()) + } } diff --git a/nym-validator-rewarder/src/rewarder/ticketbook_issuance/helpers.rs b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/helpers.rs new file mode 100644 index 0000000000..e893ac6606 --- /dev/null +++ b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/helpers.rs @@ -0,0 +1,37 @@ +// Copyright 2024 - Nym Technologies SA +// 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), + ) +} diff --git a/nym-validator-rewarder/src/rewarder/ticketbook_issuance/mod.rs b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/mod.rs new file mode 100644 index 0000000000..b8abc174f4 --- /dev/null +++ b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/mod.rs @@ -0,0 +1,63 @@ +// Copyright 2023-2024 - Nym Technologies SA +// 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, +} + +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 { + 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 + } +} diff --git a/nym-validator-rewarder/src/rewarder/ticketbook_issuance/types.rs b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/types.rs new file mode 100644 index 0000000000..6dc48db5dc --- /dev/null +++ b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/types.rs @@ -0,0 +1,93 @@ +// Copyright 2023 - Nym Technologies SA +// 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, +} + +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, +} + +impl TicketbookIssuanceResults { + pub fn rewarding_amounts(&self, per_operator_budget: &Coin) -> Vec<(AccountId, Vec)> { + 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() +} diff --git a/nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs new file mode 100644 index 0000000000..89a01087ed --- /dev/null +++ b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs @@ -0,0 +1,610 @@ +// Copyright 2024 - Nym Technologies SA +// 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 { + requested: T, + received: T, +} + +#[derive(Serialize, Deserialize)] +pub struct MismatchClaim { + claimed: T, + actual: T, +} + +#[derive(Serialize, Deserialize)] +pub struct GenericError { + error: String, +} + +pub struct IssuerBan { + pub reason: String, + pub serialised_evidence: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct CheatingEvidence { + commitment: Option, + requested_challenge: Vec, + challenge_response: Option, + + #[serde(flatten)] + inner: T, +} + +pub struct IssuerUnderTest { + details: CredentialIssuer, + verification_skipped: bool, + issuer_ban: Option, + issued_commitment: Option, + sampled_deposits: HashMap, + challenge_response: Option, +} + +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(&self, additional_context: T) -> CheatingEvidence { + 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(&mut self, reason: impl Into, evidence: CheatingEvidence) + 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::>(); + + 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::>(); + + // 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, + expiration_date: Date, + made_deposits: HashSet, +} + +impl<'a> TicketbookIssuanceVerifier<'a> { + pub fn new( + config: VerificationConfig, + whitelist: &'a [AccountId], + banned_addresses: Vec, + 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, + ) -> Result { + 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, + }) + } +} diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 22bdc7adeb..f41ae89117 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -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"