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