Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bin/ntx-builder/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ mod tests {

use super::*;

const EXPECTED_SCHEMA_HASHES: [SchemaHash; 4] = [
const EXPECTED_SCHEMA_HASHES: [SchemaHash; 5] = [
SchemaHash::from_hex("c631b773787903a3dd5ea4df5e7374119b3f02b35bacf14d11eacd8d8500e3d9"),
SchemaHash::from_hex("26b17298444f674b06327ae7289516fe75b59926741b1221ebf36735822d116a"),
SchemaHash::from_hex("6f27c48c71d173366c90752c330bf888332923e68a290ac3acdb5861539120e8"),
SchemaHash::from_hex("638b3991fe1b025ab8820e5cfc902d82d212a6bd3eb26d29af3959de1487ea5c"),
SchemaHash::from_hex("95f93ed0b68d3719a7ea450376e96af1772f7947ddc82fb0a90dac80e049525c"),
];

#[test]
Expand Down
14 changes: 14 additions & 0 deletions bin/ntx-builder/src/db/migrations/005_sponsorship_status.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Index every FEE_SPONSORSHIP note by its feature note, including consumed notes. The note status
-- endpoint reports consumed sponsorships too, and the partial index does not cover them. The
-- selection-time join (`feature_note_id = ? AND committed_at IS NULL`) also uses this index.
DROP INDEX idx_sponsorship_notes_feature;
CREATE INDEX idx_sponsorship_notes_feature ON sponsorship_notes(feature_note_id);

-- Latest failure of the FEE_SPONSORSHIP note itself, for status reporting. The feature note keeps
-- the attempt count and the backoff.
--
-- Block number of the last failed attempt. NULL if the sponsorship never failed.
ALTER TABLE sponsorship_notes ADD COLUMN last_attempt BIGINT
CONSTRAINT sponsorship_notes_last_attempt_is_u32 CHECK (last_attempt BETWEEN 0 AND 0xFFFFFFFF);
-- Latest execution error message. NULL if no error recorded.
ALTER TABLE sponsorship_notes ADD COLUMN last_error TEXT;
12 changes: 12 additions & 0 deletions bin/ntx-builder/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,18 @@ impl NtxDbWriter {
.await
}

pub(crate) async fn sponsorships_failed(
&self,
failed_sponsorships: Vec<(Nullifier, NoteError)>,
block_num: BlockNumber,
) -> Result<(), DatabaseError> {
self.writer
.write("sponsorships_failed", move |tx| {
queries::sponsorships_failed(tx, &failed_sponsorships, block_num)
})
.await
}

pub(crate) async fn discard_notes(
&self,
nullifiers: Vec<Nullifier>,
Expand Down
18 changes: 15 additions & 3 deletions bin/ntx-builder/src/db/queries/get_note_status/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use miden_node_db::DatabaseError;
use miden_node_db::sqlite::ReadTx;
use miden_protocol::note::NoteId;

use crate::db::queries::{NoteSponsorshipRow, select_note_sponsorships};

const SQL: &str = include_str!("get_note_status.sql");

/// Row returned by [`get_note_status`].
Expand All @@ -13,22 +15,32 @@ pub struct NoteStatusRow {
pub attempt_count: i64,
pub last_attempt: Option<i64>,
pub committed_at: Option<i64>,
/// `FEE_SPONSORSHIP` notes bound to the note.
pub sponsorships: Vec<NoteSponsorshipRow>,
}

/// Returns the status for a note identified by its note ID.
/// Returns the status for a note identified by its note ID, together with the `FEE_SPONSORSHIP`
/// notes bound to it.
pub fn get_note_status(
tx: &ReadTx<'_>,
note_id: NoteId,
) -> Result<Option<NoteStatusRow>, DatabaseError> {
Ok(tx
let Some(mut status) = tx
.query(SQL, &[&note_id], |row| {
Ok(NoteStatusRow {
last_error: row.get::<Option<String>>(0)?,
attempt_count: row.get::<i64>(1)?,
last_attempt: row.get::<Option<i64>>(2)?,
committed_at: row.get::<Option<i64>>(3)?,
sponsorships: Vec::new(),
})
})?
.into_iter()
.next())
.next()
else {
return Ok(None);
};

status.sponsorships = select_note_sponsorships(tx, note_id)?;
Ok(Some(status))
}
6 changes: 6 additions & 0 deletions bin/ntx-builder/src/db/queries/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ pub use select_genesis_commitment::select_genesis_commitment;
mod select_genesis_validator_keys;
pub use select_genesis_validator_keys::select_genesis_validator_keys;

mod select_note_sponsorships;
pub use select_note_sponsorships::{NoteSponsorshipRow, select_note_sponsorships};

mod sponsorships_failed;
pub use sponsorships_failed::sponsorships_failed;

mod sponsorships_for_pending_notes;
pub use sponsorships_for_pending_notes::select_sponsorships_for_pending_notes;

Expand Down
41 changes: 41 additions & 0 deletions bin/ntx-builder/src/db/queries/select_note_sponsorships/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Selects the `FEE_SPONSORSHIP` notes bound to a feature note.

use miden_node_db::sqlite::ReadTx;
use miden_node_db::{DatabaseError, SqlTypeConvert};
use miden_protocol::block::BlockNumber;
use miden_protocol::note::{Note, NoteId};

use crate::sponsorship::SponsorshipNote;

const SQL: &str = include_str!("select_note_sponsorships.sql");

/// Row returned by [`select_note_sponsorships`].
#[derive(Debug, Clone)]
pub struct NoteSponsorshipRow {
pub note: SponsorshipNote,
pub committed_at: Option<BlockNumber>,
pub last_attempt: Option<BlockNumber>,
pub last_error: Option<String>,
}

/// Returns every `FEE_SPONSORSHIP` note bound to the given feature note, consumed or not, ordered
/// by sponsorship note ID.
pub fn select_note_sponsorships(
tx: &ReadTx<'_>,
feature_note_id: NoteId,
) -> Result<Vec<NoteSponsorshipRow>, DatabaseError> {
tx.query(SQL, &[&feature_note_id], |row| {
let note = SponsorshipNote::try_from(row.get::<Note>(0)?).map_err(|source| {
DatabaseError::deserialization("failed to convert to sponsorship note", source)
})?;
let committed_at = row.get::<Option<i64>>(1)?.map(BlockNumber::from_raw_sql).transpose()?;
let last_attempt = row.get::<Option<i64>>(2)?.map(BlockNumber::from_raw_sql).transpose()?;
let last_error = row.get::<Option<String>>(3)?;
Ok(NoteSponsorshipRow {
note,
committed_at,
last_attempt,
last_error,
})
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Selects every FEE_SPONSORSHIP note bound to the given feature note, including consumed notes.
SELECT note_data, committed_at, last_attempt, last_error
FROM sponsorship_notes
WHERE feature_note_id = ?1
ORDER BY note_id
27 changes: 27 additions & 0 deletions bin/ntx-builder/src/db/queries/sponsorships_failed/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Records the latest failure of a set of `FEE_SPONSORSHIP` notes.

use miden_node_db::sqlite::WriteTx;
use miden_node_db::{DatabaseError, SqlTypeConvert};
use miden_node_tracing::ErrorReport;
use miden_protocol::block::BlockNumber;
use miden_protocol::note::Nullifier;

use crate::NoteError;

const SQL: &str = include_str!("sponsorship_failed.sql");

/// Marks `FEE_SPONSORSHIP` notes as failed by setting `last_attempt` and storing the latest error
/// message. Nullifiers that are not in `sponsorship_notes` match no row.
pub fn sponsorships_failed(
tx: &WriteTx<'_>,
failed_sponsorships: &[(Nullifier, NoteError)],
block_num: BlockNumber,
) -> Result<(), DatabaseError> {
let block_num_val = block_num.to_raw_sql();

for (nullifier, error) in failed_sponsorships {
let error_report = error.as_report();
tx.execute(SQL, &[nullifier, &block_num_val, &error_report])?;
}
Ok(())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Records the latest failure of a FEE_SPONSORSHIP note by setting `last_attempt` and `last_error`.
-- The attempt count and the backoff stay on the feature note.
UPDATE sponsorship_notes
SET last_attempt = ?2, last_error = ?3
WHERE nullifier = ?1
83 changes: 83 additions & 0 deletions bin/ntx-builder/src/db/queries/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,89 @@ async fn sponsorships_for_pending_notes_binds_by_feature_note_not_tag() {
assert_eq!(pending[&feature.as_note().id()].len(), 1);
}

/// The note status reports every sponsorship bound to the feature note, consumed or not, and no
/// sponsorship bound to another feature note.
#[tokio::test]
async fn get_note_status_reports_bound_sponsorships() {
let (db, _dir) = test_setup().await;
let account_id = mock_network_account_id();
let feature = mock_single_target_note(account_id, 1);
let other_feature = mock_single_target_note(account_id, 2);
let pending = sponsorship_for(account_id, feature.as_note().id(), 3);
let consumed = sponsorship_for(account_id, feature.as_note().id(), 4);
let unrelated = sponsorship_for(account_id, other_feature.as_note().id(), 5);

db.insert_network_notes(vec![feature.clone(), other_feature.clone()])
.await
.unwrap();
db.insert_sponsorship_notes(vec![pending.clone(), consumed.clone(), unrelated])
.await
.unwrap();
db.mark_sponsorships_consumed(vec![consumed.nullifier()], BlockNumber::from(7))
.await
.unwrap();

let status = db.get_note_status(feature.as_note().id()).await.unwrap().unwrap();
let mut reported: Vec<_> = status
.sponsorships
.iter()
.map(|row| (row.note.id(), row.committed_at))
.collect();
reported.sort_by_key(|(id, _)| *id);
let mut expected = vec![(pending.id(), None), (consumed.id(), Some(BlockNumber::from(7)))];
expected.sort_by_key(|(id, _)| *id);
assert_eq!(reported, expected);

let other = db.get_note_status(other_feature.as_note().id()).await.unwrap().unwrap();
assert_eq!(other.sponsorships.len(), 1);

let unsponsored = mock_single_target_note(account_id, 6);
db.insert_network_notes(vec![unsponsored.clone()]).await.unwrap();
let status = db.get_note_status(unsponsored.as_note().id()).await.unwrap().unwrap();
assert!(status.sponsorships.is_empty());
}

/// A sponsorship failure is stored on the sponsorship row, keeps only the latest error, and does
/// not change the attempt tracking of the feature note.
#[tokio::test]
async fn sponsorships_failed_records_latest_error_on_sponsorship() {
let (db, _dir) = test_setup().await;
let account_id = mock_network_account_id();
let feature = mock_single_target_note(account_id, 1);
let failing = sponsorship_for(account_id, feature.as_note().id(), 2);
let healthy = sponsorship_for(account_id, feature.as_note().id(), 3);

db.insert_network_notes(vec![feature.clone()]).await.unwrap();
db.insert_sponsorship_notes(vec![failing.clone(), healthy.clone()])
.await
.unwrap();

db.sponsorships_failed(
vec![(failing.nullifier(), test_note_error("first"))],
BlockNumber::from(5),
)
.await
.unwrap();
db.sponsorships_failed(
vec![(failing.nullifier(), test_note_error("second"))],
BlockNumber::from(6),
)
.await
.unwrap();

let status = db.get_note_status(feature.as_note().id()).await.unwrap().unwrap();
assert_eq!(status.attempt_count, 0);
assert_eq!(status.last_error, None);

let failing_row = status.sponsorships.iter().find(|row| row.note.id() == failing.id()).unwrap();
assert_eq!(failing_row.last_attempt, Some(BlockNumber::from(6)));
assert!(failing_row.last_error.as_deref().is_some_and(|error| error.contains("second")));

let healthy_row = status.sponsorships.iter().find(|row| row.note.id() == healthy.id()).unwrap();
assert_eq!(healthy_row.last_attempt, None);
assert_eq!(healthy_row.last_error, None);
}

// NOTE ELIGIBILITY
// ================================================================================================
//
Expand Down
Loading
Loading