diff --git a/CHANGELOG.md b/CHANGELOG.md index 77872696c..c26231e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +* New note status added to reflect more possible states (#355). * [BREAKING] Library API reorganization (#367). * Added `wasm` and `async` feature to make the code compatible with WASM-32 target (#378). * Changed `cargo-make` usage for `make` and `Makefile.toml` for a regular `Makefile` (#359). diff --git a/Cargo.toml b/Cargo.toml index aaabb54d0..697c25028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ testing = ["miden-objects/testing", "miden-lib/testing"] tonic = ["dep:tonic", "dep:miden-node-proto"] [dependencies] +chrono = { version = "0.4", optional = false } clap = { version = "4.3", features = ["derive"], optional = true } comfy-table = { version = "7.1.0", optional = true } figment = { version = "0.10", features = ["toml", "env"], optional = true } diff --git a/src/cli/new_transactions.rs b/src/cli/new_transactions.rs index 38f773f50..cbaf46d7f 100644 --- a/src/cli/new_transactions.rs +++ b/src/cli/new_transactions.rs @@ -258,7 +258,7 @@ impl ConsumeNotesCmd { info!("No input note IDs provided, getting all notes consumable by {}", account_id); let consumable_notes = client.get_consumable_notes(Some(account_id))?; - list_of_notes.extend(consumable_notes.iter().map(|n| n.note.id())); + list_of_notes.extend(consumable_notes.iter().map(|(note, _)| note.id())); } if list_of_notes.is_empty() { diff --git a/src/cli/notes.rs b/src/cli/notes.rs index 875ece820..149401b34 100644 --- a/src/cli/notes.rs +++ b/src/cli/notes.rs @@ -3,9 +3,9 @@ use comfy_table::{presets, Attribute, Cell, ContentArrangement, Table}; use miden_client::{ errors::{ClientError, IdPrefixFetchError}, rpc::NodeRpcClient, - store::{InputNoteRecord, NoteFilter as ClientNoteFilter, NoteStatus, OutputNoteRecord, Store}, + store::{InputNoteRecord, NoteFilter as ClientNoteFilter, OutputNoteRecord, Store}, transactions::transaction_request::known_script_roots::{P2ID, P2IDR, SWAP}, - Client, ConsumableNote, + Client, NoteConsumability, }; use miden_objects::{ accounts::AccountId, @@ -27,6 +27,7 @@ pub enum NoteFilter { Pending, Committed, Consumed, + Processing, Consumable, } @@ -39,6 +40,7 @@ impl TryInto> for NoteFilter { NoteFilter::Pending => Ok(ClientNoteFilter::Pending), NoteFilter::Committed => Ok(ClientNoteFilter::Committed), NoteFilter::Consumed => Ok(ClientNoteFilter::Consumed), + NoteFilter::Processing => Ok(ClientNoteFilter::Processing), NoteFilter::Consumable => Err("Consumable filter is not supported".to_string()), } } @@ -310,14 +312,14 @@ where fn print_consumable_notes_summary<'a, I>(notes: I) -> Result<(), String> where - I: IntoIterator, + I: IntoIterator)>, { let mut table = create_dynamic_table(&["Note ID", "Account ID", "Relevance"]); - for consumable_note in notes { - for relevance in &consumable_note.relevances { + for (note, relevances) in notes { + for relevance in relevances { table.add_row(vec![ - consumable_note.note.id().to_hex(), + note.id().to_hex(), relevance.0.to_string(), relevance.1.to_string(), ]); @@ -351,21 +353,6 @@ fn note_summary( .or(output_note_record.map(|record| record.id())) .expect("One of the two records should be Some"); - let commit_height = input_note_record - .map(|record| { - record - .inclusion_proof() - .map(|proof| proof.origin().block_num.to_string()) - .unwrap_or("-".to_string()) - }) - .or(output_note_record.map(|record| { - record - .inclusion_proof() - .map(|proof| proof.origin().block_num.to_string()) - .unwrap_or("-".to_string()) - })) - .expect("One of the two records should be Some"); - let assets_hash_str = input_note_record .map(|record| record.assets().commitment().to_string()) .or(output_note_record.map(|record| record.assets().commitment().to_string())) @@ -408,27 +395,8 @@ fn note_summary( let status = input_note_record .map(|record| record.status()) .or(output_note_record.map(|record| record.status())) - .expect("One of the two records should be Some"); - - let note_consumer = input_note_record - .map(|record| record.consumer_account_id()) - .or(output_note_record.map(|record| record.consumer_account_id())) - .expect("One of the two records should be Some"); - - let status = match status { - NoteStatus::Committed => { - status.to_string() + format!(" (height {})", commit_height).as_str() - }, - NoteStatus::Consumed => { - status.to_string() - + format!( - " (by {})", - note_consumer.map(|id| id.to_string()).unwrap_or("?".to_string()) - ) - .as_str() - }, - _ => status.to_string(), - }; + .expect("One of the two records should be Some") + .to_string(); let note_metadata = input_note_record .map(|record| record.metadata()) diff --git a/src/client/mod.rs b/src/client/mod.rs index 63389d407..0958e0880 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -15,8 +15,7 @@ mod notes; pub mod store_authenticator; pub mod sync; pub mod transactions; -pub use note_screener::{NoteRelevance, NoteScreener}; -pub use notes::ConsumableNote; +pub use note_screener::{NoteConsumability, NoteRelevance, NoteScreener}; // MIDEN CLIENT // ================================================================================================ diff --git a/src/client/note_screener.rs b/src/client/note_screener.rs index 93849cca4..4ad13793e 100644 --- a/src/client/note_screener.rs +++ b/src/client/note_screener.rs @@ -18,6 +18,11 @@ pub enum NoteRelevance { After(u32), } +/// Represents the consumability of a note by a specific account. +/// +/// The tuple contains the account ID that may consume the note and the moment it will become relevant. +pub type NoteConsumability = (AccountId, NoteRelevance); + impl fmt::Display for NoteRelevance { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -46,7 +51,7 @@ impl NoteScreener { pub fn check_relevance( &self, note: &Note, - ) -> Result, NoteScreenerError> { + ) -> Result, NoteScreenerError> { let account_ids = BTreeSet::from_iter(maybe_await!(self.store.get_account_ids())?); let script_hash = note.script().hash().to_string(); @@ -63,7 +68,7 @@ impl NoteScreener { fn check_p2id_relevance( note: &Note, account_ids: &BTreeSet, - ) -> Result, NoteScreenerError> { + ) -> Result, NoteScreenerError> { let mut note_inputs_iter = note.inputs().values().iter(); let account_id_felt = note_inputs_iter .next() @@ -85,7 +90,7 @@ impl NoteScreener { fn check_p2idr_relevance( note: &Note, account_ids: &BTreeSet, - ) -> Result, NoteScreenerError> { + ) -> Result, NoteScreenerError> { let mut note_inputs_iter = note.inputs().values().iter(); let account_id_felt = note_inputs_iter .next() @@ -129,7 +134,7 @@ impl NoteScreener { &self, note: &Note, account_ids: &BTreeSet, - ) -> Result, NoteScreenerError> { + ) -> Result, NoteScreenerError> { let note_inputs = note.inputs().values().to_vec(); if note_inputs.len() != 9 { return Ok(Vec::new()); @@ -177,7 +182,7 @@ impl NoteScreener { &self, _note: &Note, account_ids: &BTreeSet, - ) -> Result, NoteScreenerError> { + ) -> Result, NoteScreenerError> { // TODO: try to execute the note script against relevant accounts; this will // require querying data from the store Ok(account_ids diff --git a/src/client/notes.rs b/src/client/notes.rs index f358f54cf..d1f637b4b 100644 --- a/src/client/notes.rs +++ b/src/client/notes.rs @@ -8,23 +8,13 @@ use miden_tx::{auth::TransactionAuthenticator, ScriptTarget}; use tracing::info; use winter_maybe_async::{maybe_async, maybe_await}; -use super::{note_screener::NoteRelevance, rpc::NodeRpcClient, Client}; +use super::{note_screener::NoteConsumability, rpc::NodeRpcClient, Client}; use crate::{ client::NoteScreener, errors::ClientError, store::{InputNoteRecord, NoteFilter, OutputNoteRecord, Store}, }; -// TYPES -// -------------------------------------------------------------------------------------------- -/// Contains information about a note that can be consumed -pub struct ConsumableNote { - /// The consumable note - pub note: InputNoteRecord, - /// Stores which accounts can consume the note and it's relevance - pub relevances: Vec<(AccountId, NoteRelevance)>, -} - impl Client { // INPUT NOTE DATA RETRIEVAL // -------------------------------------------------------------------------------------------- @@ -38,40 +28,48 @@ impl Client maybe_await!(self.store.get_input_notes(filter)).map_err(|err| err.into()) } - /// Returns input notes that are able to be consumed by the account_id. + /// Returns the input notes and their consumability. /// /// If account_id is None then all consumable input notes are returned. #[maybe_async] pub fn get_consumable_notes( &self, account_id: Option, - ) -> Result, ClientError> { + ) -> Result)>, ClientError> { let commited_notes = maybe_await!(self.store.get_input_notes(NoteFilter::Committed))?; let note_screener = NoteScreener::new(self.store.clone()); let mut relevant_notes = Vec::new(); for input_note in commited_notes { - let account_relevance = + let mut account_relevance = maybe_await!(note_screener.check_relevance(&input_note.clone().try_into()?))?; + if let Some(account_id) = account_id { + account_relevance.retain(|(id, _)| *id == account_id); + } + if account_relevance.is_empty() { continue; } - relevant_notes.push(ConsumableNote { - note: input_note, - relevances: account_relevance, - }); - } - - if let Some(account_id) = account_id { - relevant_notes.retain(|note| note.relevances.iter().any(|(id, _)| *id == account_id)); + relevant_notes.push((input_note, account_relevance)); } Ok(relevant_notes) } + /// Returns the consumability of the provided note. + #[maybe_async] + pub fn get_note_consumability( + &self, + note: InputNoteRecord, + ) -> Result, ClientError> { + let note_screener = NoteScreener::new(self.store.clone()); + maybe_await!(note_screener.check_relevance(¬e.clone().try_into()?)) + .map_err(|err| err.into()) + } + /// Returns the input note with the specified hash. #[maybe_async] pub fn get_input_note(&self, note_id: NoteId) -> Result { @@ -169,7 +167,6 @@ impl Client note.metadata().copied(), inclusion_proof, note.details().clone(), - None, ); maybe_await!(self.store.insert_input_note(¬e)).map_err(|err| err.into()) diff --git a/src/client/sync.rs b/src/client/sync.rs index 3dacef2a0..9f16a5984 100644 --- a/src/client/sync.rs +++ b/src/client/sync.rs @@ -744,6 +744,10 @@ fn get_transactions_to_commit( // account be included in a single block. If that happens, we'll need to rewrite // this check. + // TODO: If all input notes were consumed but the account hashes don't match, it means that + // another transaction consumed them and that the current transaction failed. We should + // handle this case in the future and rollback the store accordingly. + t.input_note_nullifiers.iter().all(|n| nullifiers.contains(n)) && t.output_notes.iter().all(|n| note_ids.contains(&n.id())) && account_hash_updates.iter().any(|(account_id, account_hash)| { diff --git a/src/lib.rs b/src/lib.rs index 908921a49..adf7f0c3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ extern crate alloc; mod client; pub use client::{ accounts::AccountTemplate, rpc, store_authenticator::StoreAuthenticator, sync::SyncSummary, - transactions, Client, ConsumableNote, NoteRelevance, + transactions, Client, NoteConsumability, NoteRelevance, }; pub mod config; diff --git a/src/store/data_store.rs b/src/store/data_store.rs index c670ee747..01657f92a 100644 --- a/src/store/data_store.rs +++ b/src/store/data_store.rs @@ -55,13 +55,13 @@ impl DataStore for ClientDataStore { let note_record = input_note_records.get(note_id).expect("should have key"); match note_record.status() { - NoteStatus::Pending => { + NoteStatus::Pending { .. } => { return Err(DataStoreError::InternalError(format!( "The input note ID {} does not contain a note origin.", note_id.to_hex() ))); }, - NoteStatus::Consumed => { + NoteStatus::Consumed { .. } => { return Err(DataStoreError::NoteAlreadyConsumed(*note_id)); }, _ => {}, diff --git a/src/store/mod.rs b/src/store/mod.rs index 5c37ae951..4c230c185 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -85,6 +85,7 @@ pub trait Store { fn get_unspent_input_note_nullifiers(&self) -> Result, StoreError> { let nullifiers = maybe_await!(self.get_input_notes(NoteFilter::Committed))? .iter() + .chain(maybe_await!(self.get_input_notes(NoteFilter::Processing))?.iter()) .map(|input_note| Ok(Nullifier::from(Digest::try_from(input_note.nullifier())?))) .collect::, _>>(); @@ -303,6 +304,8 @@ pub enum NoteFilter<'a> { /// Return a list of pending notes ([InputNoteRecord] or [OutputNoteRecord]). These represent notes for which the store /// does not have anchor data. Pending, + /// Return a list of notes that are currently being processed. + Processing, /// Return a list containing the note that matches with the provided [NoteId]. List(&'a [NoteId]), /// Return a list containing the note that matches with the provided [NoteId]. diff --git a/src/store/note_record/input_note_record.rs b/src/store/note_record/input_note_record.rs index da2391f3d..40e20ae92 100644 --- a/src/store/note_record/input_note_record.rs +++ b/src/store/note_record/input_note_record.rs @@ -1,5 +1,4 @@ use miden_objects::{ - accounts::AccountId, notes::{ Note, NoteAssets, NoteDetails, NoteId, NoteInclusionProof, NoteInputs, NoteMetadata, NoteRecipient, @@ -40,7 +39,6 @@ pub struct InputNoteRecord { metadata: Option, recipient: Digest, status: NoteStatus, - consumer_account_id: Option, } impl InputNoteRecord { @@ -52,7 +50,6 @@ impl InputNoteRecord { metadata: Option, inclusion_proof: Option, details: NoteRecordDetails, - consumer_account_id: Option, ) -> InputNoteRecord { InputNoteRecord { id, @@ -62,7 +59,6 @@ impl InputNoteRecord { metadata, inclusion_proof, details, - consumer_account_id, } } @@ -97,10 +93,6 @@ impl InputNoteRecord { pub fn details(&self) -> &NoteRecordDetails { &self.details } - - pub fn consumer_account_id(&self) -> Option { - self.consumer_account_id - } } impl From<&NoteDetails> for InputNoteRecord { @@ -111,7 +103,7 @@ impl From<&NoteDetails> for InputNoteRecord { recipient: note_details.recipient().digest(), metadata: None, inclusion_proof: None, - status: NoteStatus::Pending, + status: NoteStatus::Pending { created_at: 0 }, details: NoteRecordDetails { nullifier: note_details.nullifier().to_string(), script_hash: note_details.script().hash(), @@ -119,7 +111,6 @@ impl From<&NoteDetails> for InputNoteRecord { inputs: note_details.inputs().values().to_vec(), serial_num: note_details.serial_num(), }, - consumer_account_id: None, } } } @@ -154,7 +145,6 @@ impl Deserializable for InputNoteRecord { metadata, inclusion_proof, details, - consumer_account_id: None, }) } } @@ -165,7 +155,7 @@ impl From for InputNoteRecord { id: note.id(), recipient: note.recipient().digest(), assets: note.assets().clone(), - status: NoteStatus::Pending, + status: NoteStatus::Pending { created_at: 0 }, metadata: Some(*note.metadata()), inclusion_proof: None, details: NoteRecordDetails::new( @@ -174,7 +164,6 @@ impl From for InputNoteRecord { note.inputs().values().to_vec(), note.serial_num(), ), - consumer_account_id: None, } } } @@ -185,7 +174,7 @@ impl From for InputNoteRecord { id: recorded_note.note().id(), recipient: recorded_note.note().recipient().digest(), assets: recorded_note.note().assets().clone(), - status: NoteStatus::Pending, + status: NoteStatus::Pending { created_at: 0 }, metadata: Some(*recorded_note.note().metadata()), details: NoteRecordDetails::new( recorded_note.note().nullifier().to_string(), @@ -194,7 +183,6 @@ impl From for InputNoteRecord { recorded_note.note().serial_num(), ), inclusion_proof: recorded_note.proof().cloned(), - consumer_account_id: None, } } } @@ -255,7 +243,6 @@ impl TryFrom for InputNoteRecord { metadata: Some(*output_note.metadata()), recipient: output_note.recipient(), status: output_note.status(), - consumer_account_id: output_note.consumer_account_id(), }), None => Err(ClientError::NoteError(miden_objects::NoteError::invalid_origin_index( "Output Note Record contains no details".to_string(), diff --git a/src/store/note_record/mod.rs b/src/store/note_record/mod.rs index dc377e3ef..ceab6f004 100644 --- a/src/store/note_record/mod.rs +++ b/src/store/note_record/mod.rs @@ -1,6 +1,8 @@ use std::fmt::Display; +use chrono::{Local, TimeZone}; use miden_objects::{ + accounts::AccountId, assembly::{Assembler, ProgramAst}, notes::NoteScript, utils::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable}, @@ -46,54 +48,119 @@ pub use output_note_record::OutputNoteRecord; // NOTE STATUS // ================================================================================================ +pub const NOTE_STATUS_PENDING: &str = "Pending"; +pub const NOTE_STATUS_COMMITTED: &str = "Committed"; +pub const NOTE_STATUS_CONSUMED: &str = "Consumed"; +pub const NOTE_STATUS_PROCESSING: &str = "Processing"; + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum NoteStatus { - Pending, - Committed, - Consumed, -} - -impl From for u8 { - fn from(value: NoteStatus) -> Self { - match value { - NoteStatus::Pending => 0, - NoteStatus::Committed => 1, - NoteStatus::Consumed => 2, - } - } -} - -impl TryFrom for NoteStatus { - type Error = DeserializationError; - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(NoteStatus::Pending), - 1 => Ok(NoteStatus::Committed), - 2 => Ok(NoteStatus::Consumed), - _ => Err(DeserializationError::InvalidValue(value.to_string())), - } - } + /// Note is pending to be commited on chain. + Pending { + /// UNIX epoch-based timestamp (in seconds) when the note (either new or imported) started being tracked by the client. + created_at: u64, + }, + /// Note has been commited on chain. + Committed { + /// Block height at which the note was commited. + block_height: u64, + }, + /// Note has been consumed locally but not yet nullified on chain. + Processing { + /// ID of account that is consuming the note. + consumer_account_id: AccountId, + /// UNIX epoch-based timestamp (in seconds) of the note's consumption. + submitted_at: u64, + }, + /// Note has been nullified on chain. + Consumed { + /// ID of account that consumed the note. If the consumer account is not known, this field will be `None`. + consumer_account_id: Option, + /// Block height at which the note was consumed. + block_height: u64, + }, } impl Serializable for NoteStatus { fn write_into(&self, target: &mut W) { - target.write_bytes(&[(*self).into()]); + match self { + NoteStatus::Pending { created_at } => { + target.write_u8(0); + target.write_u64(*created_at); + }, + NoteStatus::Committed { block_height } => { + target.write_u8(1); + target.write_u64(*block_height); + }, + NoteStatus::Processing { consumer_account_id, submitted_at } => { + target.write_u8(2); + target.write_u64(*submitted_at); + consumer_account_id.write_into(target); + }, + NoteStatus::Consumed { consumer_account_id, block_height } => { + target.write_u8(3); + target.write_u64(*block_height); + consumer_account_id.write_into(target); + }, + } } } impl Deserializable for NoteStatus { fn read_from(source: &mut R) -> Result { - let enum_byte = u8::read_from(source)?; - enum_byte.try_into() + let status = source.read_u8()?; + match status { + 0 => { + let created_at = source.read_u64()?; + Ok(NoteStatus::Pending { created_at }) + }, + 1 => { + let block_height = source.read_u64()?; + Ok(NoteStatus::Committed { block_height }) + }, + 2 => { + let submitted_at = source.read_u64()?; + let consumer_account_id = AccountId::read_from(source)?; + Ok(NoteStatus::Processing { consumer_account_id, submitted_at }) + }, + 3 => { + let block_height = source.read_u64()?; + let consumer_account_id = Option::::read_from(source)?; + Ok(NoteStatus::Consumed { consumer_account_id, block_height }) + }, + _ => Err(DeserializationError::InvalidValue("NoteStatus".to_string())), + } } } impl Display for NoteStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - NoteStatus::Pending => write!(f, "Pending"), - NoteStatus::Committed => write!(f, "Committed"), - NoteStatus::Consumed => write!(f, "Consumed"), + NoteStatus::Pending { created_at } => write!( + f, + "{NOTE_STATUS_PENDING} (created at {})", + Local + .timestamp_opt(*created_at as i64, 0) + .single() + .expect("timestamp should be valid") + ), + NoteStatus::Committed { block_height } => { + write!(f, "{NOTE_STATUS_COMMITTED} (at block height {block_height})") + }, + NoteStatus::Processing { consumer_account_id, submitted_at } => write!( + f, + "{NOTE_STATUS_PROCESSING} (submitted at {} by account {})", + Local + .timestamp_opt(*submitted_at as i64, 0) + .single() + .expect("timestamp should be valid"), + consumer_account_id.to_hex() + ), + NoteStatus::Consumed { consumer_account_id, block_height } => write!( + f, + "{NOTE_STATUS_CONSUMED} (at block height {block_height} by account {})", + consumer_account_id.map(|id| id.to_hex()).unwrap_or("?".to_string()) + ), } } } diff --git a/src/store/note_record/output_note_record.rs b/src/store/note_record/output_note_record.rs index 618c9770a..851dfee61 100644 --- a/src/store/note_record/output_note_record.rs +++ b/src/store/note_record/output_note_record.rs @@ -1,5 +1,4 @@ use miden_objects::{ - accounts::AccountId, notes::{Note, NoteAssets, NoteId, NoteInclusionProof, NoteMetadata, PartialNote}, transaction::OutputNote, Digest, @@ -33,7 +32,6 @@ pub struct OutputNoteRecord { metadata: NoteMetadata, recipient: Digest, status: NoteStatus, - consumer_account_id: Option, } impl OutputNoteRecord { @@ -45,7 +43,6 @@ impl OutputNoteRecord { metadata: NoteMetadata, inclusion_proof: Option, details: Option, - consumer_account_id: Option, ) -> OutputNoteRecord { OutputNoteRecord { id, @@ -55,7 +52,6 @@ impl OutputNoteRecord { metadata, inclusion_proof, details, - consumer_account_id, } } @@ -86,10 +82,6 @@ impl OutputNoteRecord { pub fn details(&self) -> Option<&NoteRecordDetails> { self.details.as_ref() } - - pub fn consumer_account_id(&self) -> Option { - self.consumer_account_id - } } // CONVERSIONS @@ -102,7 +94,7 @@ impl From for OutputNoteRecord { id: note.id(), recipient: note.recipient().digest(), assets: note.assets().clone(), - status: NoteStatus::Pending, + status: NoteStatus::Pending { created_at: 0 }, metadata: *note.metadata(), inclusion_proof: None, details: Some(NoteRecordDetails::new( @@ -111,7 +103,6 @@ impl From for OutputNoteRecord { note.inputs().values().to_vec(), note.serial_num(), )), - consumer_account_id: None, } } } @@ -122,11 +113,10 @@ impl From for OutputNoteRecord { partial_note.id(), partial_note.recipient_digest(), partial_note.assets().clone(), - NoteStatus::Pending, + NoteStatus::Pending { created_at: 0 }, *partial_note.metadata(), None, None, - None, ) } } @@ -162,7 +152,6 @@ impl TryFrom for OutputNoteRecord { metadata: *metadata, recipient: input_note.recipient(), status: input_note.status(), - consumer_account_id: input_note.consumer_account_id(), }), None => Err(ClientError::NoteError(miden_objects::NoteError::invalid_origin_index( "Input Note Record contains no metadata".to_string(), diff --git a/src/store/sqlite_store/mod.rs b/src/store/sqlite_store/mod.rs index 62ab2513e..2e60e3869 100644 --- a/src/store/sqlite_store/mod.rs +++ b/src/store/sqlite_store/mod.rs @@ -4,7 +4,7 @@ use core::cell::{RefCell, RefMut}; use miden_objects::{ accounts::{Account, AccountId, AccountStub, AuthSecretKey}, crypto::merkle::{InOrderIndex, MmrPeaks}, - notes::NoteTag, + notes::{NoteTag, Nullifier}, BlockHeader, Digest, Word, }; use rusqlite::{vtab::array, Connection}; @@ -260,6 +260,11 @@ impl Store for SqliteStore { fn get_account_auth_by_pub_key(&self, pub_key: Word) -> Result { self.get_account_auth_by_pub_key(pub_key) } + + #[maybe_async] + fn get_unspent_input_note_nullifiers(&self) -> Result, StoreError> { + self.get_unspent_input_note_nullifiers() + } } // TESTS diff --git a/src/store/sqlite_store/notes.rs b/src/store/sqlite_store/notes.rs index be6f8444f..77c389f08 100644 --- a/src/store/sqlite_store/notes.rs +++ b/src/store/sqlite_store/notes.rs @@ -1,27 +1,35 @@ use alloc::rc::Rc; use std::fmt; +use chrono::Utc; use clap::error::Result; use miden_objects::{ accounts::AccountId, crypto::utils::{Deserializable, Serializable}, - notes::{NoteAssets, NoteId, NoteInclusionProof, NoteMetadata, NoteScript, Nullifier}, + notes::{NoteAssets, NoteId, NoteInclusionProof, NoteMetadata, NoteScript}, transaction::TransactionId, Digest, }; +use miden_tx::utils::DeserializationError; use rusqlite::{named_params, params, params_from_iter, types::Value, Transaction}; use super::SqliteStore; use crate::{ errors::StoreError, - store::{InputNoteRecord, NoteFilter, NoteRecordDetails, NoteStatus, OutputNoteRecord}, + store::{ + note_record::{ + NOTE_STATUS_COMMITTED, NOTE_STATUS_CONSUMED, NOTE_STATUS_PENDING, + NOTE_STATUS_PROCESSING, + }, + InputNoteRecord, NoteFilter, NoteRecordDetails, NoteStatus, Nullifier, OutputNoteRecord, + }, }; fn insert_note_query(table_name: NoteTable) -> String { format!("\ INSERT INTO {table_name} - (note_id, assets, recipient, status, metadata, details, inclusion_proof, consumer_transaction_id) - VALUES (:note_id, :assets, :recipient, :status, json(:metadata), json(:details), json(:inclusion_proof), :consumer_transaction_id)", + (note_id, assets, recipient, status, metadata, details, inclusion_proof, consumer_transaction_id, created_at) + VALUES (:note_id, :assets, :recipient, :status, json(:metadata), json(:details), json(:inclusion_proof), :consumer_transaction_id, unixepoch(current_timestamp));", table_name = table_name) } @@ -60,6 +68,9 @@ type SerializedInputNoteParts = ( Option, Vec, Option, + u64, + Option, + Option, ); type SerializedOutputNoteParts = ( Vec, @@ -70,6 +81,9 @@ type SerializedOutputNoteParts = ( Option, Option>, Option, + u64, + Option, + Option, ); // NOTE TABLE @@ -97,18 +111,21 @@ impl<'a> NoteFilter<'a> { /// Returns a [String] containing the query for this Filter fn to_query(&self, notes_table: NoteTable) -> String { let base = format!( - "SELECT - note.assets, - note.details, + "SELECT + note.assets, + note.details, note.recipient, note.status, note.metadata, note.inclusion_proof, script.serialized_note_script, - tx.account_id - from {notes_table} AS note + tx.account_id, + note.created_at, + note.submitted_at, + note.nullifier_height + from {notes_table} AS note LEFT OUTER JOIN notes_scripts AS script - ON note.details IS NOT NULL AND + ON note.details IS NOT NULL AND json_extract(note.details, '$.script_hash') = script.script_hash LEFT OUTER JOIN transactions AS tx ON note.consumer_transaction_id IS NOT NULL AND @@ -117,9 +134,18 @@ impl<'a> NoteFilter<'a> { match self { NoteFilter::All => base, - NoteFilter::Committed => format!("{base} WHERE status = 'Committed'"), - NoteFilter::Consumed => format!("{base} WHERE status = 'Consumed'"), - NoteFilter::Pending => format!("{base} WHERE status = 'Pending'"), + NoteFilter::Committed => { + format!("{base} WHERE status = '{NOTE_STATUS_COMMITTED}'") + }, + NoteFilter::Consumed => { + format!("{base} WHERE status = '{NOTE_STATUS_CONSUMED}'") + }, + NoteFilter::Pending => { + format!("{base} WHERE status = '{NOTE_STATUS_PENDING}'") + }, + NoteFilter::Processing => { + format!("{base} WHERE status = '{NOTE_STATUS_PROCESSING}'") + }, NoteFilter::Unique(_) | NoteFilter::List(_) => { format!("{base} WHERE note.note_id IN rarray(?)") }, @@ -229,13 +255,16 @@ impl SqliteStore { Ok(tx.commit()?) } - /// Returns the nullifiers of all unspent input notes - pub fn get_unspent_input_note_nullifiers(&self) -> Result, StoreError> { - const QUERY: &str = "SELECT json_extract(details, '$.nullifier') FROM input_notes WHERE status = 'Committed'"; - + pub(crate) fn get_unspent_input_note_nullifiers(&self) -> Result, StoreError> { + const QUERY: &str = + "SELECT json_extract(details, '$.nullifier') FROM input_notes WHERE status IN rarray(?)"; + let unspent_filters = Rc::new(vec![ + Value::from(NOTE_STATUS_COMMITTED.to_string()), + Value::from(NOTE_STATUS_PROCESSING.to_string()), + ]); self.db() .prepare(QUERY)? - .query_map([], |row| row.get(0)) + .query_map([unspent_filters], |row| row.get(0)) .expect("no binding parameters used in query") .map(|result| { result.map_err(|err| StoreError::ParsingError(err.to_string())).and_then( @@ -339,24 +368,28 @@ pub fn update_note_consumer_tx_id( note_id: NoteId, consumer_tx_id: TransactionId, ) -> Result<(), StoreError> { - const UPDATE_INPUT_NOTES_QUERY: &str = "UPDATE input_notes SET consumer_transaction_id = :consumer_transaction_id WHERE note_id = :note_id;"; + const UPDATE_INPUT_NOTES_QUERY: &str = "UPDATE input_notes SET status = :status, consumer_transaction_id = :consumer_transaction_id, submitted_at = :submitted_at WHERE note_id = :note_id;"; tx.execute( UPDATE_INPUT_NOTES_QUERY, named_params! { ":note_id": note_id.inner().to_string(), ":consumer_transaction_id": consumer_tx_id.to_string(), + ":submitted_at": Utc::now().timestamp(), + ":status": NOTE_STATUS_PROCESSING, }, ) .map_err(|err| StoreError::QueryError(err.to_string()))?; - const UPDATE_OUTPUT_NOTES_QUERY: &str = "UPDATE output_notes SET consumer_transaction_id = :consumer_transaction_id WHERE note_id = :note_id;"; + const UPDATE_OUTPUT_NOTES_QUERY: &str = "UPDATE output_notes SET status = :status, consumer_transaction_id = :consumer_transaction_id, submitted_at = :submitted_at WHERE note_id = :note_id;"; tx.execute( UPDATE_OUTPUT_NOTES_QUERY, named_params! { ":note_id": note_id.inner().to_string(), ":consumer_transaction_id": consumer_tx_id.to_string(), + ":submitted_at": Utc::now().timestamp(), + ":status": NOTE_STATUS_PROCESSING, }, ) .map_err(|err| StoreError::QueryError(err.to_string()))?; @@ -376,6 +409,9 @@ fn parse_input_note_columns( let inclusion_proof: Option = row.get(5)?; let serialized_note_script: Vec = row.get(6)?; let consumer_account_id: Option = row.get(7)?; + let created_at: u64 = row.get(8)?; + let submitted_at: Option = row.get(9)?; + let nullifier_height: Option = row.get(10)?; Ok(( assets, @@ -386,6 +422,9 @@ fn parse_input_note_columns( inclusion_proof, serialized_note_script, consumer_account_id, + created_at, + submitted_at, + nullifier_height, )) } @@ -402,6 +441,9 @@ fn parse_input_note( note_inclusion_proof, serialized_note_script, consumer_account_id, + created_at, + submitted_at, + nullifier_height, ) = serialized_input_note_parts; // Merge the info that comes from the input notes table and the notes script table @@ -439,12 +481,36 @@ fn parse_input_note( let recipient = Digest::try_from(recipient)?; let id = NoteId::new(recipient, note_assets.commitment()); - let status: NoteStatus = serde_json::from_str(&format!("\"{status}\"")) - .map_err(StoreError::JsonDataDeserializationError)?; let consumer_account_id: Option = match consumer_account_id { Some(account_id) => Some(AccountId::try_from(account_id as u64)?), None => None, }; + + // If the note is committed and has a consumer account id, then it was consumed locally but the client is not synced with the chain + let status = match status.as_str() { + NOTE_STATUS_PENDING => NoteStatus::Pending { created_at }, + NOTE_STATUS_COMMITTED => NoteStatus::Committed { + block_height: inclusion_proof + .clone() + .map(|proof| proof.origin().block_num as u64) + .expect("Committed note should have inclusion proof"), + }, + NOTE_STATUS_PROCESSING => NoteStatus::Processing { + consumer_account_id: consumer_account_id + .expect("Processing note should have consumer account id"), + submitted_at: submitted_at.expect("Processing note should have submition timestamp"), + }, + NOTE_STATUS_CONSUMED => NoteStatus::Consumed { + consumer_account_id, + block_height: nullifier_height.expect("Consumed note should have nullifier height"), + }, + _ => { + return Err(StoreError::DataDeserializationError(DeserializationError::InvalidValue( + format!("NoteStatus: {}", status), + ))) + }, + }; + Ok(InputNoteRecord::new( id, recipient, @@ -453,7 +519,6 @@ fn parse_input_note( note_metadata, inclusion_proof, note_details, - consumer_account_id, )) } @@ -480,15 +545,11 @@ pub(crate) fn serialize_input_note( )?) .map_err(StoreError::InputSerializationError)?; - let status = serde_json::to_string(&NoteStatus::Committed) - .map_err(StoreError::InputSerializationError)? - .replace('\"', ""); + let status = NOTE_STATUS_COMMITTED.to_string(); (Some(inclusion_proof), status) }, None => { - let status = serde_json::to_string(&NoteStatus::Pending) - .map_err(StoreError::InputSerializationError)? - .replace('\"', ""); + let status = NOTE_STATUS_PENDING.to_string(); (None, status) }, @@ -531,6 +592,9 @@ fn parse_output_note_columns( let inclusion_proof: Option = row.get(5)?; let serialized_note_script: Option> = row.get(6)?; let consumer_account_id: Option = row.get(7)?; + let created_at: u64 = row.get(8)?; + let submitted_at: Option = row.get(9)?; + let nullifier_height: Option = row.get(10)?; Ok(( assets, @@ -541,6 +605,9 @@ fn parse_output_note_columns( inclusion_proof, serialized_note_script, consumer_account_id, + created_at, + submitted_at, + nullifier_height, )) } @@ -557,6 +624,9 @@ fn parse_output_note( note_inclusion_proof, serialized_note_script, consumer_account_id, + created_at, + submitted_at, + nullifier_height, ) = serialized_output_note_parts; let note_details: Option = if let Some(details_as_json_str) = note_details { @@ -596,14 +666,37 @@ fn parse_output_note( let recipient = Digest::try_from(recipient)?; let id = NoteId::new(recipient, note_assets.commitment()); - let status: NoteStatus = serde_json::from_str(&format!("\"{status}\"")) - .map_err(StoreError::JsonDataDeserializationError)?; let consumer_account_id: Option = match consumer_account_id { Some(account_id) => Some(AccountId::try_from(account_id as u64)?), None => None, }; + // If the note is committed and has a consumer account id, then it was consumed locally but the client is not synced with the chain + let status = match status.as_str() { + NOTE_STATUS_PENDING => NoteStatus::Pending { created_at }, + NOTE_STATUS_COMMITTED => NoteStatus::Committed { + block_height: inclusion_proof + .clone() + .map(|proof| proof.origin().block_num as u64) + .expect("Committed note should have inclusion proof"), + }, + NOTE_STATUS_PROCESSING => NoteStatus::Processing { + consumer_account_id: consumer_account_id + .expect("Processing note should have consumer account id"), + submitted_at: submitted_at.expect("Processing note should have submition timestamp"), + }, + NOTE_STATUS_CONSUMED => NoteStatus::Consumed { + consumer_account_id, + block_height: nullifier_height.expect("Consumed note should have nullifier height"), + }, + _ => { + return Err(StoreError::DataDeserializationError(DeserializationError::InvalidValue( + format!("NoteStatus: {}", status), + ))) + }, + }; + Ok(OutputNoteRecord::new( id, recipient, @@ -612,7 +705,6 @@ fn parse_output_note( note_metadata, inclusion_proof, note_details, - consumer_account_id, )) } @@ -638,16 +730,12 @@ pub(crate) fn serialize_output_note( )?) .map_err(StoreError::InputSerializationError)?; - let status = serde_json::to_string(&NoteStatus::Committed) - .map_err(StoreError::InputSerializationError)? - .replace('\"', ""); + let status = NOTE_STATUS_COMMITTED.to_string(); (Some(inclusion_proof), status) }, None => { - let status = serde_json::to_string(&NoteStatus::Pending) - .map_err(StoreError::InputSerializationError)? - .replace('\"', ""); + let status = NOTE_STATUS_PENDING.to_string(); (None, status) }, diff --git a/src/store/sqlite_store/store.sql b/src/store/sqlite_store/store.sql index b2fb3fd48..d62e35d8e 100644 --- a/src/store/sqlite_store/store.sql +++ b/src/store/sqlite_store/store.sql @@ -73,8 +73,8 @@ CREATE TABLE input_notes ( note_id BLOB NOT NULL, -- the note id recipient BLOB NOT NULL, -- the note recipient assets BLOB NOT NULL, -- the serialized NoteAssets, including vault hash and list of assets - status TEXT CHECK( status IN ( -- the status of the note - either pending, committed or consumed - 'Pending', 'Committed', 'Consumed' + status TEXT CHECK( status IN ( -- the status of the note - either pending, committed, processing or consumed + 'Pending', 'Committed', 'Processing', 'Consumed' )), inclusion_proof JSON NULL, -- JSON consisting of the following fields: @@ -94,6 +94,9 @@ CREATE TABLE input_notes ( -- inputs -- the serialized NoteInputs, including inputs hash and list of inputs -- serial_num -- the note serial number consumer_transaction_id BLOB NULL, -- the transaction ID of the transaction that consumed the note + created_at UNSIGNED BIG INT NOT NULL, -- timestamp of the note creation/import + submitted_at UNSIGNED BIG INT NULL, -- timestamp of the note submission to node + nullifier_height UNSIGNED BIG INT NULL, -- block height when the nullifier arrived FOREIGN KEY (consumer_transaction_id) REFERENCES transactions(id) PRIMARY KEY (note_id) @@ -108,6 +111,8 @@ CREATE TABLE input_notes ( )) CONSTRAINT check_valid_metadata_json CHECK (metadata IS NULL OR (json_extract(metadata, '$.sender') IS NOT NULL AND json_extract(metadata, '$.tag') IS NOT NULL)) CONSTRAINT check_valid_consumer_transaction_id CHECK (consumer_transaction_id IS NULL OR status != 'Pending') + CONSTRAINT check_valid_submitted_at CHECK (submitted_at IS NOT NULL OR status != 'Processing') + CONSTRAINT check_valid_nullifier_height CHECK (nullifier_height IS NOT NULL OR status != 'Consumed') ); -- Create output notes table @@ -115,8 +120,8 @@ CREATE TABLE output_notes ( note_id BLOB NOT NULL, -- the note id recipient BLOB NOT NULL, -- the note recipient assets BLOB NOT NULL, -- the serialized NoteAssets, including vault hash and list of assets - status TEXT CHECK( status IN ( -- the status of the note - either pending, committed or consumed - 'Pending', 'Committed', 'Consumed' + status TEXT CHECK( status IN ( -- the status of the note - either pending, committed, processing or consumed + 'Pending', 'Committed', 'Processing', 'Consumed' )), inclusion_proof JSON NULL, -- JSON consisting of the following fields: @@ -136,6 +141,9 @@ CREATE TABLE output_notes ( -- inputs -- the serialized NoteInputs, including inputs hash and list of inputs -- serial_num -- the note serial number consumer_transaction_id BLOB NULL, -- the transaction ID of the transaction that consumed the note + created_at UNSIGNED BIG INT NOT NULL, -- timestamp of the note creation/import + submitted_at UNSIGNED BIG INT NULL, -- timestamp of the note submission to node + nullifier_height UNSIGNED BIG INT NULL, -- block height when the nullifier arrived FOREIGN KEY (consumer_transaction_id) REFERENCES transactions(id) PRIMARY KEY (note_id) @@ -157,6 +165,8 @@ CREATE TABLE output_notes ( json_extract(details, '$.serial_num') IS NOT NULL )) CONSTRAINT check_valid_consumer_transaction_id CHECK (consumer_transaction_id IS NULL OR status != 'Pending') + CONSTRAINT check_valid_submitted_at CHECK (submitted_at IS NOT NULL OR status != 'Processing') + CONSTRAINT check_valid_nullifier_height CHECK (nullifier_height IS NOT NULL OR status != 'Consumed') ); -- Create note's scripts table, used for both input and output notes diff --git a/src/store/sqlite_store/sync.rs b/src/store/sqlite_store/sync.rs index 2c72f3225..e1d630139 100644 --- a/src/store/sqlite_store/sync.rs +++ b/src/store/sqlite_store/sync.rs @@ -5,7 +5,10 @@ use super::SqliteStore; use crate::{ client::sync::StateSyncUpdate, errors::StoreError, - store::sqlite_store::{accounts::update_account, notes::insert_input_note_tx}, + store::{ + note_record::{NOTE_STATUS_COMMITTED, NOTE_STATUS_CONSUMED}, + sqlite_store::{accounts::update_account, notes::insert_input_note_tx}, + }, }; impl SqliteStore { @@ -93,13 +96,19 @@ impl SqliteStore { // Update spent notes for nullifier in nullifiers.iter() { const SPENT_INPUT_NOTE_QUERY: &str = - "UPDATE input_notes SET status = 'Consumed' WHERE json_extract(details, '$.nullifier') = ?"; + "UPDATE input_notes SET status = ?, nullifier_height = ? WHERE json_extract(details, '$.nullifier') = ?"; let nullifier = nullifier.to_hex(); - tx.execute(SPENT_INPUT_NOTE_QUERY, params![nullifier])?; + tx.execute( + SPENT_INPUT_NOTE_QUERY, + params![NOTE_STATUS_CONSUMED.to_string(), block_header.block_num(), nullifier], + )?; const SPENT_OUTPUT_NOTE_QUERY: &str = - "UPDATE output_notes SET status = 'Consumed' WHERE json_extract(details, '$.nullifier') = ?"; - tx.execute(SPENT_OUTPUT_NOTE_QUERY, params![nullifier])?; + "UPDATE output_notes SET status = ?, nullifier_height = ? WHERE json_extract(details, '$.nullifier') = ?"; + tx.execute( + SPENT_OUTPUT_NOTE_QUERY, + params![NOTE_STATUS_CONSUMED.to_string(), block_header.block_num(), nullifier], + )?; } Self::insert_block_header_tx(&tx, block_header, new_mmr_peaks, block_has_relevant_notes)?; @@ -125,13 +134,14 @@ impl SqliteStore { // Update output notes const COMMITTED_OUTPUT_NOTES_QUERY: &str = - "UPDATE output_notes SET status = 'Committed', inclusion_proof = json(:inclusion_proof) WHERE note_id = :note_id"; + "UPDATE output_notes SET status = :status , inclusion_proof = json(:inclusion_proof) WHERE note_id = :note_id"; tx.execute( COMMITTED_OUTPUT_NOTES_QUERY, named_params! { ":inclusion_proof": inclusion_proof, ":note_id": note_id.inner().to_hex(), + ":status": NOTE_STATUS_COMMITTED.to_string(), }, )?; } @@ -149,7 +159,7 @@ impl SqliteStore { serde_json::to_string(metadata).map_err(StoreError::InputSerializationError)?; const COMMITTED_INPUT_NOTES_QUERY: &str = - "UPDATE input_notes SET status = 'Committed', inclusion_proof = json(:inclusion_proof), metadata = json(:metadata) WHERE note_id = :note_id"; + "UPDATE input_notes SET status = :status , inclusion_proof = json(:inclusion_proof), metadata = json(:metadata) WHERE note_id = :note_id"; tx.execute( COMMITTED_INPUT_NOTES_QUERY, @@ -157,6 +167,7 @@ impl SqliteStore { ":inclusion_proof": inclusion_proof, ":metadata": metadata, ":note_id": input_note.id().inner().to_hex(), + ":status": NOTE_STATUS_COMMITTED.to_string(), }, )?; } diff --git a/tests/integration/main.rs b/tests/integration/main.rs index cfbd8358d..fc8235ada 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -132,7 +132,7 @@ async fn test_p2idr_transfer_consumed_by_target() { //Check that the note is not consumed by the target account assert!(matches!( client.get_input_note(note.id()).unwrap().status(), - NoteStatus::Committed + NoteStatus::Committed { .. } )); consume_notes(&mut client, from_account_id, &[note.clone()]).await; @@ -140,8 +140,16 @@ async fn test_p2idr_transfer_consumed_by_target() { // Check that the note is consumed by the target account let input_note = client.get_input_note(note.id()).unwrap(); - assert!(matches!(input_note.status(), NoteStatus::Consumed)); - assert_eq!(input_note.consumer_account_id().unwrap(), from_account_id); + assert!(matches!(input_note.status(), NoteStatus::Consumed { .. })); + if let NoteStatus::Consumed { + consumer_account_id: Some(consumer_account_id), + .. + } = input_note.status() + { + assert_eq!(consumer_account_id, from_account_id); + } else { + panic!("Note should be consumed"); + } // Do a transfer from first account to second account with Recall. In this situation we'll do // the happy path where the `to_account_id` consumes the note @@ -338,7 +346,7 @@ async fn test_get_consumable_notes() { // Check that note is consumable by both accounts let consumable_notes = client.get_consumable_notes(None).unwrap(); - let relevant_accounts = &consumable_notes.first().unwrap().relevances; + let relevant_accounts = &consumable_notes.first().unwrap().1; assert_eq!(relevant_accounts.len(), 2); assert!(!client.get_consumable_notes(Some(from_account_id)).unwrap().is_empty()); assert!(!client.get_consumable_notes(Some(to_account_id)).unwrap().is_empty()); diff --git a/tests/integration/onchain_tests.rs b/tests/integration/onchain_tests.rs index 1d98c98f6..31f6966bf 100644 --- a/tests/integration/onchain_tests.rs +++ b/tests/integration/onchain_tests.rs @@ -227,8 +227,13 @@ async fn test_onchain_accounts() { // Check that the client doesn't know who consumed the note let input_note = client_1.get_input_note(notes[0].id()).unwrap(); - assert!(matches!(input_note.status(), NoteStatus::Consumed)); - assert!(input_note.consumer_account_id().is_none()); + assert!(matches!( + input_note.status(), + NoteStatus::Consumed { + consumer_account_id: None, + block_height: _ + } + )); let new_from_account_balance = client_1 .get_account(from_account_id)