diff --git a/Cargo.lock b/Cargo.lock index ca08ebe21f..f0f3f85101 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7580,7 +7580,7 @@ dependencies = [ [[package]] name = "nym-node-status-api" -version = "4.6.0" +version = "4.6.1" dependencies = [ "ammonia", "anyhow", diff --git a/nym-gateway-probe/src/common/bandwidth_helpers.rs b/nym-gateway-probe/src/common/bandwidth_helpers.rs index 10962a1eb6..5f968b8d92 100644 --- a/nym-gateway-probe/src/common/bandwidth_helpers.rs +++ b/nym-gateway-probe/src/common/bandwidth_helpers.rs @@ -86,10 +86,21 @@ pub(crate) async fn import_bandwidth( // 2. import actual tickets for ticket in attached_ticket_materials.attached_tickets { let ticketbook = ticket.ticketbook.try_unpack()?; + let total = ticketbook.params_total_tickets(); + if ticket.usable_index as u64 >= total { + error!( + "⚠️ received usable_index {} >= params_total_tickets {} for {}. \ + This ticket is unusable: spending will fail with SpendExceedsAllowance.", + ticket.usable_index, + total, + ticketbook.ticketbook_type() + ); + } info!( - "importing partial ticketbook {}. index to use: {}", + "importing partial ticketbook {}. index to use: {}, params_total_tickets: {}", ticketbook.ticketbook_type(), - ticket.usable_index + ticket.usable_index, + total, ); bandwidth_importer .import_partial_ticketbook(&ticketbook, ticket.usable_index, ticket.usable_index) diff --git a/nym-node-status-api/nym-node-status-api/Cargo.toml b/nym-node-status-api/nym-node-status-api/Cargo.toml index 64db4e1f4c..729404d8b6 100644 --- a/nym-node-status-api/nym-node-status-api/Cargo.toml +++ b/nym-node-status-api/nym-node-status-api/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node-status-api" -version = "4.6.0" +version = "4.6.1" authors.workspace = true edition.workspace = true license.workspace = true diff --git a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs index bf4ad1e161..e1e507b272 100644 --- a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs +++ b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs @@ -68,8 +68,8 @@ impl TicketbookManagerState { for typ in &self.buffered_ticket_types { debug!("attempting to get materials for ticket of type {typ}"); if let Some(ticket) = self.storage.next_ticket(*typ, testrun_id).await? { - let epoch_id = ticket.ticketbook.epoch_id(); - let expiration_date = ticket.ticketbook.expiration_date(); + let epoch_id = ticket.epoch_id(); + let expiration_date = ticket.expiration_date(); debug!( "retrieved ticket corresponds to epoch {epoch_id} and expiration date {expiration_date}" diff --git a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs index 904bb7eb57..a40611ea08 100644 --- a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs +++ b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs @@ -1,25 +1,50 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use anyhow::bail; use nym_credentials::IssuedTicketBook; use nym_credentials::ecash::bandwidth::serialiser::VersionedSerialise; use nym_gateway_probe::types::AttachedTicket; +use nym_validator_client::nym_api::EpochId; use sqlx::FromRow; use time::Date; use zeroize::{Zeroize, ZeroizeOnDrop}; -pub struct RetrievedTicketbook { - pub ticketbook_id: i32, - pub total_tickets: u32, - pub spent_tickets: u32, - pub ticketbook: IssuedTicketBook, +pub(crate) struct RetrievedTicketbook { + usable_index: u32, + ticketbook: IssuedTicketBook, +} + +impl RetrievedTicketbook { + pub fn new(ticketbook: IssuedTicketBook) -> anyhow::Result { + let usable_index = ticketbook.spent_tickets() as u32 - 1; + // spent_tickets is the post-increment number from the DB: the ticket we're + // handing out has already been counted as "used" in the DB, but has NOT YET + // been spent yet by the recipient. To get its 0-based index in the ticketbook, + // subtract 1 (e.g. spent_tickets=1, the ticket at index 0). + if usable_index < 1 { + bail!("Malformed ticket: cannot convert from ticket with spent_tickets=0"); + } + Ok(Self { + usable_index, + ticketbook, + }) + } + + pub fn epoch_id(&self) -> EpochId { + self.ticketbook.epoch_id() + } + + pub fn expiration_date(&self) -> time::Date { + self.ticketbook.expiration_date() + } } impl From for AttachedTicket { fn from(retrieved: RetrievedTicketbook) -> Self { AttachedTicket { ticketbook: retrieved.ticketbook.pack(), - usable_index: retrieved.spent_tickets, + usable_index: retrieved.usable_index, } } } diff --git a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/mod.rs b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/mod.rs index e8f2091448..f2aebcfafd 100644 --- a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/mod.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::db::Storage; -use crate::ticketbook_manager::storage::auxiliary_models::RetrievedTicketbook; -use anyhow::{Context, anyhow}; +use anyhow::{Context, anyhow, bail}; +use auxiliary_models::RetrievedTicketbook; use nym_credential_proxy_lib::error::CredentialProxyError; use nym_credential_proxy_lib::shared_state::ecash_state::{ IssuanceTicketBook, IssuedTicketBook, TicketType, @@ -16,6 +16,7 @@ use nym_crypto::aes::cipher::zeroize::Zeroizing; use nym_ecash_time::{EcashTime, ecash_today}; use nym_validator_client::nym_api::EpochId; use time::Date; +use tracing::warn; pub(crate) mod auxiliary_models; @@ -85,7 +86,7 @@ impl TicketbookManagerStorage { /// Tries to retrieve one of the stored ticketbook that has not yet expired /// it immediately updated the on-disk number of used tickets so that another task /// could obtain their own tickets at the same time - pub(crate) async fn next_ticket( + pub(super) async fn next_ticket( &self, ticket_type: TicketType, testrun_id: i32, @@ -113,16 +114,33 @@ impl TicketbookManagerStorage { .set_distributed_ticketbook(testrun_id, raw.id, raw.used_tickets) .await?; - deserialised.update_spent_tickets(raw.used_tickets as u64); - Ok(Some(RetrievedTicketbook { - ticketbook_id: raw.id, - total_tickets: raw - .total_tickets - .try_into() - .context("failed to convert i32 total tickets into u32")?, - spent_tickets: deserialised.spent_tickets() as u32, - ticketbook: deserialised, - })) + deserialised.update_spent_tickets((raw.used_tickets) as u64); + + let total = deserialised.params_total_tickets(); + let spent = raw.used_tickets as u64; + + if spent > total { + // should never happen: implies a bug in DB fetching + bail!( + "testrun_id={testrun_id}, ticketbook_id={}, ticket_type={ticket_type}, \ + marked as used_tickets = {spent} > params_total_tickets = {total}. \ + Cannot have spent more than is in the ticketbook", + raw.id, + ); + } else { + tracing::debug!( + "testrun_id={testrun_id}, ticketbook_id={}, ticket_type={ticket_type}, \ + db_used_tickets={} / total_tickets={total}", + raw.id, + raw.used_tickets, + ); + } + + let retrieved_ticketbook = RetrievedTicketbook::new(deserialised) + .inspect_err(|e| warn!("Failed to convert retrieved ticketbook: {e}")) + .ok(); + + Ok(retrieved_ticketbook) } }