From 6dbea72275a96f87e647ad8d92752cb5fa8434ed Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Tue, 4 Nov 2025 17:04:06 +0400 Subject: [PATCH] feat!(wallet): persist wallet events --- .../tari_walletd/src/handlers/accounts.rs | 3 +- .../tari_walletd/src/handlers/context.rs | 2 +- .../tari_walletd/src/handlers/helpers.rs | 3 +- applications/tari_walletd/src/handlers/rpc.rs | 2 +- .../tari_walletd/src/handlers/transaction.rs | 4 +- applications/tari_walletd/src/services/mod.rs | 4 +- .../src/services/template_monitor.rs | 3 +- .../template_lib_types/src/encrypted_data.rs | 1 + crates/template_lib_types/src/max_string.rs | 12 ++- crates/wallet/crypto/src/memo.rs | 48 +++++++++- crates/wallet/sdk/src/apis/events.rs | 51 +++++++++++ crates/wallet/sdk/src/apis/mod.rs | 1 + .../src/events.rs => sdk/src/models/event.rs} | 90 ++++++++++++++++--- crates/wallet/sdk/src/models/mod.rs | 2 + crates/wallet/sdk/src/sdk.rs | 5 ++ crates/wallet/sdk/src/storage/mod.rs | 5 +- crates/wallet/sdk/src/storage/writer.rs | 5 ++ .../src/account_monitor/monitor.rs | 7 +- .../src/account_monitor/scanner.rs | 38 +++++--- crates/wallet/sdk_services/src/lib.rs | 1 - .../src/transaction_service/service.rs | 15 ++-- .../sdk_services/src/utxo_scanner/scanner.rs | 3 +- .../src/utxo_scanner/scanner_round.rs | 18 ++-- .../src/utxo_scanner/utxo_recovery.rs | 26 ++---- .../sdk_services/src/utxo_scanner/worker.rs | 3 +- .../2023-02-08-122514_initial/up.sql | 16 +++- .../down.sql | 1 - .../up.sql | 2 - crates/wallet/storage_sqlite/src/schema.rs | 14 ++- crates/wallet/storage_sqlite/src/writer.rs | 51 ++++++++++- 30 files changed, 354 insertions(+), 82 deletions(-) create mode 100644 crates/wallet/sdk/src/apis/events.rs rename crates/wallet/{sdk_services/src/events.rs => sdk/src/models/event.rs} (53%) delete mode 100644 crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/down.sql delete mode 100644 crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/up.sql diff --git a/applications/tari_walletd/src/handlers/accounts.rs b/applications/tari_walletd/src/handlers/accounts.rs index a0043dc464..9a4d8f3531 100644 --- a/applications/tari_walletd/src/handlers/accounts.rs +++ b/applications/tari_walletd/src/handlers/accounts.rs @@ -30,9 +30,8 @@ use tari_ootle_wallet_sdk::{ stealth_transfer::{StealthTransferParams, TransferOutput}, substate::ValidatorScanResult, }, - models::{BranchAndKeyId, KeyBranch, KeyId, NewAccountData}, + models::{BranchAndKeyId, KeyBranch, KeyId, NewAccountData, TransactionSubmittedEvent}, }; -use tari_ootle_wallet_sdk_services::events::TransactionSubmittedEvent; use tari_template_builtin::ACCOUNT_TEMPLATE_ADDRESS; use tari_template_lib::{ constants::{STEALTH_TARI_RESOURCE_ADDRESS, XTR, XTR_FAUCET_COMPONENT_ADDRESS, XTR_FAUCET_VAULT_ADDRESS}, diff --git a/applications/tari_walletd/src/handlers/context.rs b/applications/tari_walletd/src/handlers/context.rs index c7875dbfbc..efe5f1953a 100644 --- a/applications/tari_walletd/src/handlers/context.rs +++ b/applications/tari_walletd/src/handlers/context.rs @@ -2,9 +2,9 @@ // SPDX-License-Identifier: BSD-3-Clause use axum_extra::headers::authorization::Bearer; +use tari_ootle_wallet_sdk::models::WalletEvent; use tari_ootle_wallet_sdk_services::{ account_monitor::AccountMonitorHandle, - events::WalletEvent, notify::Notify, transaction_service::TransactionServiceHandle, }; diff --git a/applications/tari_walletd/src/handlers/helpers.rs b/applications/tari_walletd/src/handlers/helpers.rs index 31ae89e9b0..b1123be4db 100644 --- a/applications/tari_walletd/src/handlers/helpers.rs +++ b/applications/tari_walletd/src/handlers/helpers.rs @@ -10,12 +10,11 @@ use tari_ootle_common_types::{ }; use tari_ootle_wallet_sdk::{ apis::accounts::{AccountsApi, AccountsApiError}, - models::{AccountWithAddress, DerivedKeyIndex}, + models::{AccountWithAddress, DerivedKeyIndex, TransactionFinalizedEvent, WalletEvent}, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, }; -use tari_ootle_wallet_sdk_services::events::{TransactionFinalizedEvent, WalletEvent}; use tari_template_builtin::ACCOUNT_TEMPLATE_ADDRESS; use tari_template_lib::models::ComponentAddress; use tari_transaction::TransactionId; diff --git a/applications/tari_walletd/src/handlers/rpc.rs b/applications/tari_walletd/src/handlers/rpc.rs index 7e204e5fb7..b47079df66 100644 --- a/applications/tari_walletd/src/handlers/rpc.rs +++ b/applications/tari_walletd/src/handlers/rpc.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause use axum_extra::headers::authorization::Bearer; -use tari_ootle_wallet_sdk_services::events::AuthLoginRequestEvent; +use tari_ootle_wallet_sdk::models::AuthLoginRequestEvent; use tari_wallet_daemon_client::{ permissions::JrpcPermission, types::{ diff --git a/applications/tari_walletd/src/handlers/transaction.rs b/applications/tari_walletd/src/handlers/transaction.rs index a66079836b..d2c50bace0 100644 --- a/applications/tari_walletd/src/handlers/transaction.rs +++ b/applications/tari_walletd/src/handlers/transaction.rs @@ -11,10 +11,10 @@ use tari_engine_types::ToByteType; use tari_ootle_common_types::{optional::Optional, Epoch, Network}; use tari_ootle_wallet_sdk::{ apis::{config::ConfigKey, transaction::TransactionApiError}, - models::{BranchAndKeyId, KeyBranch}, + models::{BranchAndKeyId, KeyBranch, WalletEvent}, network::WalletQueryErrorStatus, }; -use tari_ootle_wallet_sdk_services::{events::WalletEvent, transaction_service::TransactionServiceError}; +use tari_ootle_wallet_sdk_services::transaction_service::TransactionServiceError; use tari_transaction::{args, Transaction}; use tari_transaction_manifest::parse_manifest; use tari_wallet_daemon_client::{ diff --git a/applications/tari_walletd/src/services/mod.rs b/applications/tari_walletd/src/services/mod.rs index 9c28e1f2b8..a90bf5b668 100644 --- a/applications/tari_walletd/src/services/mod.rs +++ b/applications/tari_walletd/src/services/mod.rs @@ -15,13 +15,13 @@ use futures::{future, future::BoxFuture, FutureExt}; pub use session_store::*; use tari_ootle_common_types::optional::IsNotFoundError; use tari_ootle_wallet_sdk::{ + models::WalletEvent, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, }; use tari_ootle_wallet_sdk_services::{ account_monitor::{AccountMonitor, AccountMonitorHandle}, - events::WalletEvent, notify::Notify, transaction_service::{TransactionService, TransactionServiceHandle}, utxo_scanner::{StealthUtxoScannerWorker, UtxoRecovery}, @@ -54,7 +54,7 @@ where let utxo_recovery_join_handle = { let sdk = wallet_sdk.clone(); let notify_sub = utxo_scanner_handle.subscribe_notifications(); - tokio::spawn(UtxoRecovery::new(sdk).run(notify_sub)) + tokio::spawn(UtxoRecovery::new(sdk).with_notify(notify.clone()).run(notify_sub)) }; let (account_monitor, account_monitor_handle) = diff --git a/applications/tari_walletd/src/services/template_monitor.rs b/applications/tari_walletd/src/services/template_monitor.rs index 1ece7f8d1c..9e617dc770 100644 --- a/applications/tari_walletd/src/services/template_monitor.rs +++ b/applications/tari_walletd/src/services/template_monitor.rs @@ -10,11 +10,12 @@ use tari_ootle_common_types::{ substate_type::SubstateType, }; use tari_ootle_wallet_sdk::{ + models::WalletEvent, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, }; -use tari_ootle_wallet_sdk_services::{events::WalletEvent, notify::Notify}; +use tari_ootle_wallet_sdk_services::notify::Notify; use tari_shutdown::ShutdownSignal; use tari_template_abi::TemplateDef; use tari_template_lib::types::TemplateAddress; diff --git a/crates/template_lib_types/src/encrypted_data.rs b/crates/template_lib_types/src/encrypted_data.rs index a0e189a4e8..b053cad120 100644 --- a/crates/template_lib_types/src/encrypted_data.rs +++ b/crates/template_lib_types/src/encrypted_data.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::max_bytes::MaxBytes; +/// The maximum size of EncryptedData including the maximum memo size (335 bytes) const MAX_SIZE: usize = EncryptedData::ENCRYPTED_DATA_SIZE_WITHOUT_MEMO + EncryptedData::MAX_MEMO_SIZE; /// Used by the receiver to determine the value and mask of the commitment. Used in stealth and confidential transfers, diff --git a/crates/template_lib_types/src/max_string.rs b/crates/template_lib_types/src/max_string.rs index ca5bc517fc..562bb8a544 100644 --- a/crates/template_lib_types/src/max_string.rs +++ b/crates/template_lib_types/src/max_string.rs @@ -1,7 +1,11 @@ // Copyright 2025 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -use std::ops::{Deref, DerefMut}; +use std::{ + fmt, + fmt::Display, + ops::{Deref, DerefMut}, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MaxString { @@ -61,6 +65,12 @@ impl<'de, const N: usize> serde::Deserialize<'de> for MaxString { } } +impl Display for MaxString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.s.as_ref().fmt(f) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wallet/crypto/src/memo.rs b/crates/wallet/crypto/src/memo.rs index 58d890c6fd..432baa2ff3 100644 --- a/crates/wallet/crypto/src/memo.rs +++ b/crates/wallet/crypto/src/memo.rs @@ -1,9 +1,9 @@ // Copyright 2025 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -use std::{cmp, io}; +use std::{cmp, fmt::Display, io}; -use tari_template_lib::types::{MaxBytes, MaxString}; +use tari_template_lib::types::{hex::write_hex_fmt, MaxBytes, MaxString}; /// These are selected to be out of range of the Minotari memo field tags /// See: @@ -118,6 +118,20 @@ impl Memo { self.len() == 0 } + /// Attempt to interpret the memo as a UTF-8 string, regardless of its variant. + /// Returns `None` if the memo cannot be represented as a UTF-8 string. + pub fn as_utf8_str(&self) -> Option<&str> { + match self { + Self::Message(s) => Some(s), + Self::Bytes(b) => str::from_utf8(b.as_ref()).ok(), + Self::PayRefAndBytes(body) => { + let (_, msg_bytes) = split_len_prefixed(body)?; + str::from_utf8(msg_bytes).ok() + }, + Self::U256(_) => None, + } + } + pub fn as_memo_message(&self) -> Option<&str> { match self { Self::Message(s) => Some(s), @@ -241,6 +255,36 @@ impl Memo { } } +impl Display for Memo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::U256(b) => { + write!(f, "U256(")?; + write_hex_fmt(f, b)?; + write!(f, ")") + }, + Self::Message(s) => write!(f, "Message({})", s), + Self::Bytes(b) => { + write!(f, "Bytes(")?; + write_hex_fmt(f, b)?; + write!(f, ")") + }, + Self::PayRefAndBytes(b) => { + let Some((pay_ref, msg_bytes)) = split_len_prefixed(b) else { + return write!(f, "PayRefAndBytes()"); + }; + write!( + f, + "PayRefAndBytes(pay_ref: {}, bytes: ", + std::str::from_utf8(pay_ref).unwrap_or(""), + )?; + write_hex_fmt(f, msg_bytes)?; + write!(f, ")") + }, + } + } +} + fn read_until_len_or_eof(mut buf: &mut [u8], reader: &mut R, max_len: usize) -> io::Result { let mut bytes_read = 0; while !buf.is_empty() { diff --git a/crates/wallet/sdk/src/apis/events.rs b/crates/wallet/sdk/src/apis/events.rs new file mode 100644 index 0000000000..844a7168db --- /dev/null +++ b/crates/wallet/sdk/src/apis/events.rs @@ -0,0 +1,51 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_ootle_common_types::optional::IsNotFoundError; + +use crate::{ + models::WalletEvent, + storage::{WalletEventStoreWriter, WalletStorageError, WriteableWalletStore}, +}; + +#[derive(Clone)] +pub struct EventsApi<'a, TStore> { + store: &'a TStore, +} + +impl<'a, TStore> EventsApi<'a, TStore> { + pub(crate) fn new(store: &'a TStore) -> Self { + Self { store } + } +} + +impl<'a, TStore: WriteableWalletStore> EventsApi<'a, TStore> { + pub fn log_event(&self, event: &WalletEvent) -> Result<(), EventApiError> { + self.store.with_write_tx(|tx| tx.append_wallet_event(event))?; + Ok(()) + } + + // pub fn search_events( + // &self, + // event_type: Option, + // limit: usize, + // offset: usize, + // ) -> Result, EventApiError> { + // let events = self + // .store + // .with_read_tx(|tx| tx.search_wallet_events(event_type, limit, offset))?; + // Ok(events) + // } +} + +#[derive(thiserror::Error, Debug)] +pub enum EventApiError { + #[error("Store error: {0}")] + StoreError(#[from] WalletStorageError), +} + +impl IsNotFoundError for EventApiError { + fn is_not_found_error(&self) -> bool { + matches!(self, EventApiError::StoreError(e) if e.is_not_found_error()) + } +} diff --git a/crates/wallet/sdk/src/apis/mod.rs b/crates/wallet/sdk/src/apis/mod.rs index 0317c08043..a49439e0e6 100644 --- a/crates/wallet/sdk/src/apis/mod.rs +++ b/crates/wallet/sdk/src/apis/mod.rs @@ -6,6 +6,7 @@ pub mod confidential_crypto; pub mod confidential_outputs; pub mod confidential_transfer; pub mod config; +pub mod events; pub mod key_manager; pub mod locks; pub mod non_fungible_tokens; diff --git a/crates/wallet/sdk_services/src/events.rs b/crates/wallet/sdk/src/models/event.rs similarity index 53% rename from crates/wallet/sdk_services/src/events.rs rename to crates/wallet/sdk/src/models/event.rs index 5dbb2156ec..56f466d557 100644 --- a/crates/wallet/sdk_services/src/events.rs +++ b/crates/wallet/sdk/src/models/event.rs @@ -1,11 +1,14 @@ // Copyright 2023 The Tari Project // SPDX-License-Identifier: BSD-3-Clause +use std::fmt::Display; + use tari_engine_types::commit_result::FinalizeResult; -use tari_ootle_wallet_sdk::models::{Account, NewAccountData, TransactionStatus}; use tari_template_lib::{models::UtxoAddress, prelude::ComponentAddress}; use tari_transaction::TransactionId; +use crate::models::{Account, NewAccountData, TransactionStatus}; + #[derive(Debug, Clone)] pub enum WalletEvent { TransactionSubmitted(TransactionSubmittedEvent), @@ -13,13 +16,19 @@ pub enum WalletEvent { TransactionInvalid(TransactionInvalidEvent), AccountCreatedOnChain(AccountCreatedEvent), AccountChangedOnChain(AccountChangedEvent), - AuthLoginRequest(#[allow(dead_code)] AuthLoginRequestEvent), + AuthLoginRequest(AuthLoginRequestEvent), UtxoRecoveryStarted(UtxoRecoveryStartedEvent), UtxoRecovered(UtxoRecoveredEvent), UtxoRecoveryCompleted(UtxoRecoveryCompletedEvent), UtxoSpent(UtxoSpentEvent), } +impl WalletEvent { + pub fn as_event_type(&self) -> WalletEventType { + self.into() + } +} + impl From for WalletEvent { fn from(value: TransactionSubmittedEvent) -> Self { Self::TransactionSubmitted(value) @@ -80,14 +89,20 @@ impl From for WalletEvent { } } -#[derive(Debug, Clone)] +impl Display for WalletEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + WalletEventType::from(self).fmt(f) + } +} + +#[derive(Debug, Clone, serde::Serialize)] pub struct TransactionSubmittedEvent { pub transaction_id: TransactionId, /// Set to Some if this transaction results in a new account pub new_account: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct TransactionFinalizedEvent { pub transaction_id: TransactionId, pub finalize: FinalizeResult, @@ -95,18 +110,19 @@ pub struct TransactionFinalizedEvent { pub status: TransactionStatus, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct AccountCreatedEvent { pub account: Account, pub _created_by_tx: TransactionId, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct AccountChangedEvent { pub account_address: ComponentAddress, + pub version: u32, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct TransactionInvalidEvent { pub transaction_id: TransactionId, pub status: TransactionStatus, @@ -114,27 +130,77 @@ pub struct TransactionInvalidEvent { pub final_fee: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct AuthLoginRequestEvent; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct UtxoRecoveredEvent { pub address: UtxoAddress, pub account_address: ComponentAddress, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct UtxoRecoveryStartedEvent { pub round_id: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct UtxoRecoveryCompletedEvent { pub round_id: usize, pub num_recovered: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct UtxoSpentEvent { + pub account_address: ComponentAddress, pub address: UtxoAddress, } + +#[derive(Debug, Clone, Copy)] +pub enum WalletEventType { + TransactionSubmitted, + TransactionFinalized, + TransactionInvalid, + AccountCreatedOnChain, + AccountChangedOnChain, + AuthLoginRequest, + UtxoRecoveryStarted, + UtxoRecovered, + UtxoRecoveryCompleted, + UtxoSpent, +} + +impl Display for WalletEventType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + WalletEventType::TransactionSubmitted => "TransactionSubmitted", + WalletEventType::TransactionFinalized => "TransactionFinalized", + WalletEventType::TransactionInvalid => "TransactionInvalid", + WalletEventType::AccountCreatedOnChain => "AccountCreatedOnChain", + WalletEventType::AccountChangedOnChain => "AccountChangedOnChain", + WalletEventType::AuthLoginRequest => "AuthLoginRequest", + WalletEventType::UtxoRecoveryStarted => "UtxoRecoveryStarted", + WalletEventType::UtxoRecovered => "UtxoRecovered", + WalletEventType::UtxoRecoveryCompleted => "UtxoRecoveryCompleted", + WalletEventType::UtxoSpent => "UtxoSpent", + }; + write!(f, "{}", s) + } +} + +impl From<&WalletEvent> for WalletEventType { + fn from(event: &WalletEvent) -> Self { + match event { + WalletEvent::TransactionSubmitted(_) => Self::TransactionSubmitted, + WalletEvent::TransactionFinalized(_) => Self::TransactionFinalized, + WalletEvent::TransactionInvalid(_) => Self::TransactionInvalid, + WalletEvent::AccountCreatedOnChain(_) => Self::AccountCreatedOnChain, + WalletEvent::AccountChangedOnChain(_) => Self::AccountChangedOnChain, + WalletEvent::AuthLoginRequest(_) => Self::AuthLoginRequest, + WalletEvent::UtxoRecoveryStarted(_) => Self::UtxoRecoveryStarted, + WalletEvent::UtxoRecovered(_) => Self::UtxoRecovered, + WalletEvent::UtxoRecoveryCompleted(_) => Self::UtxoRecoveryCompleted, + WalletEvent::UtxoSpent(_) => Self::UtxoSpent, + } + } +} diff --git a/crates/wallet/sdk/src/models/mod.rs b/crates/wallet/sdk/src/models/mod.rs index 49886040f3..7541f2fed0 100644 --- a/crates/wallet/sdk/src/models/mod.rs +++ b/crates/wallet/sdk/src/models/mod.rs @@ -5,6 +5,7 @@ mod account; mod authored_template; mod confidential_output; mod config; +mod event; mod key; mod lock_guard; mod non_fungible_tokens; @@ -20,6 +21,7 @@ pub use account::*; pub use authored_template::*; pub use confidential_output::*; pub use config::Config; +pub use event::*; pub use key::*; pub use lock_guard::*; pub use non_fungible_tokens::*; diff --git a/crates/wallet/sdk/src/sdk.rs b/crates/wallet/sdk/src/sdk.rs index 61356054d2..83efd2f667 100644 --- a/crates/wallet/sdk/src/sdk.rs +++ b/crates/wallet/sdk/src/sdk.rs @@ -23,6 +23,7 @@ use crate::{ confidential_outputs::ConfidentialOutputsApi, confidential_transfer::ConfidentialTransferApi, config::{ConfigApi, ConfigApiError, ConfigKey}, + events::EventsApi, key_manager::{KeyManagerApi, KeyManagerApiError}, locks::LocksApi, non_fungible_tokens::NonFungibleTokensApi, @@ -160,6 +161,10 @@ where LocksApi::new(&self.store) } + pub fn event_api(&self) -> EventsApi<'_, TStore> { + EventsApi::new(&self.store) + } + /// Returns the KeyManager API for the wallet. pub fn key_manager_api(&self) -> KeyManagerApi<'_, TStore> { let network = self.config.network; diff --git a/crates/wallet/sdk/src/storage/mod.rs b/crates/wallet/sdk/src/storage/mod.rs index 800dca14f0..0cbe0f86a6 100644 --- a/crates/wallet/sdk/src/storage/mod.rs +++ b/crates/wallet/sdk/src/storage/mod.rs @@ -36,7 +36,10 @@ impl ReadableWalletStore for &T { } pub trait WriteableWalletStore: ReadableWalletStore { - type WriteTransaction<'a>: WalletStoreWriter + Deref> + DerefMut + type WriteTransaction<'a>: WalletStoreWriter + + WalletEventStoreWriter + + Deref> + + DerefMut where Self: 'a; fn create_write_tx(&self) -> Result, WalletStorageError>; diff --git a/crates/wallet/sdk/src/storage/writer.rs b/crates/wallet/sdk/src/storage/writer.rs index f6745b8153..677900ac05 100644 --- a/crates/wallet/sdk/src/storage/writer.rs +++ b/crates/wallet/sdk/src/storage/writer.rs @@ -30,6 +30,7 @@ use crate::{ SubstateModel, UtxoUnspent, VaultModel, + WalletEvent, WalletLockId, WalletTransactionUpdate, }, @@ -216,3 +217,7 @@ pub trait WalletStoreWriter: CommittableStore { public_nonce: RistrettoPublicKeyBytes, ) -> Result<(), WalletStorageError>; } + +pub trait WalletEventStoreWriter { + fn append_wallet_event(&mut self, event: &WalletEvent) -> Result<(), WalletStorageError>; +} diff --git a/crates/wallet/sdk_services/src/account_monitor/monitor.rs b/crates/wallet/sdk_services/src/account_monitor/monitor.rs index 8589bc3323..a5b2376ea2 100644 --- a/crates/wallet/sdk_services/src/account_monitor/monitor.rs +++ b/crates/wallet/sdk_services/src/account_monitor/monitor.rs @@ -16,7 +16,7 @@ use tari_ootle_wallet_sdk::{ substate::SubstateApiError, transaction::TransactionApiError, }, - models::NewAccountData, + models::{NewAccountData, WalletEvent}, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, @@ -35,7 +35,6 @@ use crate::{ handle::{AccountMonitorHandle, AccountMonitorRequest}, scanner::AccountScanner, }, - events::WalletEvent, notify::Notify, utxo_scanner::UtxoScannerHandle, }; @@ -249,6 +248,10 @@ where } async fn on_event(&mut self, event: WalletEvent) -> Result<(), AccountMonitorError> { + debug!(target: LOG_TARGET, "🏦 Account monitor received event: {}", event); + if let Err(err) = self.wallet_sdk.event_api().log_event(&event) { + warn!(target: LOG_TARGET, "Failed to store wallet event: {}", err); + } match event { WalletEvent::TransactionSubmitted(event) => { if let Some(account) = event.new_account { diff --git a/crates/wallet/sdk_services/src/account_monitor/scanner.rs b/crates/wallet/sdk_services/src/account_monitor/scanner.rs index 00b5961145..d817c656ba 100644 --- a/crates/wallet/sdk_services/src/account_monitor/scanner.rs +++ b/crates/wallet/sdk_services/src/account_monitor/scanner.rs @@ -14,7 +14,7 @@ use tari_engine_types::{ use tari_ootle_common_types::optional::{IsNotFoundError, Optional}; use tari_ootle_wallet_sdk::{ apis::substate::ValidatorScanResult, - models::{AccountUpdate, NewAccountData, NonFungibleToken}, + models::{AccountChangedEvent, AccountCreatedEvent, AccountUpdate, NewAccountData, NonFungibleToken, WalletEvent}, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, @@ -27,11 +27,7 @@ use tari_template_lib::{ }; use tari_transaction::TransactionId; -use crate::{ - account_monitor::monitor::AccountMonitorError, - events::{AccountChangedEvent, AccountCreatedEvent, WalletEvent}, - notify::Notify, -}; +use crate::{account_monitor::monitor::AccountMonitorError, notify::Notify}; const LOG_TARGET: &str = "tari::ootle::wallet_services::account_monitor"; @@ -149,7 +145,10 @@ where .await?; } - self.notify.notify(AccountChangedEvent { account_address }); + self.notify.notify(AccountChangedEvent { + account_address, + version: account_substate_id.version(), + }); } Ok(is_updated) @@ -465,6 +464,7 @@ where Ok(value) => Some(( a.as_component_address().expect("BUG: substate id is a component"), value, + s.version(), )), Err(e) => { error!( @@ -478,7 +478,7 @@ where let mut updated_accounts = HashSet::new(); // Find and process all new/existing vaults - for (account_address, value) in accounts { + for (account_address, value, account_version) in accounts { // If we know about this account, mark it as on-chain (if it isn't already) if self.mark_account_as_on_chain(&account_address).optional()?.is_none() { continue; @@ -514,7 +514,7 @@ where } if has_changed { - updated_accounts.insert(account_address); + updated_accounts.insert((account_address, account_version)); } } @@ -547,14 +547,21 @@ where }); // Check if this vault is associated with an account - if accounts_api.get_account_by_address(&account_addr).optional()?.is_none() { + if !accounts_api.exists_by_address(&account_addr)? { info!( target: LOG_TARGET, "🏦 Vault {} not in any known account", vault_addr, ); continue; - } + }; + + // Version unchanged + let account_version = substate_api + .get_substate(&account_addr.into()) + .optional()? + .map(|s| s.substate_id.version()) + .unwrap_or(0); self.add_vault_to_account_if_not_exist(&account_addr, vault_id, vault) .await?; @@ -577,7 +584,7 @@ where // Update the vault balance / confidential outputs self.refresh_vault(account_addr, vault_id, vault, updated_nfts).await?; - updated_accounts.insert(account_addr); + updated_accounts.insert((account_addr, account_version)); } // Update UTXOs @@ -612,8 +619,11 @@ where updated_accounts.len(), tx_id ); - for account_address in updated_accounts { - self.notify.notify(AccountChangedEvent { account_address }); + for (account_address, version) in updated_accounts { + self.notify.notify(AccountChangedEvent { + account_address, + version, + }); } } diff --git a/crates/wallet/sdk_services/src/lib.rs b/crates/wallet/sdk_services/src/lib.rs index ec3011cc42..5cb0c20a0c 100644 --- a/crates/wallet/sdk_services/src/lib.rs +++ b/crates/wallet/sdk_services/src/lib.rs @@ -3,7 +3,6 @@ pub mod account_monitor; pub mod account_recovery; -pub mod events; #[cfg(feature = "indexer_client")] pub mod indexer_rest_api; pub mod notify; diff --git a/crates/wallet/sdk_services/src/transaction_service/service.rs b/crates/wallet/sdk_services/src/transaction_service/service.rs index fbfce29d9a..a5b9120651 100644 --- a/crates/wallet/sdk_services/src/transaction_service/service.rs +++ b/crates/wallet/sdk_services/src/transaction_service/service.rs @@ -7,7 +7,15 @@ use log::*; use tari_engine_types::commit_result::ExecuteResult; use tari_ootle_common_types::optional::IsNotFoundError; use tari_ootle_wallet_sdk::{ - models::{NewAccountData, TransactionStatus, WalletLockId}, + models::{ + NewAccountData, + TransactionFinalizedEvent, + TransactionInvalidEvent, + TransactionStatus, + TransactionSubmittedEvent, + WalletEvent, + WalletLockId, + }, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, @@ -24,10 +32,7 @@ use super::{ error::TransactionServiceError, handle::{TransactionServiceHandle, TransactionServiceRequest}, }; -use crate::{ - events::{TransactionFinalizedEvent, TransactionInvalidEvent, TransactionSubmittedEvent, WalletEvent}, - notify::Notify, -}; +use crate::notify::Notify; const LOG_TARGET: &str = "tari::ootle::wallet_services::transaction_service"; diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs index 9a0c320725..c4fc506614 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs @@ -3,7 +3,7 @@ use tari_ootle_common_types::optional::IsNotFoundError; use tari_ootle_wallet_sdk::{ - models::AccountWithAddress, + models::{AccountWithAddress, WalletEvent}, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, @@ -11,7 +11,6 @@ use tari_ootle_wallet_sdk::{ use tari_template_lib::models::ResourceAddress; use crate::{ - events::WalletEvent, notify::Notify, utxo_scanner::{StealthScannerApiError, UtxoScanRoundStats, UtxoScannerRound}, }; diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs index 8947e5807d..2d80f9939f 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs @@ -15,18 +15,23 @@ use tari_ootle_common_types::{ StateVersion, }; use tari_ootle_wallet_sdk::{ - models::{AccountWithAddress, StartOfShard, UtxoSpent, UtxoUnspent, WalletSecretKey, WalletUtxoUpdate}, + models::{ + AccountWithAddress, + StartOfShard, + UtxoSpent, + UtxoSpentEvent, + UtxoUnspent, + WalletEvent, + WalletSecretKey, + WalletUtxoUpdate, + }, network::{StatusResponseError, UtxoUpdateStream, WalletNetworkInterface}, storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, WalletSdk, }; use tari_template_lib::models::{ComponentAddress, ResourceAddress, UtxoAddress}; -use crate::{ - events::{UtxoSpentEvent, WalletEvent}, - notify::Notify, - utxo_scanner::StealthScannerApiError, -}; +use crate::{notify::Notify, utxo_scanner::StealthScannerApiError}; const LOG_TARGET: &str = "tari::ootle::wallet_services::scanner_round"; // TODO: either fetch num preshards from the network or we should hardcode it to a single value for all apps @@ -233,6 +238,7 @@ where for spent in self.utxos_to_spend.drain(..) { if Self::spend(tx, self.resource_address, &spent)? { self.notify.notify(UtxoSpentEvent { + account_address: *self.account.component_address(), address: UtxoAddress::new(*self.resource_address, spent.id), }); num_spent += 1; diff --git a/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs b/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs index 07fc6d558f..66ce80b6c9 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs @@ -10,7 +10,13 @@ use tari_ootle_common_types::{ Network, }; use tari_ootle_wallet_sdk::{ - models::AccountAndViewKeys, + models::{ + AccountAndViewKeys, + UtxoRecoveredEvent, + UtxoRecoveryCompletedEvent, + UtxoRecoveryStartedEvent, + WalletEvent, + }, network::{StatusResponseError, WalletNetworkInterface}, storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, WalletSdk, @@ -18,11 +24,7 @@ use tari_ootle_wallet_sdk::{ use tari_template_lib::models::{ComponentAddress, ResourceAddress, UtxoAddress, UtxoId}; use tokio::sync::watch; -use crate::{ - events::{UtxoRecoveredEvent, UtxoRecoveryCompletedEvent, UtxoRecoveryStartedEvent, WalletEvent}, - notify::Notify, - utxo_scanner::StealthScannerApiError, -}; +use crate::{notify::Notify, utxo_scanner::StealthScannerApiError}; const LOG_TARGET: &str = "tari::ootle::wallet_services::utxo_recovery"; @@ -102,18 +104,6 @@ where if batch.is_empty() { debug!(target: LOG_TARGET, "✅ No more UTXOs to process"); - - if self.round_id == 0 { - self.notify(UtxoRecoveryStartedEvent { - round_id: self.round_id, - }); - self.notify(UtxoRecoveryCompletedEvent { - round_id: self.round_id, - num_recovered, - }); - - self.round_id += 1; - } return Ok(()); } diff --git a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs index 18ea1a7aa5..3d47869e1e 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs @@ -12,6 +12,7 @@ use futures_bounded::PushError; use log::{info, warn}; use tari_ootle_common_types::optional::IsNotFoundError; use tari_ootle_wallet_sdk::{ + models::WalletEvent, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, @@ -22,7 +23,7 @@ use tokio::{ task::JoinHandle, }; -use crate::{events::WalletEvent, notify::Notify, utxo_scanner::UtxoScanner}; +use crate::{notify::Notify, utxo_scanner::UtxoScanner}; const LOG_TARGET: &str = "tari::ootle::wallet_services::stealth_utxo_scanner"; diff --git a/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql b/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql index 9a9b1aa9ca..6307666eea 100644 --- a/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql +++ b/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql @@ -180,6 +180,7 @@ CREATE TABLE locks ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, transaction_id TEXT NULL, + timeout_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -297,4 +298,17 @@ CREATE TABLE utxo_process_queue ); CREATE UNIQUE INDEX utxo_process_queue_account_resource_tag_nonce_uniq - ON utxo_process_queue (account_id, resource_address, utxo_tag, public_nonce); \ No newline at end of file + ON utxo_process_queue (account_id, resource_address, utxo_tag, public_nonce); + +CREATE TABLE wallet_events +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NULL, + event_type TEXT NOT NULL, + event_data TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE NO ACTION +); + +CREATE INDEX idx_wallet_events_account_id ON wallet_events (account_id) where account_id IS NOT NULL; +CREATE INDEX idx_wallet_events_event_type ON wallet_events (event_type); diff --git a/crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/down.sql b/crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/down.sql deleted file mode 100644 index d9a93fe9a1..0000000000 --- a/crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/down.sql +++ /dev/null @@ -1 +0,0 @@ --- This file should undo anything in `up.sql` diff --git a/crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/up.sql b/crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/up.sql deleted file mode 100644 index 13073afeae..0000000000 --- a/crates/wallet/storage_sqlite/migrations/2025-10-29-122554_add_timeout_at_to_locks/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE locks - ADD COLUMN timeout_at DATETIME NULL; \ No newline at end of file diff --git a/crates/wallet/storage_sqlite/src/schema.rs b/crates/wallet/storage_sqlite/src/schema.rs index 95a5e9b048..dc70e1f98a 100644 --- a/crates/wallet/storage_sqlite/src/schema.rs +++ b/crates/wallet/storage_sqlite/src/schema.rs @@ -96,8 +96,8 @@ diesel::table! { locks (id) { id -> Integer, transaction_id -> Nullable, - created_at -> Timestamp, timeout_at -> Nullable, + created_at -> Timestamp, } } @@ -242,6 +242,16 @@ diesel::table! { } } +diesel::table! { + wallet_events (id) { + id -> Nullable, + account_id -> Nullable, + event_type -> Text, + event_data -> Text, + created_at -> Timestamp, + } +} + diesel::table! { webauthn_registration_passkeys (id) { id -> Integer, @@ -271,6 +281,7 @@ diesel::joinable!(utxo_process_queue -> accounts (account_id)); diesel::joinable!(vault_locks -> locks (lock_id)); diesel::joinable!(vault_locks -> vaults (vault_id)); diesel::joinable!(vaults -> accounts (account_id)); +diesel::joinable!(wallet_events -> accounts (account_id)); diesel::joinable!(webauthn_registration_passkeys -> webauthn_registrations (registration_id)); diesel::allow_tables_to_appear_in_same_query!( @@ -291,6 +302,7 @@ diesel::allow_tables_to_appear_in_same_query!( utxo_process_queue, vault_locks, vaults, + wallet_events, webauthn_registration_passkeys, webauthn_registrations, ); diff --git a/crates/wallet/storage_sqlite/src/writer.rs b/crates/wallet/storage_sqlite/src/writer.rs index 82597405cb..8efcd9c0a7 100644 --- a/crates/wallet/storage_sqlite/src/writer.rs +++ b/crates/wallet/storage_sqlite/src/writer.rs @@ -43,10 +43,11 @@ use tari_ootle_wallet_sdk::{ TransactionStatus, UtxoUnspent, VaultModel, + WalletEvent, WalletLockId, WalletTransactionUpdate, }, - storage::{CommittableStore, WalletStorageError, WalletStoreReader, WalletStoreWriter}, + storage::{CommittableStore, WalletEventStoreWriter, WalletStorageError, WalletStoreReader, WalletStoreWriter}, }; use tari_template_lib::{ models::{ComponentAddress, NonFungibleId, ResourceAddress, UtxoAddress, UtxoId, VaultId}, @@ -1666,6 +1667,54 @@ impl WalletStoreWriter for WriteTransaction<'_> { } } +impl WalletEventStoreWriter for WriteTransaction<'_> { + fn append_wallet_event(&mut self, event: &WalletEvent) -> Result<(), WalletStorageError> { + const OPERATION: &str = "append_wallet_event"; + use crate::schema::{accounts, wallet_events}; + + let (maybe_account, payload) = match event { + WalletEvent::TransactionSubmitted(payload) => ( + payload.new_account.as_ref().map(|a| a.address), + serialize_json(payload)?, + ), + WalletEvent::TransactionFinalized(payload) => (None, serialize_json(payload)?), + WalletEvent::TransactionInvalid(payload) => (None, serialize_json(payload)?), + WalletEvent::AccountCreatedOnChain(payload) => { + (Some(payload.account.component_address), serialize_json(payload)?) + }, + WalletEvent::AccountChangedOnChain(payload) => (Some(payload.account_address), serialize_json(payload)?), + WalletEvent::AuthLoginRequest(payload) => (None, serialize_json(payload)?), + WalletEvent::UtxoRecoveryStarted(payload) => (None, serialize_json(payload)?), + WalletEvent::UtxoRecovered(payload) => (Some(payload.account_address), serialize_json(payload)?), + WalletEvent::UtxoRecoveryCompleted(payload) => (None, serialize_json(payload)?), + WalletEvent::UtxoSpent(payload) => (Some(payload.account_address), serialize_json(payload)?), + }; + + let maybe_account = maybe_account.map(|addr| addr.to_string()); + + let account_id = match maybe_account { + Some(addr) => accounts::table + .select(accounts::id) + .filter(accounts::address.eq(addr)) + .first::(self.connection()) + .optional() + .map_err(|e| WalletStorageError::general(OPERATION, e))?, + None => None, + }; + + diesel::insert_into(wallet_events::table) + .values(( + wallet_events::account_id.eq(account_id), + wallet_events::event_type.eq(event.as_event_type().to_string()), + wallet_events::event_data.eq(payload), + )) + .execute(self.connection()) + .map_err(|e| WalletStorageError::general(OPERATION, e))?; + + Ok(()) + } +} + impl Drop for WriteTransaction<'_> { fn drop(&mut self) { if !self.transaction.is_done() {