diff --git a/api/src/owner_rpc.rs b/api/src/owner_rpc.rs index d3cf21103..c584abead 100644 --- a/api/src/owner_rpc.rs +++ b/api/src/owner_rpc.rs @@ -270,6 +270,7 @@ pub trait OwnerRpc { "ttl_cutoff_height": null, "tx_slate_id": null, "payment_proof": null, + "reverted_after": null, "tx_type": "ConfirmedCoinbase" }, { @@ -289,6 +290,7 @@ pub trait OwnerRpc { "stored_tx": null, "ttl_cutoff_height": null, "payment_proof": null, + "reverted_after": null, "tx_slate_id": null, "tx_type": "ConfirmedCoinbase" } @@ -340,6 +342,7 @@ pub trait OwnerRpc { "amount_currently_spendable": "60000000000", "amount_immature": "180000000000", "amount_locked": "0", + "amount_reverted": "0", "last_confirmed_height": "4", "minimum_confirmations": "1", "total": "240000000000" diff --git a/controller/src/display.rs b/controller/src/display.rs index d22fe6146..f2b4c795e 100644 --- a/controller/src/display.rs +++ b/controller/src/display.rs @@ -304,6 +304,12 @@ pub fn info( bFG->"Confirmed Total", FG->amount_to_hr_string(wallet_info.total, false) ]); + if wallet_info.amount_reverted > 0 { + table.add_row(row![ + Fr->format!("Reverted"), + Fr->amount_to_hr_string(wallet_info.amount_reverted, false) + ]); + } // Only dispay "Immature Coinbase" if we have related outputs in the wallet. // This row just introduces confusion if the wallet does not receive coinbase rewards. if wallet_info.amount_immature > 0 { @@ -337,6 +343,12 @@ pub fn info( bFG->"Total", FG->amount_to_hr_string(wallet_info.total, false) ]); + if wallet_info.amount_reverted > 0 { + table.add_row(row![ + Fr->format!("Reverted"), + Fr->amount_to_hr_string(wallet_info.amount_reverted, false) + ]); + } // Only dispay "Immature Coinbase" if we have related outputs in the wallet. // This row just introduces confusion if the wallet does not receive coinbase rewards. if wallet_info.amount_immature > 0 { diff --git a/controller/tests/revert.rs b/controller/tests/revert.rs new file mode 100644 index 000000000..05e4a608c --- /dev/null +++ b/controller/tests/revert.rs @@ -0,0 +1,379 @@ +// Copyright 2020 The Grin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[macro_use] +mod common; + +use common::{clean_output_dir, create_wallet_proxy, setup}; +use grin_wallet_controller::controller::owner_single_use as owner; +use grin_wallet_impls::test_framework::*; +use grin_wallet_impls::{DefaultLCProvider, PathToSlate, SlatePutter}; +use grin_wallet_libwallet as libwallet; +use grin_wallet_libwallet::api_impl::types::InitTxArgs; +use grin_wallet_libwallet::WalletInst; +use grin_wallet_util::grin_chain as chain; +use grin_wallet_util::grin_core as core; +use grin_wallet_util::grin_core::core::hash::Hashed; +use grin_wallet_util::grin_core::core::Transaction; +use grin_wallet_util::grin_core::global; +use grin_wallet_util::grin_keychain::ExtKeychain; +use grin_wallet_util::grin_util::secp::key::SecretKey; +use grin_wallet_util::grin_util::Mutex; +use log::error; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +type Wallet = Arc< + Mutex< + Box< + dyn WalletInst< + 'static, + DefaultLCProvider<'static, LocalWalletClient, ExtKeychain>, + LocalWalletClient, + ExtKeychain, + >, + >, + >, +>; + +fn revert( + test_dir: &'static str, +) -> Result< + ( + Arc, + Arc, + u64, + u64, + Transaction, + Wallet, + Option, + Wallet, + Option, + ), + libwallet::Error, +> { + let mut wallet_proxy = create_wallet_proxy(test_dir); + let stopper = wallet_proxy.running.clone(); + let chain = wallet_proxy.chain.clone(); + let test_dir2 = format!("{}/chain2", test_dir); + let wallet_proxy2 = create_wallet_proxy(&test_dir2); + let chain2 = wallet_proxy2.chain.clone(); + let stopper2 = wallet_proxy2.running.clone(); + + create_wallet_and_add!( + client1, + wallet1, + mask1_i, + test_dir, + "wallet1", + None, + &mut wallet_proxy, + false + ); + let mask1 = mask1_i.as_ref(); + + create_wallet_and_add!( + client2, + wallet2, + mask2_i, + test_dir, + "wallet2", + None, + &mut wallet_proxy, + false + ); + let mask2 = mask2_i.as_ref(); + + // Set the wallet proxy listener running + std::thread::spawn(move || { + if let Err(e) = wallet_proxy.run() { + error!("Wallet Proxy error: {}", e); + } + }); + + owner(Some(wallet1.clone()), mask1, None, |api, m| { + api.create_account_path(m, "a")?; + api.set_active_account(m, "a")?; + Ok(()) + })?; + + owner(Some(wallet2.clone()), mask2, None, |api, m| { + api.create_account_path(m, "b")?; + api.set_active_account(m, "b")?; + Ok(()) + })?; + + let reward = core::consensus::REWARD; + let cm = global::coinbase_maturity() as u64; + let sent = reward * 2; + + // Mine some blocks + let bh = 10u64; + award_blocks_to_wallet(&chain, wallet1.clone(), mask1, bh as usize, false)?; + + // Sanity check contents + owner(Some(wallet1.clone()), mask1, None, |api, m| { + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, bh * reward); + assert_eq!(info.amount_currently_spendable, (bh - cm) * reward); + assert_eq!(info.amount_reverted, 0); + // check tx log as well + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + let (c, _) = libwallet::TxLogEntry::sum_confirmed(&txs); + assert_eq!(info.total, c); + assert_eq!(txs.len(), bh as usize); + Ok(()) + })?; + + owner(Some(wallet2.clone()), mask2, None, |api, m| { + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, 0); + assert_eq!(info.amount_currently_spendable, 0); + assert_eq!(info.amount_reverted, 0); + // check tx log as well + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + assert_eq!(txs.len(), 0); + Ok(()) + })?; + + // Send some funds + let mut tx = None; + owner(Some(wallet1.clone()), mask1, None, |api, m| { + // send to send + let args = InitTxArgs { + src_acct_name: None, + amount: sent, + minimum_confirmations: cm, + max_outputs: 500, + num_change_outputs: 1, + selection_strategy_is_use_all: false, + ..Default::default() + }; + let slate = api.init_send_tx(m, args)?; + // output tx file + let send_file = format!("{}/part_tx_1.tx", test_dir); + PathToSlate(send_file.into()).put_tx(&slate)?; + api.tx_lock_outputs(m, &slate, 0)?; + let slate = client1.send_tx_slate_direct("wallet2", &slate)?; + let slate = api.finalize_tx(m, &slate)?; + tx = Some(slate.tx); + + Ok(()) + })?; + let tx = tx.unwrap(); + + // Check funds have been received + owner(Some(wallet2.clone()), mask2, None, |api, m| { + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, 0); + assert_eq!(info.amount_currently_spendable, 0); + assert_eq!(info.amount_reverted, 0); + // check tx log as well + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!(tx.tx_type, libwallet::TxLogEntryType::TxReceived); + assert!(!tx.confirmed); + Ok(()) + })?; + + // Update parallel chain + assert_eq!(chain2.head_header().unwrap().height, 0); + for i in 0..bh { + let hash = chain.get_header_by_height(i + 1).unwrap().hash(); + let block = chain.get_block(&hash).unwrap(); + process_block(&chain2, block); + } + assert_eq!(chain2.head_header().unwrap().height, bh); + + // Build 2 blocks at same height: 1 with the tx, 1 without + let head = chain.head_header().unwrap(); + let block_with = + create_block_for_wallet(&chain, head.clone(), vec![&tx], wallet1.clone(), mask1)?; + let block_without = create_block_for_wallet(&chain, head, vec![], wallet1.clone(), mask1)?; + + // Add block with tx to the chain + process_block(&chain, block_with.clone()); + assert_eq!(chain.head_header().unwrap(), block_with.header); + + // Add block without tx to the parallel chain + process_block(&chain2, block_without.clone()); + assert_eq!(chain2.head_header().unwrap(), block_without.header); + + let bh = bh + 1; + + // Check funds have been confirmed + owner(Some(wallet2.clone()), mask2, None, |api, m| { + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, sent); + assert_eq!(info.amount_currently_spendable, sent); + assert_eq!(info.amount_reverted, 0); + // check tx log as well + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!(tx.tx_type, libwallet::TxLogEntryType::TxReceived); + assert!(tx.confirmed); + assert!(tx.kernel_excess.is_some()); + assert!(tx.reverted_after.is_none()); + Ok(()) + })?; + + // Attach more blocks to the parallel chain, making it the longest one + award_block_to_wallet(&chain2, vec![], wallet1.clone(), mask1)?; + assert_eq!(chain2.head_header().unwrap().height, bh + 1); + let new_head = chain2 + .get_block(&chain2.head_header().unwrap().hash()) + .unwrap(); + + // Input blocks from parallel chain to original chain, updating it as well + // and effectively reverting the transaction + process_block(&chain, block_without.clone()); // This shouldn't update the head + assert_eq!(chain.head_header().unwrap(), block_with.header); + process_block(&chain, new_head.clone()); // But this should! + assert_eq!(chain.head_header().unwrap(), new_head.header); + + let bh = bh + 1; + + // Check funds have been reverted + owner(Some(wallet2.clone()), mask2, None, |api, m| { + api.scan(m, None, false)?; + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, 0); + assert_eq!(info.amount_currently_spendable, 0); + assert_eq!(info.amount_reverted, sent); + // check tx log as well + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!(tx.tx_type, libwallet::TxLogEntryType::TxReverted); + assert!(!tx.confirmed); + assert!(tx.reverted_after.is_some()); + Ok(()) + })?; + + stopper2.store(false, Ordering::Relaxed); + Ok(( + chain, stopper, sent, bh, tx, wallet1, mask1_i, wallet2, mask2_i, + )) +} + +fn revert_reconfirm_impl(test_dir: &'static str) -> Result<(), libwallet::Error> { + let (chain, stopper, sent, bh, tx, wallet1, mask1_i, wallet2, mask2_i) = revert(test_dir)?; + let mask1 = mask1_i.as_ref(); + let mask2 = mask2_i.as_ref(); + + // Include the tx into the chain again, the tx should no longer be reverted + award_block_to_wallet(&chain, vec![&tx], wallet1.clone(), mask1)?; + + let bh = bh + 1; + + // Check funds have been confirmed again + owner(Some(wallet2.clone()), mask2, None, |api, m| { + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, sent); + assert_eq!(info.amount_currently_spendable, sent); + assert_eq!(info.amount_reverted, 0); + // check tx log as well + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!(tx.tx_type, libwallet::TxLogEntryType::TxReceived); + assert!(tx.confirmed); + assert!(tx.reverted_after.is_none()); + Ok(()) + })?; + + // let logging finish + stopper.store(false, Ordering::Relaxed); + thread::sleep(Duration::from_millis(1000)); + Ok(()) +} + +fn revert_cancel_impl(test_dir: &'static str) -> Result<(), libwallet::Error> { + let (_, stopper, sent, bh, _, _, _, wallet2, mask2_i) = revert(test_dir)?; + let mask2 = mask2_i.as_ref(); + + // Cancelling tx + owner(Some(wallet2.clone()), mask2, None, |api, m| { + // Sanity check + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, 0); + assert_eq!(info.amount_currently_spendable, 0); + assert_eq!(info.amount_reverted, sent); + + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + + // Cancel + api.cancel_tx(m, Some(tx.id), None)?; + + // Check updated summary info + let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?; + assert!(refreshed); + assert_eq!(info.last_confirmed_height, bh); + assert_eq!(info.total, 0); + assert_eq!(info.amount_currently_spendable, 0); + assert_eq!(info.amount_reverted, 0); + + // Check updated tx log + let (_, txs) = api.retrieve_txs(m, true, None, None)?; + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!(tx.tx_type, libwallet::TxLogEntryType::TxReceivedCancelled); + Ok(()) + })?; + + // let logging finish + stopper.store(false, Ordering::Relaxed); + thread::sleep(Duration::from_millis(1000)); + Ok(()) +} + +#[test] +fn tx_revert_reconfirm() { + let test_dir = "test_output/revert_tx"; + setup(test_dir); + if let Err(e) = revert_reconfirm_impl(test_dir) { + panic!("Libwallet Error: {} - {}", e, e.backtrace().unwrap()); + } + clean_output_dir(test_dir); +} + +#[test] +fn tx_revert_cancel() { + let test_dir = "test_output/revert_tx_cancel"; + setup(test_dir); + if let Err(e) = revert_cancel_impl(test_dir) { + panic!("Libwallet Error: {} - {}", e, e.backtrace().unwrap()); + } + clean_output_dir(test_dir); +} diff --git a/impls/src/test_framework/mod.rs b/impls/src/test_framework/mod.rs index cfd4df02d..283189a62 100644 --- a/impls/src/test_framework/mod.rs +++ b/impls/src/test_framework/mod.rs @@ -108,14 +108,13 @@ fn height_range_to_pmmr_indices_local( } } -/// Adds a block with a given reward to the chain and mines it -pub fn add_block_with_reward( +fn create_block_with_reward( chain: &Chain, + prev: core::core::BlockHeader, txs: Vec<&Transaction>, reward_output: Output, reward_kernel: TxKernel, -) { - let prev = chain.head_header().unwrap(); +) -> core::core::Block { let next_header_info = consensus::next_difficulty(1, chain.difficulty_iter().unwrap()); let mut b = core::core::Block::new( &prev, @@ -134,26 +133,36 @@ pub fn add_block_with_reward( global::min_edge_bits(), ) .unwrap(); - chain.process_block(b, chain::Options::MINE).unwrap(); - chain.validate(false).unwrap(); + b } -/// adds a reward output to a wallet, includes that reward in a block, mines -/// the block and adds it to the chain, with option transactions included. -/// Helpful for building up precise wallet balances for testing. -pub fn award_block_to_wallet<'a, L, C, K>( +/// Adds a block with a given reward to the chain and mines it +pub fn add_block_with_reward( chain: &Chain, txs: Vec<&Transaction>, + reward_output: Output, + reward_kernel: TxKernel, +) { + let prev = chain.head_header().unwrap(); + let block = create_block_with_reward(chain, prev, txs, reward_output, reward_kernel); + process_block(chain, block); +} + +/// adds a reward output to a wallet, includes that reward in a block +/// and return the block +pub fn create_block_for_wallet<'a, L, C, K>( + chain: &Chain, + prev: core::core::BlockHeader, + txs: Vec<&Transaction>, wallet: Arc + 'a>>>, keychain_mask: Option<&SecretKey>, -) -> Result<(), libwallet::Error> +) -> Result where L: WalletLCProvider<'a, C, K>, C: NodeClient + 'a, K: keychain::Keychain + 'a, { // build block fees - let prev = chain.head_header().unwrap(); let fee_amt = txs.iter().map(|tx| tx.fee()).sum(); let block_fees = BlockFees { fees: fee_amt, @@ -166,10 +175,35 @@ where let w = w_lock.lc_provider()?.wallet_inst()?; foreign::build_coinbase(&mut **w, keychain_mask, &block_fees, false)? }; - add_block_with_reward(chain, txs, coinbase_tx.output, coinbase_tx.kernel); + let block = create_block_with_reward(chain, prev, txs, coinbase_tx.output, coinbase_tx.kernel); + Ok(block) +} + +/// adds a reward output to a wallet, includes that reward in a block, mines +/// the block and adds it to the chain, with option transactions included. +/// Helpful for building up precise wallet balances for testing. +pub fn award_block_to_wallet<'a, L, C, K>( + chain: &Chain, + txs: Vec<&Transaction>, + wallet: Arc + 'a>>>, + keychain_mask: Option<&SecretKey>, +) -> Result<(), libwallet::Error> +where + L: WalletLCProvider<'a, C, K>, + C: NodeClient + 'a, + K: keychain::Keychain + 'a, +{ + let prev = chain.head_header().unwrap(); + let block = create_block_for_wallet(chain, prev, txs, wallet, keychain_mask)?; + process_block(chain, block); Ok(()) } +pub fn process_block(chain: &Chain, block: core::core::Block) { + chain.process_block(block, chain::Options::MINE).unwrap(); + chain.validate(false).unwrap(); +} + /// Award a blocks to a wallet directly pub fn award_blocks_to_wallet<'a, L, C, K>( chain: &Chain, diff --git a/libwallet/src/internal/tx.rs b/libwallet/src/internal/tx.rs index f19c74d8c..16ae8de11 100644 --- a/libwallet/src/internal/tx.rs +++ b/libwallet/src/internal/tx.rs @@ -300,8 +300,9 @@ where return Err(ErrorKind::TransactionDoesntExist(tx_id_string).into()); } let tx = tx_vec[0].clone(); - if tx.tx_type != TxLogEntryType::TxSent && tx.tx_type != TxLogEntryType::TxReceived { - return Err(ErrorKind::TransactionNotCancellable(tx_id_string).into()); + match tx.tx_type { + TxLogEntryType::TxSent | TxLogEntryType::TxReceived | TxLogEntryType::TxReverted => {} + _ => return Err(ErrorKind::TransactionNotCancellable(tx_id_string).into()), } if tx.confirmed { return Err(ErrorKind::TransactionNotCancellable(tx_id_string).into()); diff --git a/libwallet/src/internal/updater.rs b/libwallet/src/internal/updater.rs index be82d62f5..c9d0b5332 100644 --- a/libwallet/src/internal/updater.rs +++ b/libwallet/src/internal/updater.rs @@ -15,7 +15,7 @@ //! Utilities to check the status of all the outputs we have stored in //! the wallet storage and update them. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; use crate::error::Error; @@ -121,7 +121,8 @@ where true => { !tx_entry.confirmed && (tx_entry.tx_type == TxLogEntryType::TxReceived - || tx_entry.tx_type == TxLogEntryType::TxSent) + || tx_entry.tx_type == TxLogEntryType::TxSent + || tx_entry.tx_type == TxLogEntryType::TxReverted) } false => true, }; @@ -157,14 +158,13 @@ pub fn map_wallet_outputs<'a, T: ?Sized, C, K>( keychain_mask: Option<&SecretKey>, parent_key_id: &Identifier, update_all: bool, -) -> Result)>, Error> +) -> Result, Option, bool)>, Error> where T: WalletBackend<'a, C, K>, C: NodeClient + 'a, K: Keychain + 'a, { - let mut wallet_outputs: HashMap)> = - HashMap::new(); + let mut wallet_outputs = HashMap::new(); let keychain = wallet.keychain(keychain_mask)?; let unspents: Vec = wallet .iter() @@ -174,7 +174,7 @@ where let tx_entries = retrieve_txs(wallet, None, None, Some(&parent_key_id), true)?; // Only select outputs that are actually involved in an outstanding transaction - let unspents: Vec = match update_all { + let unspents = match update_all { false => unspents .into_iter() .filter(|x| match x.tx_log_entry.as_ref() { @@ -192,7 +192,13 @@ where .commit(out.value, &out.key_id, SwitchCommitmentType::Regular) .unwrap(), // TODO: proper support for different switch commitment schemes }; - wallet_outputs.insert(commit, (out.key_id.clone(), out.mmr_index)); + let val = ( + out.key_id.clone(), + out.mmr_index, + out.tx_log_entry, + out.status == OutputStatus::Unspent, + ); + wallet_outputs.insert(commit, val); } Ok(wallet_outputs) } @@ -201,7 +207,7 @@ where pub fn cancel_tx_and_outputs<'a, T: ?Sized, C, K>( wallet: &mut T, keychain_mask: Option<&SecretKey>, - tx: TxLogEntry, + mut tx: TxLogEntry, outputs: Vec, parent_key_id: &Identifier, ) -> Result<(), Error> @@ -214,7 +220,7 @@ where for mut o in outputs { // unlock locked outputs - if o.status == OutputStatus::Unconfirmed { + if o.status == OutputStatus::Unconfirmed || o.status == OutputStatus::Reverted { batch.delete(&o.key_id, &o.mmr_index)?; } if o.status == OutputStatus::Locked { @@ -222,12 +228,12 @@ where batch.save(o)?; } } - let mut tx = tx; - if tx.tx_type == TxLogEntryType::TxSent { - tx.tx_type = TxLogEntryType::TxSentCancelled; - } - if tx.tx_type == TxLogEntryType::TxReceived { - tx.tx_type = TxLogEntryType::TxReceivedCancelled; + match tx.tx_type { + TxLogEntryType::TxSent => tx.tx_type = TxLogEntryType::TxSentCancelled, + TxLogEntryType::TxReceived | TxLogEntryType::TxReverted => { + tx.tx_type = TxLogEntryType::TxReceivedCancelled + } + _ => {} } batch.save_tx_log_entry(tx, parent_key_id)?; batch.commit()?; @@ -238,8 +244,9 @@ where pub fn apply_api_outputs<'a, T: ?Sized, C, K>( wallet: &mut T, keychain_mask: Option<&SecretKey>, - wallet_outputs: &HashMap)>, + wallet_outputs: &HashMap, Option, bool)>, api_outputs: &HashMap, + reverted_kernels: HashSet, height: u64, parent_key_id: &Identifier, ) -> Result<(), Error> @@ -264,7 +271,7 @@ where return Ok(()); } let mut batch = wallet.batch(keychain_mask)?; - for (commit, (id, mmr_index)) in wallet_outputs.iter() { + for (commit, (id, mmr_index, _, _)) in wallet_outputs.iter() { if let Ok(mut output) = batch.get(id, mmr_index) { match api_outputs.get(&commit) { Some(o) => { @@ -296,12 +303,19 @@ where // also mark the transaction in which this output is involved as confirmed // note that one involved input/output confirmation SHOULD be enough // to reliably confirm the tx - if !output.is_coinbase && output.status == OutputStatus::Unconfirmed { + if !output.is_coinbase + && (output.status == OutputStatus::Unconfirmed + || output.status == OutputStatus::Reverted) + { let tx = batch.tx_log_iter().find(|t| { Some(t.id) == output.tx_log_entry && t.parent_key_id == *parent_key_id }); if let Some(mut t) = tx { + if t.tx_type == TxLogEntryType::TxReverted { + t.tx_type = TxLogEntryType::TxReceived; + t.reverted_after = None; + } t.update_confirmation_ts(); t.confirmed = true; batch.save_tx_log_entry(t, &parent_key_id)?; @@ -310,11 +324,35 @@ where output.height = o.1; output.mark_unspent(); } - None => output.mark_spent(), - }; + None => { + if !output.is_coinbase + && output + .tx_log_entry + .map(|i| reverted_kernels.contains(&i)) + .unwrap_or(false) + { + output.mark_reverted(); + } else { + output.mark_spent(); + } + } + } batch.save(output)?; } } + + for mut tx in batch.tx_log_iter() { + if reverted_kernels.contains(&tx.id) && tx.parent_key_id == *parent_key_id { + tx.tx_type = TxLogEntryType::TxReverted; + tx.reverted_after = tx.confirmation_ts.clone().and_then(|t| { + let now = chrono::Utc::now(); + (now - t).to_std().ok() + }); + tx.confirmed = false; + batch.save_tx_log_entry(tx, &parent_key_id)?; + } + } + { batch.save_last_confirmed_height(parent_key_id, height)?; } @@ -349,11 +387,17 @@ where .w2n_client() .get_outputs_from_node(wallet_output_keys)?; + // For any disappeared output, check the on-chain status of the corresponding transaction kernel + // If it is no longer present, the transaction was reverted due to a re-org + let reverted_kernels = + find_reverted_kernels(wallet, &wallet_outputs, &api_outputs, parent_key_id)?; + apply_api_outputs( wallet, keychain_mask, &wallet_outputs, &api_outputs, + reverted_kernels, height, parent_key_id, )?; @@ -361,6 +405,53 @@ where Ok(()) } +fn find_reverted_kernels<'a, T: ?Sized, C, K>( + wallet: &mut T, + wallet_outputs: &HashMap, Option, bool)>, + api_outputs: &HashMap, + parent_key_id: &Identifier, +) -> Result, Error> +where + T: WalletBackend<'a, C, K>, + C: NodeClient + 'a, + K: Keychain + 'a, +{ + let mut client = wallet.w2n_client().clone(); + let mut ids = HashSet::new(); + + // Get transaction IDs for outputs that are no longer unspent + for (commit, (_, _, tx_id, was_unspent)) in wallet_outputs { + if let Some(tx_id) = *tx_id { + if *was_unspent && !api_outputs.contains_key(commit) { + ids.insert(tx_id); + } + } + } + + // Get corresponding kernels + let kernels = wallet + .tx_log_iter() + .filter(|t| { + ids.contains(&t.id) + && t.parent_key_id == *parent_key_id + && t.tx_type == TxLogEntryType::TxReceived + }) + .filter_map(|t| { + t.kernel_excess + .map(|e| (t.id, e, t.kernel_lookup_min_height)) + }); + + // Check each of the kernels on-chain + let mut reverted = HashSet::new(); + for (id, excess, min_height) in kernels { + if client.get_kernel(&excess, min_height, None)?.is_none() { + reverted.insert(id); + } + } + + Ok(reverted) +} + fn clean_old_unconfirmed<'a, T: ?Sized, C, K>( wallet: &mut T, keychain_mask: Option<&SecretKey>, @@ -414,6 +505,7 @@ where let mut awaiting_finalization_total = 0; let mut unconfirmed_total = 0; let mut locked_total = 0; + let mut reverted_total = 0; for out in outputs { match out.status { @@ -440,6 +532,7 @@ where OutputStatus::Locked => { locked_total += out.value; } + OutputStatus::Reverted => reverted_total += out.value, OutputStatus::Spent => {} } } @@ -453,6 +546,7 @@ where amount_immature: immature_total, amount_locked: locked_total, amount_currently_spendable: unspent_total, + amount_reverted: reverted_total, }) } diff --git a/libwallet/src/types.rs b/libwallet/src/types.rs index d25c0bf08..c84a492d4 100644 --- a/libwallet/src/types.rs +++ b/libwallet/src/types.rs @@ -36,6 +36,7 @@ use serde; use serde_json; use std::collections::HashMap; use std::fmt; +use std::time::Duration; use uuid::Uuid; /// Combined trait to allow dynamic wallet dispatch @@ -483,16 +484,26 @@ impl OutputData { /// Marks this output as unspent if it was previously unconfirmed pub fn mark_unspent(&mut self) { - if let OutputStatus::Unconfirmed = self.status { - self.status = OutputStatus::Unspent - }; + match self.status { + OutputStatus::Unconfirmed | OutputStatus::Reverted => { + self.status = OutputStatus::Unspent + } + _ => {} + } } /// Mark an output as spent pub fn mark_spent(&mut self) { match self.status { - OutputStatus::Unspent => self.status = OutputStatus::Spent, - OutputStatus::Locked => self.status = OutputStatus::Spent, + OutputStatus::Unspent | OutputStatus::Locked => self.status = OutputStatus::Spent, + _ => (), + } + } + + /// Mark an output as reverted + pub fn mark_reverted(&mut self) { + match self.status { + OutputStatus::Unspent => self.status = OutputStatus::Reverted, _ => (), } } @@ -511,6 +522,8 @@ pub enum OutputStatus { Locked, /// Spent Spent, + /// Reverted + Reverted, } impl fmt::Display for OutputStatus { @@ -520,6 +533,7 @@ impl fmt::Display for OutputStatus { OutputStatus::Unspent => write!(f, "Unspent"), OutputStatus::Locked => write!(f, "Locked"), OutputStatus::Spent => write!(f, "Spent"), + OutputStatus::Reverted => write!(f, "Reverted"), } } } @@ -707,6 +721,9 @@ pub struct WalletInfo { /// amount locked via previous transactions #[serde(with = "secp_ser::string_or_u64")] pub amount_locked: u64, + /// amount previously confirmed, now reverted + #[serde(with = "secp_ser::string_or_u64")] + pub amount_reverted: u64, } /// Types of transactions that can be contained within a TXLog entry @@ -722,6 +739,8 @@ pub enum TxLogEntryType { TxReceivedCancelled, /// Sent transaction that was rolled back by user TxSentCancelled, + /// Received transaction that was reverted on-chain + TxReverted, } impl fmt::Display for TxLogEntryType { @@ -732,6 +751,7 @@ impl fmt::Display for TxLogEntryType { TxLogEntryType::TxSent => write!(f, "Sent Tx"), TxLogEntryType::TxReceivedCancelled => write!(f, "Received Tx\n- Cancelled"), TxLogEntryType::TxSentCancelled => write!(f, "Sent Tx\n- Cancelled"), + TxLogEntryType::TxReverted => write!(f, "Received Tx\n- Reverted"), } } } @@ -791,6 +811,9 @@ pub struct TxLogEntry { /// Additional info needed to stored payment proof #[serde(default)] pub payment_proof: Option, + /// Track the time it took for a transaction to get reverted + #[serde(with = "option_duration_as_secs", default)] + pub reverted_after: Option, } impl ser::Writeable for TxLogEntry { @@ -828,6 +851,7 @@ impl TxLogEntry { kernel_excess: None, kernel_lookup_min_height: None, payment_proof: None, + reverted_after: None, } } @@ -969,3 +993,83 @@ impl ser::Readable for WalletInitStatus { serde_json::from_slice(&data[..]).map_err(|_| ser::Error::CorruptedData) } } + +/// Serializes an Option to and from a string +pub mod option_duration_as_secs { + use serde::de::Error; + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + /// + pub fn serialize(dur: &Option, serializer: S) -> Result + where + S: Serializer, + { + match dur { + Some(dur) => serializer.serialize_str(&format!("{}", dur.as_secs())), + None => serializer.serialize_none(), + } + } + + /// + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + match Option::::deserialize(deserializer)? { + Some(s) => { + let secs = s + .parse::() + .map_err(|err| Error::custom(err.to_string()))?; + Ok(Some(Duration::from_secs(secs))) + } + None => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] + struct TestSer { + #[serde(with = "option_duration_as_secs", default)] + dur: Option, + } + + #[test] + fn duration_serde() { + let some = TestSer { + dur: Some(Duration::from_secs(100)), + }; + let val = serde_json::to_value(some.clone()).unwrap(); + if let Value::Object(o) = &val { + if let Value::String(s) = o.get("dur").unwrap() { + assert_eq!(s, "100"); + } else { + panic!("Invalid type"); + } + } else { + panic!("Invalid type") + } + assert_eq!(some, serde_json::from_value(val).unwrap()); + + let none = TestSer { dur: None }; + let val = serde_json::to_value(none.clone()).unwrap(); + if let Value::Object(o) = &val { + if let Value::Null = o.get("dur").unwrap() { + // ok + } else { + panic!("Invalid type"); + } + } else { + panic!("Invalid type") + } + assert_eq!(none, serde_json::from_value(val).unwrap()); + + let none2 = serde_json::from_str::("{}").unwrap(); + assert_eq!(none, none2); + } +}