Fix invalid ticket spend (#6683)

* Fix

* PR feedback

* Bump version

* Update sqlx files
This commit is contained in:
dynco-nym
2026-04-17 16:49:17 +02:00
committed by GitHub
parent 62962509eb
commit 7140ba4ea9
6 changed files with 79 additions and 25 deletions
@@ -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
@@ -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}"
@@ -1,25 +1,50 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Self> {
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<RetrievedTicketbook> for AttachedTicket {
fn from(retrieved: RetrievedTicketbook) -> Self {
AttachedTicket {
ticketbook: retrieved.ticketbook.pack(),
usable_index: retrieved.spent_tickets,
usable_index: retrieved.usable_index,
}
}
}
@@ -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)
}
}