diff --git a/Cargo.lock b/Cargo.lock index b7cd580432..07ce088849 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5336,6 +5336,7 @@ dependencies = [ name = "tari_common_types" version = "0.50.0-pre.1" dependencies = [ + "base64 0.21.0", "borsh", "chacha20poly1305 0.10.1", "digest 0.9.0", diff --git a/applications/tari_console_wallet/src/ui/app.rs b/applications/tari_console_wallet/src/ui/app.rs index 3dfaa003a9..34292d10e0 100644 --- a/applications/tari_console_wallet/src/ui/app.rs +++ b/applications/tari_console_wallet/src/ui/app.rs @@ -34,6 +34,7 @@ use crate::{ ui::{ components::{ base_node::BaseNode, + burn_tab::BurnTab, contacts_tab::ContactsTab, events_component::EventsComponent, log_tab::LogTab, @@ -88,6 +89,7 @@ impl App { .add("Transactions".into(), Box::new(TransactionsTab::new())) .add("Send".into(), Box::new(SendTab::new(&app_state))) .add("Receive".into(), Box::new(ReceiveTab::new())) + .add("Burn".into(), Box::new(BurnTab::new(&app_state))) .add("Contacts".into(), Box::new(ContactsTab::new())) .add("Network".into(), Box::new(NetworkTab::new(base_node_selected))) .add("Events".into(), Box::new(EventsComponent::new())) diff --git a/applications/tari_console_wallet/src/ui/components/burn_tab.rs b/applications/tari_console_wallet/src/ui/components/burn_tab.rs new file mode 100644 index 0000000000..89588741d3 --- /dev/null +++ b/applications/tari_console_wallet/src/ui/components/burn_tab.rs @@ -0,0 +1,691 @@ +// Copyright 2022 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use std::fs; + +use log::*; +use tari_core::transactions::tari_amount::MicroTari; +use tari_wallet::output_manager_service::UtxoSelectionCriteria; +use tokio::{runtime::Handle, sync::watch}; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{Block, Borders, ListItem, Paragraph, Wrap}, + Frame, +}; +use unicode_width::UnicodeWidthStr; + +use crate::ui::{ + components::{balance::Balance, Component, KeyHandled}, + state::{AppState, UiTransactionBurnStatus}, + ui_burnt_proof::UiBurntProof, + widgets::{draw_dialog, MultiColumnList, WindowedListState}, + MAX_WIDTH, +}; + +const LOG_TARGET: &str = "wallet::console_wallet::burn_tab "; + +pub struct BurnTab { + balance: Balance, + burn_input_mode: BurnInputMode, + burnt_proof_filepath_field: String, + claim_public_key_field: String, + amount_field: String, + fee_field: String, + message_field: String, + error_message: Option, + success_message: Option, + offline_message: Option, + burn_result_watch: Option>, + confirmation_dialog: Option, + proofs_list_state: WindowedListState, + show_proofs: bool, +} + +impl BurnTab { + pub fn new(app_state: &AppState) -> Self { + Self { + balance: Balance::new(), + burn_input_mode: BurnInputMode::None, + burnt_proof_filepath_field: String::new(), + claim_public_key_field: String::new(), + amount_field: String::new(), + fee_field: app_state.get_default_fee_per_gram().as_u64().to_string(), + message_field: String::new(), + error_message: None, + success_message: None, + offline_message: None, + proofs_list_state: WindowedListState::new(), + burn_result_watch: None, + confirmation_dialog: None, + show_proofs: true, + } + } + + #[allow(clippy::too_many_lines)] + fn draw_burn_form(&self, f: &mut Frame, area: Rect, _app_state: &AppState) + where B: Backend { + let block = Block::default().borders(Borders::ALL).title(Span::styled( + "Burn Tari", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )); + f.render_widget(block, area); + + let vert_chunks = Layout::default() + .constraints( + [ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ] + .as_ref(), + ) + .margin(1) + .split(area); + + let instructions = Paragraph::new(vec![ + Spans::from(vec![ + Span::raw("Press "), + Span::styled("P", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to edit "), + Span::styled("Burn Proof Filepath", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" field, "), + Span::styled("C", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to edit "), + Span::styled("Claim Public Key", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" field, "), + Span::styled("A", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to edit "), + Span::styled("Amount", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" and "), + Span::styled("F", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to edit "), + Span::styled("Fee-Per-Gram", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" field,"), + Span::styled("B", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to view burnt proofs."), + ]), + Spans::from(vec![ + Span::raw("Press "), + Span::styled("S", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to send a burn transaction."), + ]), + ]) + .wrap(Wrap { trim: false }) + .block(Block::default()); + f.render_widget(instructions, vert_chunks[0]); + + let burnt_proof_filepath_input = Paragraph::new(self.burnt_proof_filepath_field.as_ref()) + .style(match self.burn_input_mode { + BurnInputMode::BurntProofPath => Style::default().fg(Color::Magenta), + _ => Style::default(), + }) + .block( + Block::default() + .borders(Borders::ALL) + .title("Save burn proof to file(p)ath:"), + ); + f.render_widget(burnt_proof_filepath_input, vert_chunks[1]); + + let claim_public_key_input = Paragraph::new(self.claim_public_key_field.as_ref()) + .style(match self.burn_input_mode { + BurnInputMode::ClaimPublicKey => Style::default().fg(Color::Magenta), + _ => Style::default(), + }) + .block(Block::default().borders(Borders::ALL).title("To (C)laim Public Key:")); + f.render_widget(claim_public_key_input, vert_chunks[2]); + + let amount_fee_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(vert_chunks[3]); + + let amount_input = Paragraph::new(self.amount_field.to_string()) + .style(match self.burn_input_mode { + BurnInputMode::Amount => Style::default().fg(Color::Magenta), + _ => Style::default(), + }) + .block(Block::default().borders(Borders::ALL).title("(A)mount:")); + f.render_widget(amount_input, amount_fee_layout[0]); + + let fee_input = Paragraph::new(self.fee_field.as_ref()) + .style(match self.burn_input_mode { + BurnInputMode::Fee => Style::default().fg(Color::Magenta), + _ => Style::default(), + }) + .block(Block::default().borders(Borders::ALL).title("(F)ee-per-gram (uT):")); + f.render_widget(fee_input, amount_fee_layout[1]); + + let message_input = Paragraph::new(self.message_field.as_ref()) + .style(match self.burn_input_mode { + BurnInputMode::Message => Style::default().fg(Color::Magenta), + _ => Style::default(), + }) + .block(Block::default().borders(Borders::ALL).title("(M)essage:")); + f.render_widget(message_input, vert_chunks[4]); + + match self.burn_input_mode { + BurnInputMode::None => (), + BurnInputMode::BurntProofPath => f.set_cursor( + // Put cursor past the end of the input text + vert_chunks[1].x + self.burnt_proof_filepath_field.width() as u16 + 1, + // Move one line down, from the border to the input line + vert_chunks[1].y + 1, + ), + BurnInputMode::ClaimPublicKey => f.set_cursor( + // Put cursor past the end of the input text + vert_chunks[2].x + self.claim_public_key_field.width() as u16 + 1, + // Move one line down, from the border to the input line + vert_chunks[2].y + 1, + ), + BurnInputMode::Amount => { + f.set_cursor( + // Put cursor past the end of the input text + amount_fee_layout[0].x + self.amount_field.width() as u16 + 1, + // Move one line down, from the border to the input line + amount_fee_layout[0].y + 1, + ) + }, + BurnInputMode::Fee => f.set_cursor( + // Put cursor past the end of the input text + amount_fee_layout[1].x + self.fee_field.width() as u16 + 1, + // Move one line down, from the border to the input line + amount_fee_layout[1].y + 1, + ), + BurnInputMode::Message => f.set_cursor( + // Put cursor past the end of the input text + vert_chunks[4].x + self.message_field.width() as u16 + 1, + // Move one line down, from the border to the input line + vert_chunks[4].y + 1, + ), + } + } + + fn draw_proofs(&mut self, f: &mut Frame, area: Rect, app_state: &AppState) + where B: Backend { + let block = Block::default().borders(Borders::ALL).title(Span::styled( + "Burnt Proofs", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )); + f.render_widget(block, area); + + let list_areas = Layout::default() + .constraints([Constraint::Length(1), Constraint::Min(42)].as_ref()) + .margin(1) + .split(area); + + let instructions = Paragraph::new(Spans::from(vec![ + Span::raw(" Use "), + Span::styled("Up↑/Down↓ Keys", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to choose a proof, "), + Span::styled("O", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to save proof to a file (named after proof ID), "), + Span::styled("D", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to delete the selected proof."), + ])) + .wrap(Wrap { trim: true }); + f.render_widget(instructions, list_areas[0]); + + self.proofs_list_state.set_num_items(app_state.get_burnt_proofs().len()); + + let mut list_state = self + .proofs_list_state + .get_list_state((list_areas[1].height as usize).saturating_sub(3)); + + let window = self.proofs_list_state.get_start_end(); + let windowed_view = app_state.get_burnt_proofs_slice(window.0, window.1); + + let column_list = BurnTab::create_column_view(windowed_view); + column_list.render(f, list_areas[1], &mut list_state); + } + + // Helper function to create the column list to be rendered + pub fn create_column_view(windowed_view: &[UiBurntProof]) -> MultiColumnList> { + let mut column0_items = Vec::new(); + let mut column1_items = Vec::new(); + + for item in windowed_view.iter() { + column0_items.push(ListItem::new(Span::raw(item.reciprocal_claim_public_key.clone()))); + column1_items.push(ListItem::new(Span::raw(item.burned_at.to_string().clone()))); + } + + let column_list = MultiColumnList::new() + .highlight_style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Magenta)) + .heading_style(Style::default().fg(Color::Magenta)) + .max_width(MAX_WIDTH) + .add_column(None, Some(1), Vec::new()) + .add_column(Some("Reciprocal Claim Public Key"), Some(66), column0_items) + .add_column(None, Some(1), Vec::new()) + .add_column(Some("Burned At"), Some(11), column1_items); + + column_list + } + + #[allow(clippy::too_many_lines)] + fn on_key_confirmation_dialog(&mut self, c: char, app_state: &mut AppState) -> KeyHandled { + if self.confirmation_dialog.is_some() { + if 'n' == c { + self.confirmation_dialog = None; + return KeyHandled::Handled; + } else if 'y' == c { + if 'y' == c { + let amount = self + .amount_field + .parse::() + .unwrap_or_else(|_| MicroTari::from(0)); + + let fee_per_gram = if let Ok(v) = self.fee_field.parse::() { + v + } else { + self.error_message = + Some("Fee-per-gram should be an integer\nPress Enter to continue.".to_string()); + return KeyHandled::Handled; + }; + + let burn_proof_filepath = if self.burnt_proof_filepath_field.is_empty() { + None + } else { + Some(self.burnt_proof_filepath_field.clone()) + }; + + let claim_public_key = if self.claim_public_key_field.is_empty() { + None + } else { + Some(self.claim_public_key_field.clone()) + }; + + let (tx, rx) = watch::channel(UiTransactionBurnStatus::Initiated); + + let mut reset_fields = false; + match self.confirmation_dialog { + Some(BurnConfirmationDialogType::Normal) => { + match Handle::current().block_on(app_state.send_burn_transaction( + burn_proof_filepath, + claim_public_key, + amount.into(), + UtxoSelectionCriteria::default(), + fee_per_gram, + self.message_field.clone(), + tx, + )) { + Err(e) => { + self.error_message = Some(format!( + "Error sending burn transaction (with a claim public key \ + provided):\n{}\nPress Enter to continue.", + e + )) + }, + Ok(_) => { + Handle::current() + .block_on(app_state.refresh_burnt_proofs_state()) + .unwrap(); + Handle::current().block_on(app_state.update_cache()); + + reset_fields = true + }, + } + }, + None => {}, + Some(BurnConfirmationDialogType::DeleteBurntProof(proof_id)) => { + match Handle::current().block_on(app_state.delete_burnt_proof(proof_id)) { + Err(e) => { + self.error_message = Some(format!( + "Failed to delete burnt proof (id={}):\n{}\nPress Enter to continue.", + proof_id, e + )) + }, + Ok(_) => { + Handle::current() + .block_on(app_state.refresh_burnt_proofs_state()) + .unwrap(); + Handle::current().block_on(app_state.update_cache()); + }, + } + }, + } + + if reset_fields { + self.burnt_proof_filepath_field = "".to_string(); + self.claim_public_key_field = "".to_string(); + self.amount_field = "".to_string(); + self.fee_field = app_state.get_default_fee_per_gram().as_u64().to_string(); + self.message_field = "".to_string(); + self.burn_input_mode = BurnInputMode::None; + self.burn_result_watch = Some(rx); + } + + self.confirmation_dialog = None; + return KeyHandled::Handled; + } + } else { + } + } + + KeyHandled::NotHandled + } + + fn on_key_send_input(&mut self, c: char) -> KeyHandled { + if self.burn_input_mode != BurnInputMode::None { + match self.burn_input_mode { + BurnInputMode::None => (), + BurnInputMode::BurntProofPath => match c { + '\n' => self.burn_input_mode = BurnInputMode::ClaimPublicKey, + c => { + self.burnt_proof_filepath_field.push(c); + return KeyHandled::Handled; + }, + }, + BurnInputMode::ClaimPublicKey => match c { + '\n' => self.burn_input_mode = BurnInputMode::Amount, + c => { + self.claim_public_key_field.push(c); + return KeyHandled::Handled; + }, + }, + BurnInputMode::Amount => match c { + '\n' => self.burn_input_mode = BurnInputMode::Message, + c => { + if c.is_numeric() || ['t', 'T', 'u', 'U'].contains(&c) { + self.amount_field.push(c); + } + return KeyHandled::Handled; + }, + }, + BurnInputMode::Fee => match c { + '\n' => self.burn_input_mode = BurnInputMode::None, + c => { + if c.is_numeric() { + self.fee_field.push(c); + } + return KeyHandled::Handled; + }, + }, + BurnInputMode::Message => match c { + '\n' => self.burn_input_mode = BurnInputMode::None, + c => { + self.message_field.push(c); + return KeyHandled::Handled; + }, + }, + } + } + + KeyHandled::NotHandled + } + + fn on_key_show_proofs(&mut self, c: char, app_state: &mut AppState) -> KeyHandled { + if !self.show_proofs { + return KeyHandled::NotHandled; + } + + match c { + 'd' => { + if let Some(proof) = self + .proofs_list_state + .selected() + .and_then(|i| app_state.get_burnt_proof_by_index(i)) + .cloned() + { + if self.proofs_list_state.selected().is_none() { + return KeyHandled::NotHandled; + } + + self.confirmation_dialog = Some(BurnConfirmationDialogType::DeleteBurntProof(proof.id)); + } + + return KeyHandled::Handled; + }, + + 'o' => { + if let Some(proof) = self + .proofs_list_state + .selected() + .and_then(|i| app_state.get_burnt_proof_by_index(i)) + .cloned() + { + if self.proofs_list_state.selected().is_none() { + return KeyHandled::NotHandled; + } + + if let Err(e) = fs::write(format!("{}.json", proof.id), proof.payload) { + self.error_message = Some(format!( + "Failed to save burnt proof payload to file {}.json: {}, Press Enter to continue.", + proof.id, e + )); + } + } + + return KeyHandled::Handled; + }, + + _ => {}, + } + + KeyHandled::NotHandled + } +} + +impl Component for BurnTab { + #[allow(clippy::too_many_lines)] + fn draw(&mut self, f: &mut Frame, area: Rect, app_state: &AppState) { + let areas = Layout::default() + .constraints( + [ + Constraint::Length(3), + Constraint::Length(17), + Constraint::Min(42), + Constraint::Length(1), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(area); + + self.balance.draw(f, areas[0], app_state); + self.draw_burn_form(f, areas[1], app_state); + + if self.show_proofs { + self.draw_proofs(f, areas[2], app_state); + }; + + let rx_option = self.burn_result_watch.take(); + if let Some(rx) = rx_option { + trace!(target: LOG_TARGET, "{:?}", (*rx.borrow()).clone()); + let status = match (*rx.borrow()).clone() { + UiTransactionBurnStatus::Initiated => "Initiated", + UiTransactionBurnStatus::Error(e) => { + self.error_message = Some(format!("Error sending transaction: {}, Press Enter to continue.", e)); + return; + }, + UiTransactionBurnStatus::TransactionComplete(( + _proof_id, + _reciprocal_claim_public_key, + _serialized_proof, + )) => { + self.success_message = + Some("Transaction completed successfully!\nPlease press Enter to continue".to_string()); + return; + }, + }; + draw_dialog( + f, + area, + "Please Wait".to_string(), + format!("Transaction Burn Status: {}", status), + Color::Green, + 120, + 10, + ); + self.burn_result_watch = Some(rx); + } + + if let Some(msg) = self.success_message.clone() { + draw_dialog(f, area, "Success!".to_string(), msg, Color::Green, 120, 9); + } + + if let Some(msg) = self.offline_message.clone() { + draw_dialog(f, area, "Offline!".to_string(), msg, Color::Green, 120, 9); + } + + if let Some(msg) = self.error_message.clone() { + draw_dialog(f, area, "Error!".to_string(), msg, Color::Red, 120, 9); + } + + match self.confirmation_dialog { + None => (), + Some(BurnConfirmationDialogType::Normal) => { + draw_dialog( + f, + area, + "Confirm Burning Transaction".to_string(), + format!( + "Are you sure you want to burn {} Tari with a Claim Public Key {}?\n(Y)es / (N)o", + self.amount_field, self.claim_public_key_field + ), + Color::Red, + 120, + 9, + ); + }, + + Some(BurnConfirmationDialogType::DeleteBurntProof(_proof_id)) => { + draw_dialog( + f, + area, + "Confirm Delete".to_string(), + "Are you sure you want to delete this burnt proof?\n(Y)es / (N)o".to_string(), + Color::Red, + 120, + 9, + ); + }, + } + } + + fn on_key(&mut self, app_state: &mut AppState, c: char) { + if self.error_message.is_some() { + if '\n' == c { + self.error_message = None; + } + return; + } + + if self.success_message.is_some() { + if '\n' == c { + self.success_message = None; + } + return; + } + + if self.offline_message.is_some() { + if '\n' == c { + self.offline_message = None; + } + return; + } + + if self.burn_result_watch.is_some() { + return; + } + + if self.on_key_confirmation_dialog(c, app_state) == KeyHandled::Handled { + return; + } + + if self.on_key_send_input(c) == KeyHandled::Handled { + return; + } + + if self.on_key_show_proofs(c, app_state) == KeyHandled::Handled { + return; + } + + match c { + 'p' => self.burn_input_mode = BurnInputMode::BurntProofPath, + 'c' => self.burn_input_mode = BurnInputMode::ClaimPublicKey, + 'a' => { + self.burn_input_mode = BurnInputMode::Amount; + }, + 'f' => self.burn_input_mode = BurnInputMode::Fee, + 'm' => self.burn_input_mode = BurnInputMode::Message, + 'b' => { + self.show_proofs = !self.show_proofs; + }, + 's' => { + if self.claim_public_key_field.is_empty() { + self.error_message = Some("Claim Public Key is empty\nPress Enter to continue.".to_string()); + return; + } + + if self.amount_field.parse::().is_err() { + self.error_message = + Some("Amount should be a valid amount of Tari\nPress Enter to continue.".to_string()); + return; + } + + self.confirmation_dialog = Some(BurnConfirmationDialogType::Normal); + }, + _ => {}, + } + } + + fn on_up(&mut self, app_state: &mut AppState) { + self.proofs_list_state.set_num_items(app_state.get_burnt_proofs().len()); + self.proofs_list_state.previous(); + } + + fn on_down(&mut self, app_state: &mut AppState) { + self.proofs_list_state.set_num_items(app_state.get_burnt_proofs().len()); + self.proofs_list_state.next(); + } + + fn on_esc(&mut self, _: &mut AppState) { + if self.confirmation_dialog.is_some() { + return; + } + + self.burn_input_mode = BurnInputMode::None; + self.proofs_list_state.select(None); + } + + fn on_backspace(&mut self, _app_state: &mut AppState) { + match self.burn_input_mode { + BurnInputMode::BurntProofPath => { + let _ = self.burnt_proof_filepath_field.pop(); + }, + BurnInputMode::ClaimPublicKey => { + let _ = self.claim_public_key_field.pop(); + }, + BurnInputMode::Amount => { + let _ = self.amount_field.pop(); + }, + BurnInputMode::Fee => { + let _ = self.fee_field.pop(); + }, + BurnInputMode::Message => { + let _ = self.message_field.pop(); + }, + BurnInputMode::None => {}, + } + } +} + +#[derive(PartialEq, Debug)] +pub enum BurnInputMode { + None, + BurntProofPath, + ClaimPublicKey, + Amount, + Message, + Fee, +} + +#[derive(PartialEq, Debug)] +pub enum BurnConfirmationDialogType { + Normal, + DeleteBurntProof(u32), +} diff --git a/applications/tari_console_wallet/src/ui/components/contacts_tab.rs b/applications/tari_console_wallet/src/ui/components/contacts_tab.rs index e165cf8b8a..b23b70c31e 100644 --- a/applications/tari_console_wallet/src/ui/components/contacts_tab.rs +++ b/applications/tari_console_wallet/src/ui/components/contacts_tab.rs @@ -16,8 +16,8 @@ use crate::{ ui::{ components::{Component, KeyHandled}, state::AppState, + ui_contact::UiContact, widgets::{centered_rect_absolute, draw_dialog, MultiColumnList, WindowedListState}, - UiContact, MAX_WIDTH, }, utils::formatting::display_compressed_string, diff --git a/applications/tari_console_wallet/src/ui/components/mod.rs b/applications/tari_console_wallet/src/ui/components/mod.rs index 7d4a48925d..1b54f78c51 100644 --- a/applications/tari_console_wallet/src/ui/components/mod.rs +++ b/applications/tari_console_wallet/src/ui/components/mod.rs @@ -33,6 +33,7 @@ mod styles; pub mod tabs_container; pub mod transactions_tab; pub use self::component::*; +pub mod burn_tab; pub mod contacts_tab; pub mod events_component; diff --git a/applications/tari_console_wallet/src/ui/mod.rs b/applications/tari_console_wallet/src/ui/mod.rs index 838e1ca1f5..f55130b6f0 100644 --- a/applications/tari_console_wallet/src/ui/mod.rs +++ b/applications/tari_console_wallet/src/ui/mod.rs @@ -29,9 +29,11 @@ use crate::utils::crossterm_events::CrosstermEvents; mod app; mod components; pub mod state; +mod ui_burnt_proof; mod ui_contact; mod ui_error; mod widgets; + use std::io::{stdout, Stdout}; pub use app::*; @@ -43,8 +45,7 @@ use crossterm::{ use log::*; use tokio::runtime::Handle; use tui::{backend::CrosstermBackend, Terminal}; -pub use ui_contact::*; -pub use ui_error::*; +use ui_error::UiError; use crate::utils::events::{Event, EventStream}; @@ -58,6 +59,8 @@ pub fn run(app: App>) -> Result<(), ExitError> { app.app_state.refresh_transaction_state().await?; trace!(target: LOG_TARGET, "Refreshing contacts state"); app.app_state.refresh_contacts_state().await?; + trace!(target: LOG_TARGET, "Refreshing burnt proofs state"); + app.app_state.refresh_burnt_proofs_state().await?; trace!(target: LOG_TARGET, "Refreshing connected peers state"); app.app_state.refresh_connected_peers_state().await?; trace!(target: LOG_TARGET, "Checking connectivity"); diff --git a/applications/tari_console_wallet/src/ui/state/app_state.rs b/applications/tari_console_wallet/src/ui/state/app_state.rs index 8ff31563ad..7555fdee43 100644 --- a/applications/tari_console_wallet/src/ui/state/app_state.rs +++ b/applications/tari_console_wallet/src/ui/state/app_state.rs @@ -22,6 +22,7 @@ use std::{ collections::{HashMap, VecDeque}, + path::PathBuf, sync::Arc, time::{Duration, Instant}, }; @@ -73,11 +74,12 @@ use crate::{ ui::{ state::{ debouncer::BalanceEnquiryDebouncer, - tasks::{send_one_sided_transaction_task, send_transaction_task}, + tasks::{send_burn_transaction_task, send_one_sided_transaction_task, send_transaction_task}, wallet_event_monitor::WalletEventMonitor, }, - UiContact, - UiError, + ui_burnt_proof::UiBurntProof, + ui_contact::UiContact, + ui_error::UiError, }, utils::db::{CUSTOM_BASE_NODE_ADDRESS_KEY, CUSTOM_BASE_NODE_PUBLIC_KEY_KEY}, wallet_modes::PeerConfig, @@ -164,6 +166,14 @@ impl AppState { Ok(()) } + pub async fn refresh_burnt_proofs_state(&mut self) -> Result<(), UiError> { + let mut inner = self.inner.write().await; + inner.refresh_burnt_proofs_state().await?; + drop(inner); + self.update_cache().await; + Ok(()) + } + pub async fn refresh_connected_peers_state(&mut self) -> Result<(), UiError> { self.check_connectivity().await; let mut inner = self.inner.write().await; @@ -259,6 +269,22 @@ impl AppState { Ok(()) } + pub async fn delete_burnt_proof(&mut self, proof_id: u32) -> Result<(), UiError> { + let mut inner = self.inner.write().await; + + inner + .wallet + .db + .delete_burnt_proof(proof_id) + .map_err(UiError::WalletStorageError)?; + + inner.refresh_burnt_proofs_state().await?; + drop(inner); + self.update_cache().await; + + Ok(()) + } + pub async fn send_transaction( &mut self, address: String, @@ -360,6 +386,57 @@ impl AppState { Ok(()) } + pub async fn send_burn_transaction( + &mut self, + burn_proof_filepath: Option, + claim_public_key: Option, + amount: u64, + selection_criteria: UtxoSelectionCriteria, + fee_per_gram: u64, + message: String, + result_tx: watch::Sender, + ) -> Result<(), UiError> { + let inner = self.inner.write().await; + + let burn_proof_filepath = match burn_proof_filepath { + None => None, + Some(path) => { + let path = PathBuf::from(path); + + if path.exists() { + return Err(UiError::BurntProofFileExists); + } + + Some(path) + }, + }; + + let fee_per_gram = fee_per_gram * uT; + let tx_service_handle = inner.wallet.transaction_service.clone(); + let claim_public_key = match claim_public_key { + None => return Err(UiError::PublicKeyParseError), + Some(claim_public_key) => match PublicKey::from_hex(claim_public_key.as_str()) { + Ok(claim_public_key) => Some(claim_public_key), + Err(_) => return Err(UiError::PublicKeyParseError), + }, + }; + + send_burn_transaction_task( + burn_proof_filepath, + claim_public_key, + MicroTari::from(amount), + selection_criteria, + message, + fee_per_gram, + tx_service_handle, + inner.wallet.db.clone(), + result_tx, + ) + .await; + + Ok(()) + } + pub async fn cancel_transaction(&mut self, tx_id: TxId) -> Result<(), UiError> { let inner = self.inner.write().await; let mut tx_service_handle = inner.wallet.transaction_service.clone(); @@ -385,6 +462,14 @@ impl AppState { &self.cached_data.my_identity } + pub fn get_burnt_proofs(&self) -> &[UiBurntProof] { + self.cached_data.burnt_proofs.as_slice() + } + + pub fn get_burnt_proof_by_index(&self, idx: usize) -> Option<&UiBurntProof> { + self.cached_data.burnt_proofs.get(idx) + } + pub fn get_contacts(&self) -> &[UiContact] { self.cached_data.contacts.as_slice() } @@ -405,6 +490,14 @@ impl AppState { &self.cached_data.contacts[start..end] } + pub fn get_burnt_proofs_slice(&self, start: usize, end: usize) -> &[UiBurntProof] { + if self.cached_data.burnt_proofs.is_empty() || start >= end { + return &[]; + } + + &self.cached_data.burnt_proofs[start..end] + } + pub fn get_pending_txs(&self) -> &Vec { &self.cached_data.pending_txs } @@ -779,6 +872,27 @@ impl AppStateInner { Ok(()) } + pub async fn refresh_burnt_proofs_state(&mut self) -> Result<(), UiError> { + // let db_burnt_proofs = self.wallet.db.get_burnt_proofs()?; + let db_burnt_proofs = self.wallet.db.fetch_burnt_proofs()?; + let mut ui_proofs: Vec = vec![]; + + for proof in db_burnt_proofs { + ui_proofs.push(UiBurntProof { + id: proof.0, + reciprocal_claim_public_key: proof.1, + payload: proof.2, + burned_at: proof.3, + }); + } + + ui_proofs.sort_by(|a, b| a.burned_at.cmp(&b.burned_at)); + + self.data.burnt_proofs = ui_proofs; + self.updated = true; + Ok(()) + } + pub async fn refresh_connected_peers_state(&mut self) -> Result<(), UiError> { let connections = self.wallet.comms.connectivity().get_active_connections().await?; let peer_manager = self.wallet.comms.peer_manager(); @@ -1063,6 +1177,7 @@ struct AppStateData { confirmations: HashMap, my_identity: MyIdentity, contacts: Vec, + burnt_proofs: Vec, connected_peers: Vec, balance: Balance, base_node_state: BaseNodeState, @@ -1137,6 +1252,7 @@ impl AppStateData { confirmations: HashMap::new(), my_identity: identity, contacts: Vec::new(), + burnt_proofs: vec![], connected_peers: Vec::new(), balance: Balance::zero(), base_node_state: BaseNodeState::default(), @@ -1171,6 +1287,13 @@ pub enum UiTransactionSendStatus { Error(String), } +#[derive(Clone, Debug)] +pub enum UiTransactionBurnStatus { + Initiated, + TransactionComplete((u32, String, String)), + Error(String), +} + bitflags! { pub struct TransactionFilter: u8 { const NONE = 0b0000_0000; diff --git a/applications/tari_console_wallet/src/ui/state/mod.rs b/applications/tari_console_wallet/src/ui/state/mod.rs index 6fb1d091a1..f78f9e742a 100644 --- a/applications/tari_console_wallet/src/ui/state/mod.rs +++ b/applications/tari_console_wallet/src/ui/state/mod.rs @@ -25,4 +25,28 @@ mod debouncer; mod tasks; mod wallet_event_monitor; +use serde::{Deserialize, Serialize}; +use tari_common_types::serializers; + pub use self::app_state::*; + +#[derive(Serialize, Deserialize)] +pub struct CommitmentSignatureBase64 { + #[serde(with = "serializers::base64")] + pub public_nonce: Vec, + #[serde(with = "serializers::base64")] + pub u: Vec, + #[serde(with = "serializers::base64")] + pub v: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct BurntProofBase64 { + #[serde(with = "serializers::base64")] + pub reciprocal_claim_public_key: Vec, + #[serde(with = "serializers::base64")] + pub commitment: Vec, + pub ownership_proof: Option, + #[serde(with = "serializers::base64")] + pub range_proof: Vec, +} diff --git a/applications/tari_console_wallet/src/ui/state/tasks.rs b/applications/tari_console_wallet/src/ui/state/tasks.rs index db4151b857..d2c8c9b2c9 100644 --- a/applications/tari_console_wallet/src/ui/state/tasks.rs +++ b/applications/tari_console_wallet/src/ui/state/tasks.rs @@ -20,15 +20,24 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -use tari_common_types::tari_address::TariAddress; +use std::path::PathBuf; + +use log::warn; +use rand::random; +use tari_common_types::{tari_address::TariAddress, types::PublicKey}; use tari_core::transactions::{tari_amount::MicroTari, transaction_components::OutputFeatures}; +use tari_utilities::{hex::Hex, ByteArray}; use tari_wallet::{ output_manager_service::UtxoSelectionCriteria, + storage::{database::WalletDatabase, sqlite_db::wallet::WalletSqliteDatabase}, transaction_service::handle::{TransactionEvent, TransactionSendStatus, TransactionServiceHandle}, }; use tokio::sync::{broadcast, watch}; -use crate::ui::{state::UiTransactionSendStatus, UiError}; +use crate::ui::{ + state::{BurntProofBase64, CommitmentSignatureBase64, UiTransactionBurnStatus, UiTransactionSendStatus}, + ui_error::UiError, +}; const LOG_TARGET: &str = "wallet::console_wallet::tasks "; @@ -215,3 +224,103 @@ pub async fn send_one_sided_to_stealth_address_transaction( }, } } + +pub async fn send_burn_transaction_task( + burn_proof_filepath: Option, + claim_public_key: Option, + amount: MicroTari, + selection_criteria: UtxoSelectionCriteria, + message: String, + fee_per_gram: MicroTari, + mut transaction_service_handle: TransactionServiceHandle, + db: WalletDatabase, + result_tx: watch::Sender, +) { + result_tx.send(UiTransactionBurnStatus::Initiated).unwrap(); + let mut event_stream = transaction_service_handle.get_event_stream(); + + // ---------------------------------------------------------------------------- + // burning minotari + // ---------------------------------------------------------------------------- + + let (burn_tx_id, original_proof) = transaction_service_handle + .burn_tari(amount, selection_criteria, fee_per_gram, message, claim_public_key) + .await + .map_err(|err| { + log::error!("failed to burn minotari: {:?}", err); + + result_tx + .send(UiTransactionBurnStatus::Error(UiError::from(err).to_string())) + .unwrap(); + }) + .unwrap(); + + // ---------------------------------------------------------------------------- + // starting a feedback loop to wait for the answer from the transaction service + // ---------------------------------------------------------------------------- + + loop { + let original_proof = original_proof.clone(); + let burn_proof_filepath = burn_proof_filepath.clone(); + + match event_stream.recv().await { + Ok(event) => { + if let TransactionEvent::TransactionCompletedImmediately(completed_tx_id) = &*event { + if burn_tx_id == *completed_tx_id { + let wrapped_proof = BurntProofBase64 { + reciprocal_claim_public_key: original_proof.reciprocal_claim_public_key.to_vec(), + commitment: original_proof.commitment.to_vec(), + ownership_proof: original_proof.ownership_proof.map(|x| CommitmentSignatureBase64 { + public_nonce: x.public_nonce().to_vec(), + u: x.u().to_vec(), + v: x.v().to_vec(), + }), + range_proof: original_proof.range_proof.0, + }; + + let serialized_proof = + serde_json::to_string_pretty(&wrapped_proof).expect("failed to serialize burn proof"); + + let proof_id = random::(); + let filepath = + burn_proof_filepath.unwrap_or_else(|| PathBuf::from(format!("{}.json", proof_id))); + + std::fs::write(filepath, serialized_proof.as_bytes()).expect("failed to save burn proof"); + + let result = db.create_burnt_proof( + proof_id, + original_proof.reciprocal_claim_public_key.to_hex(), + serialized_proof.clone(), + ); + + if let Err(err) = result { + log::error!("failed to create database entry for the burnt proof: {:?}", err); + } + + result_tx + .send(UiTransactionBurnStatus::TransactionComplete(( + proof_id, + original_proof.reciprocal_claim_public_key.to_hex(), + serialized_proof, + ))) + .unwrap(); + + return; + } + } else { + warn!(target: LOG_TARGET, "Encountered an unexpected event"); + todo!() + } + }, + + Err(e @ broadcast::error::RecvError::Lagged(_)) => { + warn!(target: LOG_TARGET, "Error reading from event broadcast channel {:?}", e); + continue; + }, + + Err(broadcast::error::RecvError::Closed) => { + break; + }, + } + } +} diff --git a/applications/tari_console_wallet/src/ui/ui_burnt_proof.rs b/applications/tari_console_wallet/src/ui/ui_burnt_proof.rs new file mode 100644 index 0000000000..2c5fbca735 --- /dev/null +++ b/applications/tari_console_wallet/src/ui/ui_burnt_proof.rs @@ -0,0 +1,31 @@ +// Copyright 2022. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use chrono::NaiveDateTime; + +#[derive(Debug, Clone)] +pub struct UiBurntProof { + pub id: u32, + pub reciprocal_claim_public_key: String, + pub payload: String, + pub burned_at: NaiveDateTime, +} diff --git a/applications/tari_console_wallet/src/ui/ui_contact.rs b/applications/tari_console_wallet/src/ui/ui_contact.rs index d10266d459..ddb49eede2 100644 --- a/applications/tari_console_wallet/src/ui/ui_contact.rs +++ b/applications/tari_console_wallet/src/ui/ui_contact.rs @@ -1,5 +1,25 @@ -// Copyright 2022 The Tari Project -// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2022 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. use chrono::{DateTime, Local}; use tari_contacts::contacts_service::types::Contact; diff --git a/applications/tari_console_wallet/src/ui/ui_error.rs b/applications/tari_console_wallet/src/ui/ui_error.rs index 0f183d2b0f..bb169e02a0 100644 --- a/applications/tari_console_wallet/src/ui/ui_error.rs +++ b/applications/tari_console_wallet/src/ui/ui_error.rs @@ -1,5 +1,24 @@ -// Copyright 2022 The Tari Project -// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2022. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. use tari_comms::connectivity::ConnectivityError; use tari_contacts::contacts_service::error::ContactsServiceError; @@ -33,6 +52,8 @@ pub enum UiError { AddressParseError, #[error("Peer did not include an address")] NoAddress, + #[error("Specified burn proof file already exists")] + BurntProofFileExists, #[error("Channel send error: `{0}`")] SendError(String), } diff --git a/base_layer/common_types/Cargo.toml b/base_layer/common_types/Cargo.toml index bd99c03bc3..faca973a23 100644 --- a/base_layer/common_types/Cargo.toml +++ b/base_layer/common_types/Cargo.toml @@ -22,3 +22,4 @@ rand = "0.7.3" serde = { version = "1.0.106", features = ["derive"] } thiserror = "1.0.29" tokio = { version = "1.23", features = ["time", "sync"] } +base64 = "0.21.0" diff --git a/base_layer/common_types/src/encryption.rs b/base_layer/common_types/src/encryption.rs index 8983b26187..7de8e7bd90 100644 --- a/base_layer/common_types/src/encryption.rs +++ b/base_layer/common_types/src/encryption.rs @@ -41,6 +41,7 @@ pub trait Encryptable { const COMPLETED_TRANSACTION: &'static [u8] = b"COMPLETED_TRANSACTION"; const KNOWN_ONESIDED_PAYMENT_SCRIPT: &'static [u8] = b"KNOWN_ONESIDED_PAYMENT_SCRIPT"; const CLIENT_KEY_VALUE: &'static [u8] = b"CLIENT_KEY_VALUE"; + const BURNT_PROOF: &'static [u8] = b"BURNT_PROOF"; fn domain(&self, field_name: &'static str) -> Vec; fn encrypt(self, cipher: &C) -> Result diff --git a/base_layer/common_types/src/lib.rs b/base_layer/common_types/src/lib.rs index be272da839..b507bee457 100644 --- a/base_layer/common_types/src/lib.rs +++ b/base_layer/common_types/src/lib.rs @@ -27,6 +27,7 @@ pub mod emoji; pub mod encryption; pub mod epoch; pub mod grpc_authentication; +pub mod serializers; pub mod tari_address; pub mod transaction; mod tx_id; diff --git a/base_layer/common_types/src/serializers/base64.rs b/base_layer/common_types/src/serializers/base64.rs new file mode 100644 index 0000000000..f309dd4a0c --- /dev/null +++ b/base_layer/common_types/src/serializers/base64.rs @@ -0,0 +1,49 @@ +// Copyright 2022. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use std::convert::TryFrom; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Deserializer, Serializer}; + +pub fn serialize>(v: &T, s: S) -> Result { + if s.is_human_readable() { + s.serialize_str(&STANDARD.encode(v)) + } else { + s.serialize_bytes(v.as_ref()) + } +} + +pub fn deserialize<'de, D, T>(d: D) -> Result +where + D: Deserializer<'de>, + T: TryFrom>, +{ + let bytes = if d.is_human_readable() { + let s = String::deserialize(d)?; + STANDARD.decode(s.as_bytes()).map_err(serde::de::Error::custom)? + } else { + Vec::::deserialize(d)? + }; + + T::try_from(bytes).map_err(|_| serde::de::Error::custom("Failed to convert bytes to T")) +} diff --git a/base_layer/common_types/src/serializers/hex.rs b/base_layer/common_types/src/serializers/hex.rs new file mode 100644 index 0000000000..a8a86fc98d --- /dev/null +++ b/base_layer/common_types/src/serializers/hex.rs @@ -0,0 +1,52 @@ +// Copyright 2022. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use std::convert::TryFrom; + +use serde::{Deserialize, Deserializer, Serializer}; +use tari_utilities::hex::{from_hex, to_hex}; + +pub fn serialize>(v: &T, s: S) -> Result { + if s.is_human_readable() { + let st = to_hex(v.as_ref()); + s.serialize_str(&st) + } else { + s.serialize_bytes(v.as_ref()) + } +} + +/// Use a serde deserializer to serialize the hex string of the given object. +pub fn deserialize<'de, D, T>(d: D) -> Result +where + D: Deserializer<'de>, + T: TryFrom>, +{ + let bytes = if d.is_human_readable() { + let hex = ::deserialize(d)?; + from_hex(&hex).map_err(serde::de::Error::custom)? + } else { + as Deserialize>::deserialize(d)? + }; + + let hash = T::try_from(bytes).map_err(|_| serde::de::Error::custom("Failed to convert bytes to T"))?; + Ok(hash) +} diff --git a/base_layer/common_types/src/serializers/mod.rs b/base_layer/common_types/src/serializers/mod.rs new file mode 100644 index 0000000000..45b80cdbf6 --- /dev/null +++ b/base_layer/common_types/src/serializers/mod.rs @@ -0,0 +1,25 @@ +// Copyright 2022. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +pub mod base64; +pub mod hex; +pub mod string; diff --git a/base_layer/common_types/src/serializers/string.rs b/base_layer/common_types/src/serializers/string.rs new file mode 100644 index 0000000000..e37f63659d --- /dev/null +++ b/base_layer/common_types/src/serializers/string.rs @@ -0,0 +1,28 @@ +// Copyright 2022 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use std::str::FromStr; + +use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize, Serializer}; + +pub fn serialize(v: &T, s: S) -> Result { + if s.is_human_readable() { + s.serialize_str(&v.to_string()) + } else { + v.serialize(s) + } +} + +pub fn deserialize<'de, D, T>(d: D) -> Result +where + D: Deserializer<'de>, + T: FromStr + DeserializeOwned, + T::Err: std::fmt::Display, +{ + if d.is_human_readable() { + let s = ::deserialize(d)?; + s.parse().map_err(serde::de::Error::custom) + } else { + T::deserialize(d) + } +} diff --git a/base_layer/wallet/migrations/2022-08-08-134037_initial/up.sql b/base_layer/wallet/migrations/2022-08-08-134037_initial/up.sql index 9b2e28e5cb..384a37a779 100644 --- a/base_layer/wallet/migrations/2022-08-08-134037_initial/up.sql +++ b/base_layer/wallet/migrations/2022-08-08-134037_initial/up.sql @@ -1,14 +1,16 @@ -CREATE TABLE client_key_values ( +CREATE TABLE client_key_values +( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL ); -CREATE TABLE completed_transactions ( +CREATE TABLE completed_transactions +( tx_id BIGINT PRIMARY KEY NOT NULL, source_address BLOB NOT NULL, destination_address BLOB NOT NULL, amount BIGINT NOT NULL, - fee BIGINT NOT NULL, + fee BIGINT NOT NULL, transaction_protocol TEXT NOT NULL, status INTEGER NOT NULL, message TEXT NOT NULL, @@ -26,7 +28,8 @@ CREATE TABLE completed_transactions ( transaction_signature_key BLOB DEFAULT 0 NOT NULL ); -CREATE TABLE inbound_transactions ( +CREATE TABLE inbound_transactions +( tx_id BIGINT PRIMARY KEY NOT NULL, source_address BLOB NOT NULL, amount BIGINT NOT NULL, @@ -39,7 +42,8 @@ CREATE TABLE inbound_transactions ( last_send_timestamp DATETIME NULL ); -CREATE TABLE known_one_sided_payment_scripts ( +CREATE TABLE known_one_sided_payment_scripts +( script_hash BLOB PRIMARY KEY NOT NULL, private_key BLOB NOT NULL, script BLOB NOT NULL, @@ -47,59 +51,62 @@ CREATE TABLE known_one_sided_payment_scripts ( script_lock_height UNSIGNED BIGINT NOT NULL DEFAULT 0 ); -CREATE TABLE outbound_transactions ( - tx_id BIGINT PRIMARY KEY NOT NULL, - destination_address BLOB NOT NULL, - amount BIGINT NOT NULL, - fee BIGINT NOT NULL, - sender_protocol TEXT NOT NULL, - message TEXT NOT NULL, - timestamp DATETIME NOT NULL, - cancelled INTEGER DEFAULT 0 NOT NULL, - direct_send_success INTEGER DEFAULT 0 NOT NULL, - send_count INTEGER DEFAULT 0 NOT NULL, - last_send_timestamp DATETIME NULL +CREATE TABLE outbound_transactions +( + tx_id BIGINT PRIMARY KEY NOT NULL, + destination_address BLOB NOT NULL, + amount BIGINT NOT NULL, + fee BIGINT NOT NULL, + sender_protocol TEXT NOT NULL, + message TEXT NOT NULL, + timestamp DATETIME NOT NULL, + cancelled INTEGER DEFAULT 0 NOT NULL, + direct_send_success INTEGER DEFAULT 0 NOT NULL, + send_count INTEGER DEFAULT 0 NOT NULL, + last_send_timestamp DATETIME NULL ); -CREATE TABLE outputs ( - id INTEGER PRIMARY KEY NOT NULL, - commitment BLOB NULL, - spending_key BLOB NOT NULL, - value BIGINT NOT NULL, - output_type INTEGER NOT NULL, - maturity BIGINT NOT NULL, - status INTEGER NOT NULL, - hash BLOB NULL, - script BLOB NOT NULL, - input_data BLOB NOT NULL, - script_private_key BLOB NOT NULL, - script_lock_height UNSIGNED BIGINT NOT NULL DEFAULT 0, - sender_offset_public_key BLOB NOT NULL, - metadata_signature_ephemeral_commitment BLOB NOT NULL, - metadata_signature_ephemeral_pubkey BLOB NOT NULL, - metadata_signature_u_a BLOB NOT NULL, - metadata_signature_u_x BLOB NOT NULL, - metadata_signature_u_y BLOB NOT NULL, - mined_height UNSIGNED BIGINT NULL, - mined_in_block BLOB NULL, - mined_mmr_position BIGINT NULL, - marked_deleted_at_height BIGINT NULL, - marked_deleted_in_block BLOB NULL, - received_in_tx_id BIGINT NULL, - spent_in_tx_id BIGINT NULL, - coinbase_block_height UNSIGNED BIGINT NULL, - metadata BLOB NULL, - features_json TEXT NOT NULL DEFAULT '{}', - spending_priority UNSIGNED INTEGER NOT NULL DEFAULT 500, - covenant BLOB NOT NULL, - mined_timestamp DATETIME NULL, - encrypted_value BLOB NOT NULL, - minimum_value_promise BIGINT NOT NULL, - source INTEGER NOT NULL DEFAULT 0, +CREATE TABLE outputs +( + id INTEGER PRIMARY KEY NOT NULL, + commitment BLOB NULL, + spending_key BLOB NOT NULL, + value BIGINT NOT NULL, + output_type INTEGER NOT NULL, + maturity BIGINT NOT NULL, + status INTEGER NOT NULL, + hash BLOB NULL, + script BLOB NOT NULL, + input_data BLOB NOT NULL, + script_private_key BLOB NOT NULL, + script_lock_height UNSIGNED BIGINT NOT NULL DEFAULT 0, + sender_offset_public_key BLOB NOT NULL, + metadata_signature_ephemeral_commitment BLOB NOT NULL, + metadata_signature_ephemeral_pubkey BLOB NOT NULL, + metadata_signature_u_a BLOB NOT NULL, + metadata_signature_u_x BLOB NOT NULL, + metadata_signature_u_y BLOB NOT NULL, + mined_height UNSIGNED BIGINT NULL, + mined_in_block BLOB NULL, + mined_mmr_position BIGINT NULL, + marked_deleted_at_height BIGINT NULL, + marked_deleted_in_block BLOB NULL, + received_in_tx_id BIGINT NULL, + spent_in_tx_id BIGINT NULL, + coinbase_block_height UNSIGNED BIGINT NULL, + metadata BLOB NULL, + features_json TEXT NOT NULL DEFAULT '{}', + spending_priority UNSIGNED INTEGER NOT NULL DEFAULT 500, + covenant BLOB NOT NULL, + mined_timestamp DATETIME NULL, + encrypted_value BLOB NOT NULL, + minimum_value_promise BIGINT NOT NULL, + source INTEGER NOT NULL DEFAULT 0, CONSTRAINT unique_commitment UNIQUE (commitment) ); -CREATE TABLE scanned_blocks ( +CREATE TABLE scanned_blocks +( header_hash BLOB PRIMARY KEY NOT NULL, height BIGINT NOT NULL, num_outputs BIGINT NULL, @@ -107,7 +114,8 @@ CREATE TABLE scanned_blocks ( timestamp DATETIME NOT NULL ); -CREATE TABLE wallet_settings ( +CREATE TABLE wallet_settings +( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL ); diff --git a/base_layer/wallet/migrations/2023-05-05-064704_burn_tari/down.sql b/base_layer/wallet/migrations/2023-05-05-064704_burn_tari/down.sql new file mode 100644 index 0000000000..291a97c5ce --- /dev/null +++ b/base_layer/wallet/migrations/2023-05-05-064704_burn_tari/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` \ No newline at end of file diff --git a/base_layer/wallet/migrations/2023-05-05-064704_burn_tari/up.sql b/base_layer/wallet/migrations/2023-05-05-064704_burn_tari/up.sql new file mode 100644 index 0000000000..531cb3ded9 --- /dev/null +++ b/base_layer/wallet/migrations/2023-05-05-064704_burn_tari/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE burnt_proofs +( + id INTEGER PRIMARY KEY NOT NULL, + reciprocal_claim_public_key TEXT NOT NULL, + payload TEXT NOT NULL, + burned_at DATETIME NOT NULL +); diff --git a/base_layer/wallet/src/error.rs b/base_layer/wallet/src/error.rs index 101d6316ce..0738733f43 100644 --- a/base_layer/wallet/src/error.rs +++ b/base_layer/wallet/src/error.rs @@ -136,6 +136,8 @@ pub enum WalletStorageError { DatabaseMigrationError(String), #[error("Value not found: `{}`", .0.to_key_string())] ValueNotFound(DbKey), + #[error("Burnt proof not found: `{0}`")] + BurntProofNotFound(u32), #[error("Unexpected result: `{0}`")] UnexpectedResult(String), #[error("Blocking task spawn error: `{0}`")] diff --git a/base_layer/wallet/src/schema.rs b/base_layer/wallet/src/schema.rs index 20b42551ce..972330190d 100644 --- a/base_layer/wallet/src/schema.rs +++ b/base_layer/wallet/src/schema.rs @@ -130,6 +130,15 @@ diesel::table! { } } +diesel::table! { + burnt_proofs (id) { + id -> Integer, + reciprocal_claim_public_key -> Text, + payload -> Text, + burned_at -> Timestamp, + } +} + diesel::allow_tables_to_appear_in_same_query!( client_key_values, completed_transactions, @@ -139,4 +148,5 @@ diesel::allow_tables_to_appear_in_same_query!( outputs, scanned_blocks, wallet_settings, + burnt_proofs, ); diff --git a/base_layer/wallet/src/storage/database.rs b/base_layer/wallet/src/storage/database.rs index 4ab418ba76..cdca0cd43a 100644 --- a/base_layer/wallet/src/storage/database.rs +++ b/base_layer/wallet/src/storage/database.rs @@ -25,6 +25,7 @@ use std::{ sync::Arc, }; +use chrono::NaiveDateTime; use log::*; use tari_common_types::chain_metadata::ChainMetadata; use tari_comms::{ @@ -61,6 +62,16 @@ pub trait WalletBackend: Send + Sync + Clone { /// Change the passphrase used to encrypt the database fn change_passphrase(&self, existing: &SafePassword, new: &SafePassword) -> Result<(), WalletStorageError>; + + fn create_burnt_proof( + &self, + id: u32, + reciprocal_claim_public_key: String, + payload: String, + ) -> Result<(), WalletStorageError>; + fn fetch_burnt_proof(&self, id: u32) -> Result<(u32, String, String, NaiveDateTime), WalletStorageError>; + fn fetch_burnt_proofs(&self) -> Result, WalletStorageError>; + fn delete_burnt_proof(&self, id: u32) -> Result<(), WalletStorageError>; } #[derive(Debug, Clone, PartialEq)] @@ -351,6 +362,28 @@ where T: WalletBackend + 'static self.db.clear_scanned_blocks_before_height(height, exclude_recovered)?; Ok(()) } + + pub fn create_burnt_proof( + &self, + id: u32, + reciprocal_claim_public_key: String, + payload: String, + ) -> Result<(), WalletStorageError> { + self.db.create_burnt_proof(id, reciprocal_claim_public_key, payload)?; + Ok(()) + } + + pub fn fetch_burnt_proof(&self, id: u32) -> Result<(u32, String, String, NaiveDateTime), WalletStorageError> { + self.db.fetch_burnt_proof(id) + } + + pub fn fetch_burnt_proofs(&self) -> Result, WalletStorageError> { + self.db.fetch_burnt_proofs() + } + + pub fn delete_burnt_proof(&self, id: u32) -> Result<(), WalletStorageError> { + self.db.delete_burnt_proof(id) + } } impl Display for DbValue { diff --git a/base_layer/wallet/src/storage/sqlite_db/wallet.rs b/base_layer/wallet/src/storage/sqlite_db/wallet.rs index 649a842f6b..1cbc48abe0 100644 --- a/base_layer/wallet/src/storage/sqlite_db/wallet.rs +++ b/base_layer/wallet/src/storage/sqlite_db/wallet.rs @@ -32,8 +32,10 @@ use argon2::password_hash::{ SaltString, }; use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305}; +use chrono::NaiveDateTime; use diesel::{prelude::*, result::Error, SqliteConnection}; use digest::{generic_array::GenericArray, FixedOutput}; +use itertools::Itertools; use log::*; use tari_common_sqlite::sqlite_connection_pool::PooledDbConnection; use tari_common_types::{ @@ -51,6 +53,7 @@ use tari_utilities::{ hex::{from_hex, Hex}, hidden_type, safe_array::SafeArray, + ByteArray, Hidden, SafePassword, }; @@ -59,7 +62,7 @@ use zeroize::Zeroize; use crate::{ error::WalletStorageError, - schema::{client_key_values, wallet_settings}, + schema::{burnt_proofs, client_key_values, wallet_settings}, storage::{ database::{DbKey, DbKeyValuePair, DbValue, WalletBackend, WriteOperation}, sqlite_db::scanned_blocks::ScannedBlockSql, @@ -624,6 +627,99 @@ impl WalletBackend for WalletSqliteDatabase { Ok(()) } + + fn create_burnt_proof( + &self, + id: u32, + reciprocal_claim_public_key: String, + payload: String, + ) -> Result<(), WalletStorageError> { + let mut conn = self.database_connection.get_pooled_connection()?; + let cipher = acquire_read_lock!(self.cipher); + + BurntProofSql::new( + id, + reciprocal_claim_public_key, + payload, + chrono::Utc::now().naive_utc(), + &cipher, + )? + .insert(&mut conn) + } + + fn fetch_burnt_proof(&self, id: u32) -> Result<(u32, String, String, NaiveDateTime), WalletStorageError> { + let mut conn = self.database_connection.get_pooled_connection()?; + + match BurntProofSql::get(id, &mut conn) { + Ok(None) => Err(WalletStorageError::BurntProofNotFound(id)), + + Ok(Some(entry)) => match self.decrypt_value(entry) { + Ok(decrypted) => Ok(( + decrypted.id as u32, + decrypted.reciprocal_claim_public_key, + decrypted.payload, + decrypted.burned_at, + )), + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to decrypt burnt proof: id={}: {}", + id, + e.to_string() + ); + Err(WalletStorageError::AeadError(e.to_string())) + }, + }, + + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to fetch burnt proof: id={}: {}", + id, + e.to_string() + ); + + Err(WalletStorageError::BurntProofNotFound(id)) + }, + } + } + + fn fetch_burnt_proofs(&self) -> Result, WalletStorageError> { + let mut conn = self.database_connection.get_pooled_connection()?; + let proofs = BurntProofSql::index(&mut conn)?; + + Ok(proofs + .into_iter() + .filter_map(|entry| { + let entry_id = entry.id; + + match self.decrypt_value(entry) { + Ok(decrypted) => Some(( + decrypted.id as u32, + decrypted.reciprocal_claim_public_key, + decrypted.payload, + decrypted.burned_at, + )), + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to decrypt burnt proof: id={}: {}", + entry_id, + e.to_string() + ); + + None + }, + } + }) + .collect_vec()) + } + + fn delete_burnt_proof(&self, id: u32) -> Result<(), WalletStorageError> { + let mut conn = self.database_connection.get_pooled_connection()?; + BurntProofSql::delete(id, &mut conn)?; + Ok(()) + } } /// Derive a secondary database key and associated commitment @@ -888,6 +984,100 @@ impl Encryptable for ClientKeyValueSql { } } +#[derive(Clone, Debug, Queryable, Insertable, PartialEq)] +#[diesel(table_name = burnt_proofs)] +struct BurntProofSql { + id: i32, + reciprocal_claim_public_key: String, + payload: String, + burned_at: NaiveDateTime, +} + +impl BurntProofSql { + pub fn new( + id: u32, + reciprocal_claim_public_key: String, + payload: String, + burned_at: NaiveDateTime, + cipher: &XChaCha20Poly1305, + ) -> Result { + let entry = Self { + id: id as i32, + reciprocal_claim_public_key, + payload, + burned_at, + }; + entry.encrypt(cipher).map_err(WalletStorageError::AeadError) + } + + pub fn index(conn: &mut SqliteConnection) -> Result, WalletStorageError> { + Ok(burnt_proofs::table.load::(conn)?) + } + + pub fn insert(&self, conn: &mut SqliteConnection) -> Result<(), WalletStorageError> { + diesel::insert_into(burnt_proofs::table).values(self).execute(conn)?; + Ok(()) + } + + pub fn get(id: u32, conn: &mut SqliteConnection) -> Result, WalletStorageError> { + burnt_proofs::table + .filter(burnt_proofs::id.eq(id as i32)) + .first::(conn) + .map(Some) + .or_else(|err| match err { + Error::NotFound => Ok(None), + err => Err(err.into()), + }) + } + + pub fn delete(id: u32, conn: &mut SqliteConnection) -> Result { + let num_deleted = diesel::delete(burnt_proofs::table.filter(burnt_proofs::id.eq(id as i32))).execute(conn)?; + Ok(num_deleted > 0) + } +} + +impl Encryptable for BurntProofSql { + fn domain(&self, field_name: &'static str) -> Vec { + [ + Self::BURNT_PROOF, + self.id.to_be_bytes().as_bytes(), + field_name.as_bytes(), + ] + .concat() + .to_vec() + } + + #[allow(unused_assignments)] + fn encrypt(mut self, cipher: &XChaCha20Poly1305) -> Result { + self.payload = encrypt_bytes_integral_nonce( + cipher, + self.domain("value"), + Hidden::hide(self.payload.as_bytes().to_vec()), + )? + .to_hex(); + + Ok(self) + } + + #[allow(unused_assignments)] + fn decrypt(mut self, cipher: &XChaCha20Poly1305) -> Result { + let mut decrypted_value = decrypt_bytes_integral_nonce( + cipher, + self.domain("value"), + &from_hex(self.payload.as_str()).map_err(|e| e.to_string())?, + )?; + + self.payload = from_utf8(decrypted_value.as_slice()) + .map_err(|e| e.to_string())? + .to_string(); + + // we zeroize the decrypted value + decrypted_value.zeroize(); + + Ok(self) + } +} + #[cfg(test)] mod test { use tari_common_sqlite::sqlite_connection_pool::PooledDbConnection;