mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-16 11:48:55 +00:00
build + wallet + ui: fix dependencies connection setup, mnemonic validation, wallet creation, methods to work with wallet, update translations
This commit is contained in:
Generated
+315
-434
File diff suppressed because it is too large
Load Diff
+19
-14
@@ -16,42 +16,47 @@ log = "0.4"
|
||||
|
||||
## node
|
||||
openssl-sys = { version = "0.9.82", features = ["vendored"] }
|
||||
grin_api = { path = "../grin/node/api" }
|
||||
grin_chain = { path = "../grin/node/chain" }
|
||||
grin_config = { path = "../grin/node/config" }
|
||||
grin_core = { path = "../grin/node/core" }
|
||||
grin_keychain = { path = "../grin/node/keychain" }
|
||||
grin_p2p = { path = "../grin/node/p2p" }
|
||||
grin_servers = { path = "../grin/node/servers" }
|
||||
grin_util = { path = "../grin/node/util" }
|
||||
grin_api = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
grin_chain = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
grin_config = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
grin_core = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
grin_p2p = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
grin_servers = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
grin_keychain = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
grin_util = { git = "https://github.com/mimblewimble/grin", branch = "master" }
|
||||
|
||||
## wallet
|
||||
grin_wallet_impls = { path = "../grin/wallet/impls" }
|
||||
#grin_wallet_api = "5.1.0"
|
||||
#grin_wallet_libwallet = "5.1.0"
|
||||
grin_wallet_impls = { git = "https://github.com/mimblewimble/grin-wallet", branch = "master" }
|
||||
grin_wallet_api = { git = "https://github.com/mimblewimble/grin-wallet", branch = "master" }
|
||||
grin_wallet_libwallet = { git = "https://github.com/mimblewimble/grin-wallet", branch = "master" }
|
||||
grin_wallet_util = { git = "https://github.com/mimblewimble/grin-wallet", branch = "master" }
|
||||
#grin_wallet_controller = "5.1.0"
|
||||
#grin_wallet_config = "5.1.0"
|
||||
#grin_wallet_util = "5.1.0"
|
||||
|
||||
## ui
|
||||
pollster = "0.3.0"
|
||||
wgpu = "0.16.1"
|
||||
egui = { version = "0.22.0", default-features = false }
|
||||
egui_extras = { version = "0.22.0", features = ["image"] }
|
||||
rust-i18n = "2.1.0"
|
||||
|
||||
## other
|
||||
futures = "0.3"
|
||||
dirs = "5.0.1"
|
||||
once_cell = "1.10.0"
|
||||
rust-i18n = "2.0.0"
|
||||
sys-locale = "0.3.0"
|
||||
chrono = "0.4.23"
|
||||
lazy_static = "1.4.0"
|
||||
toml = "0.7.4"
|
||||
serde = "1"
|
||||
pnet = "0.33.0"
|
||||
pnet = "0.34.0"
|
||||
zeroize = "1.6.0"
|
||||
url = "2.4.0"
|
||||
parking_lot = "0.10.2"
|
||||
uuid = { version = "0.8.2", features = ["serde", "v4"] }
|
||||
num-bigint = "0.4.3"
|
||||
byteorder = "1.3"
|
||||
ed25519-dalek = "1.0.0-pre.4"
|
||||
|
||||
# stratum server
|
||||
serde_derive = "1"
|
||||
|
||||
@@ -16,6 +16,7 @@ wallets:
|
||||
words_count: 'Words count:'
|
||||
enter_word: 'Enter word #%{number}:'
|
||||
not_valid_word: Entered word is not valid
|
||||
not_valid_phrase: Entered phrase is not valid
|
||||
create_phrase_desc: Safely write down and save your recovery phrase.
|
||||
restore_phrase_desc: Enter words from your saved recovery phrase.
|
||||
setup_conn_desc: Choose how your wallet connects to the network.
|
||||
|
||||
@@ -16,6 +16,7 @@ wallets:
|
||||
words_count: 'Количество слов:'
|
||||
enter_word: 'Введите слово #%{number}:'
|
||||
not_valid_word: Введено недопустимое слово
|
||||
not_valid_phrase: Введена недопустимая фраза восстановления
|
||||
create_phrase_desc: Безопасно запишите и сохраните вашу фразу восстановления.
|
||||
restore_phrase_desc: Введите слова из вашей сохранённой фразы восстановления.
|
||||
setup_conn_desc: Выберите способ подключения вашего кошелька к сети.
|
||||
|
||||
@@ -168,7 +168,7 @@ impl View {
|
||||
let size = egui::Vec2::splat(2.0 * r + 5.0);
|
||||
let (rect, br) = ui.allocate_at_least(size, Sense::click_and_drag());
|
||||
|
||||
let mut icon_size = 22.0;
|
||||
let mut icon_size = 24.0;
|
||||
let mut icon_color = Colors::TEXT_BUTTON;
|
||||
|
||||
// Increase radius and change icon size and color on-hover.
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::gui::views::{Modal, ModalPosition, View};
|
||||
use crate::gui::views::wallets::creation::MnemonicSetup;
|
||||
use crate::gui::views::wallets::creation::types::{PhraseMode, Step};
|
||||
use crate::gui::views::wallets::setup::ConnectionSetup;
|
||||
use crate::wallet::WalletList;
|
||||
|
||||
/// Wallet creation content.
|
||||
pub struct WalletCreation {
|
||||
@@ -70,11 +71,12 @@ impl WalletCreation {
|
||||
pub const NAME_PASS_MODAL: &'static str = "name_pass_modal";
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
// Show wallet creation step description and confirmation bottom panel.
|
||||
// Show wallet creation step description and confirmation panel.
|
||||
if self.step.is_some() {
|
||||
egui::TopBottomPanel::bottom("wallet_creation_step_panel")
|
||||
.frame(egui::Frame {
|
||||
stroke: View::DEFAULT_STROKE,
|
||||
fill: Colors::FILL_DARK,
|
||||
inner_margin: Margin {
|
||||
left: View::far_left_inset_margin(ui) + 4.0,
|
||||
right: View::get_right_inset() + 4.0,
|
||||
@@ -85,65 +87,15 @@ impl WalletCreation {
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
if let Some(step) = &self.step {
|
||||
// Setup step description text and availability.
|
||||
let (step_text, step_available) = match step {
|
||||
Step::EnterMnemonic => {
|
||||
let mode = &self.mnemonic_setup.mnemonic.mode;
|
||||
let text = if mode == &PhraseMode::Generate {
|
||||
t!("wallets.create_phrase_desc")
|
||||
} else {
|
||||
t!("wallets.restore_phrase_desc")
|
||||
};
|
||||
let available = !self
|
||||
.mnemonic_setup
|
||||
.mnemonic
|
||||
.words
|
||||
.contains(&String::from(""));
|
||||
(text, available)
|
||||
}
|
||||
Step::ConfirmMnemonic => {
|
||||
let text = t!("wallets.restore_phrase_desc");
|
||||
let available = !self
|
||||
.mnemonic_setup
|
||||
.mnemonic
|
||||
.confirm_words
|
||||
.contains(&String::from(""));
|
||||
(text, available)
|
||||
},
|
||||
Step::SetupConnection => (t!("wallets.setup_conn_desc"), true)
|
||||
};
|
||||
// Show step description.
|
||||
ui.label(RichText::new(step_text).size(16.0).color(Colors::GRAY));
|
||||
|
||||
// Show next step button if there are no empty words.
|
||||
|
||||
if step_available {
|
||||
// Setup button text.
|
||||
let (next_text, color) = if step == &Step::SetupConnection {
|
||||
(format!("{} {}", CHECK, t!("complete")), Colors::GOLD)
|
||||
} else {
|
||||
let text = format!("{} {}", SHARE_FAT, t!("continue"));
|
||||
(text, Colors::WHITE)
|
||||
};
|
||||
|
||||
ui.add_space(4.0);
|
||||
// Show button.
|
||||
View::button(ui, next_text.to_uppercase(), color, || {
|
||||
self.forward();
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
self.step_control_ui(ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Show wallet creation step content.
|
||||
// Show wallet creation step content panel.
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
stroke: View::DEFAULT_STROKE,
|
||||
fill: if self.step.is_none() { Colors::FILL_DARK } else { Colors::WHITE },
|
||||
inner_margin: Margin {
|
||||
left: View::far_left_inset_margin(ui) + 4.0,
|
||||
right: View::get_right_inset() + 4.0,
|
||||
@@ -153,12 +105,74 @@ impl WalletCreation {
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
self.step_ui(ui, cb);
|
||||
self.step_content_ui(ui, cb);
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw [`Step`] description and confirmation control.
|
||||
fn step_control_ui(&mut self, ui: &mut egui::Ui) {
|
||||
if let Some(step) = &self.step {
|
||||
// Setup step description text and availability.
|
||||
let (step_text, mut step_available) = match step {
|
||||
Step::EnterMnemonic => {
|
||||
let mode = &self.mnemonic_setup.mnemonic.mode;
|
||||
let text = if mode == &PhraseMode::Generate {
|
||||
t!("wallets.create_phrase_desc")
|
||||
} else {
|
||||
t!("wallets.restore_phrase_desc")
|
||||
};
|
||||
let available = !self
|
||||
.mnemonic_setup
|
||||
.mnemonic
|
||||
.words
|
||||
.contains(&String::from(""));
|
||||
(text, available)
|
||||
}
|
||||
Step::ConfirmMnemonic => {
|
||||
let text = t!("wallets.restore_phrase_desc");
|
||||
let available = !self
|
||||
.mnemonic_setup
|
||||
.mnemonic
|
||||
.confirm_words
|
||||
.contains(&String::from(""));
|
||||
(text, available)
|
||||
},
|
||||
Step::SetupConnection => (t!("wallets.setup_conn_desc"), true)
|
||||
};
|
||||
// Show step description.
|
||||
ui.label(RichText::new(step_text).size(16.0).color(Colors::GRAY));
|
||||
|
||||
// Show error if entered phrase is not valid.
|
||||
if !self.mnemonic_setup.valid_phrase {
|
||||
step_available = false;
|
||||
ui.label(RichText::new(t!("wallets.not_valid_phrase"))
|
||||
.size(16.0)
|
||||
.color(Colors::RED));
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
// Show next step button if there are no empty words.
|
||||
if step_available {
|
||||
// Setup button text.
|
||||
let (next_text, color) = if step == &Step::SetupConnection {
|
||||
(format!("{} {}", CHECK, t!("complete")), Colors::GOLD)
|
||||
} else {
|
||||
let text = format!("{} {}", SHARE_FAT, t!("continue"));
|
||||
(text, Colors::WHITE)
|
||||
};
|
||||
|
||||
ui.add_space(4.0);
|
||||
// Show button.
|
||||
View::button(ui, next_text.to_uppercase(), color, || {
|
||||
self.forward();
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw wallet creation [`Step`] content.
|
||||
fn step_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
fn step_content_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
match &self.step {
|
||||
None => {
|
||||
// Show wallet creation message if step is empty.
|
||||
@@ -233,12 +247,23 @@ impl WalletCreation {
|
||||
if self.mnemonic_setup.mnemonic.mode == PhraseMode::Generate {
|
||||
Some(Step::ConfirmMnemonic)
|
||||
} else {
|
||||
Some(Step::SetupConnection)
|
||||
// Check if entered phrase was valid.
|
||||
if self.mnemonic_setup.valid_phrase {
|
||||
Some(Step::SetupConnection)
|
||||
} else {
|
||||
Some(Step::EnterMnemonic)
|
||||
}
|
||||
}
|
||||
}
|
||||
Step::ConfirmMnemonic => Some(Step::SetupConnection),
|
||||
Step::SetupConnection => {
|
||||
//TODO: Confirm mnemonic
|
||||
// Create wallet at last step.
|
||||
WalletList::create_wallet(
|
||||
self.name_edit.clone(),
|
||||
self.pass_edit.clone(),
|
||||
self.mnemonic_setup.mnemonic.get_phrase(),
|
||||
self.network_setup.get_ext_conn_url()
|
||||
).unwrap();
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -249,7 +274,7 @@ impl WalletCreation {
|
||||
/// Start wallet creation from showing [`Modal`] to enter name and password.
|
||||
pub fn show_name_pass_modal(&mut self) {
|
||||
// Reset modal values.
|
||||
self.hide_pass = false;
|
||||
self.hide_pass = true;
|
||||
self.modal_just_opened = true;
|
||||
self.name_edit = String::from("");
|
||||
self.pass_edit = String::from("");
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct MnemonicSetup {
|
||||
/// Current mnemonic phrase.
|
||||
pub(crate) mnemonic: Mnemonic,
|
||||
|
||||
/// Flag to check if entered phrase was valid.
|
||||
pub(crate) valid_phrase: bool,
|
||||
|
||||
/// Current word number to edit at [`Modal`].
|
||||
word_num_edit: usize,
|
||||
/// Entered word value for [`Modal`].
|
||||
@@ -37,6 +40,7 @@ impl Default for MnemonicSetup {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mnemonic: Mnemonic::default(),
|
||||
valid_phrase: true,
|
||||
word_num_edit: 0,
|
||||
word_edit: String::from(""),
|
||||
valid_word_edit: true
|
||||
@@ -290,6 +294,10 @@ impl MnemonicSetup {
|
||||
let close_modal = words.len() == self.word_num_edit
|
||||
|| !words.get(self.word_num_edit).unwrap().is_empty();
|
||||
if close_modal {
|
||||
// Check if entered phrase was valid when all words were entered.
|
||||
if !self.mnemonic.words.contains(&String::from("")) {
|
||||
self.valid_phrase = self.mnemonic.is_valid_phrase();
|
||||
}
|
||||
cb.hide_keyboard();
|
||||
modal.close();
|
||||
} else {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use grin_keychain::mnemonic::{from_entropy, search};
|
||||
use grin_keychain::mnemonic::{from_entropy, search, to_entropy};
|
||||
use rand::{Rng, thread_rng};
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
@@ -124,6 +124,16 @@ impl Mnemonic {
|
||||
valid && equal
|
||||
}
|
||||
|
||||
/// Check if current phrase is valid.
|
||||
pub fn is_valid_phrase(&self) -> bool {
|
||||
to_entropy(self.get_phrase().as_str()).is_ok()
|
||||
}
|
||||
|
||||
/// Get phrase from words.
|
||||
pub fn get_phrase(&self) -> String {
|
||||
self.words.iter().map(|x| x.to_string() + " ").collect::<String>()
|
||||
}
|
||||
|
||||
/// Generate list of words based on provided [`PhraseMode`] and [`PhraseSize`].
|
||||
fn generate_words(mode: &PhraseMode, size: &PhraseSize) -> Vec<String> {
|
||||
match mode {
|
||||
|
||||
@@ -52,6 +52,14 @@ impl ConnectionSetup {
|
||||
// Self { method: ConnectionMethod::Integrated }
|
||||
// }
|
||||
|
||||
/// Get external node connection URL.
|
||||
pub fn get_ext_conn_url(&self) -> Option<String> {
|
||||
match &self.method {
|
||||
ConnectionMethod::Integrated => None,
|
||||
ConnectionMethod::External(url) => Some(url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
ScrollArea::vertical()
|
||||
.id_source("wallet_connection_setup")
|
||||
@@ -99,11 +107,6 @@ impl ConnectionSetup {
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw external connections setup.
|
||||
fn external_conn_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
|
||||
}
|
||||
|
||||
/// Draw modal content.
|
||||
pub fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
|
||||
ui.add_space(6.0);
|
||||
|
||||
@@ -23,13 +23,10 @@ use crate::gui::views::{Modal, ModalContainer, Root, TitlePanel, TitleType, View
|
||||
use crate::gui::views::wallets::creation::{MnemonicSetup, WalletCreation};
|
||||
use crate::gui::views::wallets::setup::ConnectionSetup;
|
||||
use crate::gui::views::wallets::wallet::WalletContent;
|
||||
use crate::wallet::{Wallet, WalletList};
|
||||
use crate::wallet::WalletList;
|
||||
|
||||
/// Wallets content.
|
||||
pub struct WalletsContent {
|
||||
/// List of wallets.
|
||||
list: Vec<Wallet>,
|
||||
|
||||
/// Selected list item content.
|
||||
item_content: Option<WalletContent>,
|
||||
/// Wallet creation content.
|
||||
@@ -42,7 +39,6 @@ pub struct WalletsContent {
|
||||
impl Default for WalletsContent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
list: WalletList::list(),
|
||||
item_content: None,
|
||||
creation_content: WalletCreation::default(),
|
||||
modal_ids: vec![
|
||||
@@ -83,14 +79,22 @@ impl WalletsContent {
|
||||
// Show title panel.
|
||||
self.title_ui(ui, frame);
|
||||
|
||||
let is_wallet_panel_open = Self::is_dual_panel_mode(ui, frame) || self.list.is_empty();
|
||||
let wallets = WalletList::list();
|
||||
|
||||
let is_dual_panel = Self::is_dual_panel_mode(ui, frame);
|
||||
let is_wallet_creation = self.creation_content.can_go_back();
|
||||
let is_wallet_panel_open = is_dual_panel || is_wallet_creation || wallets.is_empty();
|
||||
let wallet_panel_width = self.wallet_panel_width(ui, frame);
|
||||
// Show wallet content.
|
||||
egui::SidePanel::right("wallet_panel")
|
||||
.resizable(false)
|
||||
.min_width(wallet_panel_width)
|
||||
.frame(egui::Frame {
|
||||
fill: if self.list.is_empty() { Colors::FILL_DARK } else { Colors::WHITE },
|
||||
fill: if !wallets.is_empty() || is_wallet_creation {
|
||||
Colors::WHITE
|
||||
} else {
|
||||
Colors::FILL_DARK
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.show_animated_inside(ui, is_wallet_panel_open, |ui| {
|
||||
@@ -98,7 +102,7 @@ impl WalletsContent {
|
||||
});
|
||||
|
||||
// Show list of wallets.
|
||||
if !self.list.is_empty() {
|
||||
if !is_wallet_creation && !wallets.is_empty() {
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
stroke: View::DEFAULT_STROKE,
|
||||
@@ -113,6 +117,12 @@ impl WalletsContent {
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
//TODO: wallets list
|
||||
for w in WalletList::list() {
|
||||
ui.label(w.config.get_name());
|
||||
View::button(ui, "get info".to_string(), Colors::GOLD, || {
|
||||
println!("12345 amount {}", w.get_txs_info(10).unwrap().2.total);
|
||||
});
|
||||
}
|
||||
});
|
||||
// Show wallet creation button if wallet panel is not open.
|
||||
if !is_wallet_panel_open {
|
||||
@@ -154,7 +164,7 @@ impl WalletsContent {
|
||||
ui: &mut egui::Ui,
|
||||
frame: &mut eframe::Frame,
|
||||
cb: &dyn PlatformCallbacks) {
|
||||
if self.list.is_empty() || self.item_content.is_none() {
|
||||
if WalletList::list().is_empty() || self.item_content.is_none() {
|
||||
self.creation_content.ui(ui, cb)
|
||||
} else {
|
||||
self.item_content.as_mut().unwrap().ui(ui, frame, cb);
|
||||
@@ -163,18 +173,19 @@ impl WalletsContent {
|
||||
|
||||
/// Get [`WalletContent`] panel width.
|
||||
fn wallet_panel_width(&self, ui: &mut egui::Ui, frame: &mut eframe::Frame) -> f32 {
|
||||
let is_wallet_creation = self.creation_content.can_go_back();
|
||||
let available_width = if WalletList::list().is_empty() || is_wallet_creation {
|
||||
ui.available_width()
|
||||
} else {
|
||||
ui.available_width() - Root::SIDE_PANEL_MIN_WIDTH
|
||||
};
|
||||
if Self::is_dual_panel_mode(ui, frame) {
|
||||
let min_width = (Root::SIDE_PANEL_MIN_WIDTH + View::get_right_inset()) as i64;
|
||||
let available_width = if self.list.is_empty() {
|
||||
ui.available_width()
|
||||
} else {
|
||||
ui.available_width() - Root::SIDE_PANEL_MIN_WIDTH
|
||||
} as i64;
|
||||
max(min_width, available_width) as f32
|
||||
max(min_width, available_width as i64) as f32
|
||||
} else {
|
||||
let dual_panel_root = Root::is_dual_panel_mode(frame);
|
||||
if dual_panel_root {
|
||||
ui.available_width()
|
||||
available_width
|
||||
} else {
|
||||
frame.info().window_info.size.x
|
||||
}
|
||||
|
||||
+7
-7
@@ -27,17 +27,17 @@ use crate::node::Node;
|
||||
|
||||
i18n!("locales");
|
||||
|
||||
mod node;
|
||||
mod wallet;
|
||||
mod settings;
|
||||
|
||||
pub mod gui;
|
||||
|
||||
// Include build information.
|
||||
pub mod built_info {
|
||||
include!(concat!(env!("OUT_DIR"), "/built.rs"));
|
||||
}
|
||||
|
||||
mod node;
|
||||
mod wallet;
|
||||
pub mod gui;
|
||||
|
||||
mod settings;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
@@ -100,7 +100,7 @@ pub fn start(mut options: eframe::NativeOptions, app_creator: eframe::AppCreator
|
||||
Node::start();
|
||||
}
|
||||
// Launch graphical interface.
|
||||
let _ = eframe::run_native("Grim", options, app_creator);
|
||||
eframe::run_native("Grim", options, app_creator).unwrap();
|
||||
}
|
||||
|
||||
/// Setup application [`egui::Style`] and [`egui::Visuals`].
|
||||
|
||||
+28
-15
@@ -12,8 +12,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use grin_core::global::ChainTypes;
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use crate::{AppConfig, Settings};
|
||||
@@ -22,8 +23,10 @@ use crate::wallet::WalletList;
|
||||
/// Wallet configuration.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct WalletConfig {
|
||||
/// Chain type for current wallet.
|
||||
chain_type: ChainTypes,
|
||||
/// Identifier for a wallet.
|
||||
id: OsString,
|
||||
id: i64,
|
||||
/// Readable wallet name.
|
||||
name: String,
|
||||
/// External node connection URL.
|
||||
@@ -35,13 +38,12 @@ const CONFIG_FILE_NAME: &'static str = "grim-wallet.toml";
|
||||
|
||||
impl WalletConfig {
|
||||
/// Create wallet config.
|
||||
pub fn create(id: OsString, name: String) -> WalletConfig {
|
||||
let config_path = Self::get_config_path(&id);
|
||||
let config = WalletConfig {
|
||||
id,
|
||||
name,
|
||||
external_node_url: None,
|
||||
};
|
||||
pub fn create(name: String, external_node_url: Option<String>) -> WalletConfig {
|
||||
let id = chrono::Utc::now().timestamp();
|
||||
let chain_type = AppConfig::chain_type();
|
||||
let config_path = Self::get_config_file_path(&chain_type, id);
|
||||
|
||||
let config = WalletConfig { chain_type, id, name, external_node_url };
|
||||
Settings::write_to_file(&config, config_path);
|
||||
config
|
||||
}
|
||||
@@ -56,18 +58,29 @@ impl WalletConfig {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get config file path for provided wallet identifier.
|
||||
fn get_config_path(id: &OsString) -> PathBuf {
|
||||
let chain_type = AppConfig::chain_type();
|
||||
let mut config_path = WalletList::get_wallets_base_dir(&chain_type);
|
||||
config_path.push(id);
|
||||
/// Get config file path for provided [`ChainTypes`] and wallet identifier.
|
||||
fn get_config_file_path(chain_type: &ChainTypes, id: i64) -> PathBuf {
|
||||
let mut config_path = WalletList::get_base_path(chain_type);
|
||||
config_path.push(id.to_string());
|
||||
// Create if the config path doesn't exist.
|
||||
if !config_path.exists() {
|
||||
let _ = fs::create_dir_all(config_path.clone());
|
||||
}
|
||||
config_path.push(CONFIG_FILE_NAME);
|
||||
config_path
|
||||
}
|
||||
|
||||
/// Get current wallet data path.
|
||||
pub fn get_data_path(&self) -> String {
|
||||
let chain_type = AppConfig::chain_type();
|
||||
let mut config_path = WalletList::get_base_path(&chain_type);
|
||||
config_path.push(self.id.to_string());
|
||||
config_path.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Save wallet config.
|
||||
fn save(&self) {
|
||||
let config_path = Self::get_config_path(&self.id);
|
||||
let config_path = Self::get_config_file_path(&self.chain_type, self.id);
|
||||
Settings::write_to_file(self, config_path);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2023 The Grim 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.
|
||||
|
||||
use grin_keychain::{ChildNumber, ExtKeychain, Identifier, Keychain};
|
||||
use grin_util::secp::key::SecretKey;
|
||||
use grin_wallet_libwallet::{AcctPathMapping, NodeClient, WalletBackend};
|
||||
use grin_wallet_libwallet::Error;
|
||||
|
||||
/// Get next available key in the wallet for a given parent
|
||||
pub fn next_available_key<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
) -> Result<Identifier, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let child = wallet.next_child(keychain_mask)?;
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// Retrieve an existing key from a wallet
|
||||
pub fn retrieve_existing_key<'a, T: ?Sized, C, K>(
|
||||
wallet: &T,
|
||||
key_id: Identifier,
|
||||
mmr_index: Option<u64>,
|
||||
) -> Result<(Identifier, u32), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let existing = wallet.get(&key_id, &mmr_index)?;
|
||||
let key_id = existing.key_id.clone();
|
||||
let derivation = existing.n_child;
|
||||
Ok((key_id, derivation))
|
||||
}
|
||||
|
||||
/// Returns a list of account to BIP32 path mappings
|
||||
pub fn accounts<'a, T: ?Sized, C, K>(wallet: &mut T) -> Result<Vec<AcctPathMapping>, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
Ok(wallet.acct_path_iter().collect())
|
||||
}
|
||||
|
||||
/// Adds an new parent account path with a given label
|
||||
pub fn new_acct_path<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
label: &str,
|
||||
) -> Result<Identifier, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let label = label.to_owned();
|
||||
if wallet.acct_path_iter().any(|l| l.label == label) {
|
||||
return Err(Error::AccountLabelAlreadyExists(label));
|
||||
}
|
||||
|
||||
// We're always using paths at m/k/0 for parent keys for output derivations
|
||||
// so find the highest of those, then increment (to conform with external/internal
|
||||
// derivation chains in BIP32 spec)
|
||||
|
||||
let highest_entry = wallet.acct_path_iter().max_by(|a, b| {
|
||||
<u32>::from(a.path.to_path().path[0]).cmp(&<u32>::from(b.path.to_path().path[0]))
|
||||
});
|
||||
|
||||
let return_id = {
|
||||
if let Some(e) = highest_entry {
|
||||
let mut p = e.path.to_path();
|
||||
p.path[0] = ChildNumber::from(<u32>::from(p.path[0]) + 1);
|
||||
p.to_identifier()
|
||||
} else {
|
||||
ExtKeychain::derive_key_id(2, 0, 0, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
let save_path = AcctPathMapping {
|
||||
label,
|
||||
path: return_id.clone(),
|
||||
};
|
||||
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
batch.save_acct_path(save_path)?;
|
||||
batch.commit()?;
|
||||
Ok(return_id)
|
||||
}
|
||||
|
||||
/// Adds/sets a particular account path with a given label
|
||||
pub fn set_acct_path<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
label: &str,
|
||||
path: &Identifier,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let label = label.to_owned();
|
||||
let save_path = AcctPathMapping {
|
||||
label,
|
||||
path: path.clone(),
|
||||
};
|
||||
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
batch.save_acct_path(save_path)?;
|
||||
batch.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
+30
-14
@@ -16,6 +16,7 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use grin_core::global::ChainTypes;
|
||||
use grin_wallet_libwallet::Error;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
@@ -24,43 +25,58 @@ use crate::wallet::Wallet;
|
||||
|
||||
lazy_static! {
|
||||
/// Global wallets state.
|
||||
static ref WALLETS_STATE: Arc<RwLock<WalletList >> = Arc::new(RwLock::new(WalletList::load()));
|
||||
static ref WALLETS_STATE: Arc<RwLock<WalletList>> = Arc::new(RwLock::new(WalletList::init()));
|
||||
}
|
||||
|
||||
/// List of created wallets.
|
||||
/// Wallets manager.
|
||||
pub struct WalletList {
|
||||
list: Vec<Wallet>
|
||||
pub(crate) list: Vec<Wallet>
|
||||
}
|
||||
|
||||
/// Base wallets directory name.
|
||||
pub const BASE_DIR_NAME: &'static str = "wallets";
|
||||
|
||||
impl WalletList {
|
||||
/// Load list of wallets.
|
||||
fn load() -> Self {
|
||||
/// Initialize manager by loading list of wallets into state.
|
||||
fn init() -> Self {
|
||||
Self { list: Self::load_wallets(&AppConfig::chain_type()) }
|
||||
}
|
||||
|
||||
/// Create new wallet and add it to state.
|
||||
pub fn create_wallet(
|
||||
name: String,
|
||||
password: String,
|
||||
mnemonic: String,
|
||||
external_node_url: Option<String>
|
||||
)-> Result<(), Error> {
|
||||
let wallet = Wallet::create(name, password, mnemonic, external_node_url)?;
|
||||
let mut w_state = WALLETS_STATE.write().unwrap();
|
||||
w_state.list.push(wallet);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load wallets for provided [`ChainType`].
|
||||
fn load_wallets(chain_type: &ChainTypes) -> Vec<Wallet> {
|
||||
let mut wallets = Vec::new();
|
||||
let wallets_dir = Self::get_wallets_base_dir(chain_type);
|
||||
// Load wallets from directory.
|
||||
let wallets_dir = Self::get_base_path(chain_type);
|
||||
// Load wallets from base directory.
|
||||
for dir in wallets_dir.read_dir().unwrap() {
|
||||
let wallet = Wallet::load(dir.unwrap().path());
|
||||
if let Some(w) = wallet {
|
||||
wallets.push(w);
|
||||
let wallet_dir = dir.unwrap().path();
|
||||
if wallet_dir.is_dir() {
|
||||
let wallet = Wallet::init(wallet_dir);
|
||||
if let Some(w) = wallet {
|
||||
wallets.push(w);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
wallets
|
||||
}
|
||||
|
||||
/// Get wallets base directory for provided [`ChainTypes`].
|
||||
pub fn get_wallets_base_dir(chain_type: &ChainTypes) -> PathBuf {
|
||||
/// Get wallets base directory path for provided [`ChainTypes`].
|
||||
pub fn get_base_path(chain_type: &ChainTypes) -> PathBuf {
|
||||
let mut wallets_path = Settings::get_base_path(Some(chain_type.shortname()));
|
||||
wallets_path.push(BASE_DIR_NAME);
|
||||
// Create wallets directory if it doesn't exist.
|
||||
// Create wallets base directory if it doesn't exist.
|
||||
if !wallets_path.exists() {
|
||||
let _ = fs::create_dir_all(wallets_path.clone());
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub mod updater;
|
||||
pub mod selection;
|
||||
pub mod tx;
|
||||
pub mod keys;
|
||||
|
||||
mod wallet;
|
||||
pub use wallet::Wallet;
|
||||
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
// Copyright 2023 The Grim 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryInto;
|
||||
|
||||
use grin_core::core::amount_to_hr_string;
|
||||
use grin_core::libtx::{
|
||||
build,
|
||||
proof::{ProofBuild, ProofBuilder},
|
||||
tx_fee,
|
||||
};
|
||||
use grin_keychain::{Identifier, Keychain};
|
||||
use grin_util::secp::key::SecretKey;
|
||||
use grin_util::secp::pedersen;
|
||||
use grin_wallet_libwallet::{address, Context, NodeClient, OutputData, OutputStatus, StoredProofInfo, TxLogEntry, TxLogEntryType, WalletBackend};
|
||||
use grin_wallet_libwallet::Error;
|
||||
use grin_wallet_libwallet::Slate;
|
||||
use grin_wallet_util::OnionV3Address;
|
||||
use log::debug;
|
||||
|
||||
use crate::wallet::keys::next_available_key;
|
||||
|
||||
/// Initialize a transaction on the sender side, returns a corresponding
|
||||
/// libwallet transaction slate with the appropriate inputs selected,
|
||||
/// and saves the private wallet identifiers of our selected outputs
|
||||
/// into our transaction context
|
||||
|
||||
pub fn build_send_tx<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain: &K,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &mut Slate,
|
||||
current_height: u64,
|
||||
minimum_confirmations: u64,
|
||||
max_outputs: usize,
|
||||
change_outputs: usize,
|
||||
selection_strategy_is_use_all: bool,
|
||||
fixed_fee: Option<u64>,
|
||||
parent_key_id: Identifier,
|
||||
use_test_nonce: bool,
|
||||
is_initiator: bool,
|
||||
amount_includes_fee: bool,
|
||||
) -> Result<Context, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let (elems, inputs, change_amounts_derivations, fee) = select_send_tx(
|
||||
wallet,
|
||||
keychain_mask,
|
||||
slate.amount,
|
||||
amount_includes_fee,
|
||||
current_height,
|
||||
minimum_confirmations,
|
||||
max_outputs,
|
||||
change_outputs,
|
||||
selection_strategy_is_use_all,
|
||||
&parent_key_id,
|
||||
false,
|
||||
)?;
|
||||
if amount_includes_fee {
|
||||
slate.amount = slate.amount.checked_sub(fee).ok_or(Error::GenericError(
|
||||
format!("Transaction amount is too small to include fee").into(),
|
||||
))?;
|
||||
};
|
||||
|
||||
if fixed_fee.map(|f| fee != f).unwrap_or(false) {
|
||||
return Err(Error::Fee(
|
||||
"The initially selected fee is not sufficient".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Update the fee on the slate so we account for this when building the tx.
|
||||
slate.fee_fields = fee.try_into().unwrap();
|
||||
slate.add_transaction_elements(keychain, &ProofBuilder::new(keychain), elems)?;
|
||||
|
||||
// Create our own private context
|
||||
let mut context = Context::new(
|
||||
keychain.secp(),
|
||||
&parent_key_id,
|
||||
use_test_nonce,
|
||||
is_initiator,
|
||||
);
|
||||
|
||||
context.fee = Some(slate.fee_fields);
|
||||
context.amount = slate.amount;
|
||||
|
||||
// Store our private identifiers for each input
|
||||
for input in inputs {
|
||||
context.add_input(&input.key_id, &input.mmr_index, input.value);
|
||||
}
|
||||
|
||||
let mut commits: HashMap<Identifier, Option<String>> = HashMap::new();
|
||||
|
||||
// Store change output(s) and cached commits
|
||||
for (change_amount, id, mmr_index) in &change_amounts_derivations {
|
||||
context.add_output(&id, &mmr_index, *change_amount);
|
||||
commits.insert(
|
||||
id.clone(),
|
||||
wallet.calc_commit_for_cache(keychain_mask, *change_amount, &id)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Locks all corresponding outputs in the context, creates
|
||||
/// change outputs and tx log entry
|
||||
pub fn lock_tx_context<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &Slate,
|
||||
current_height: u64,
|
||||
context: &Context,
|
||||
excess_override: Option<pedersen::Commitment>,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let mut output_commits: HashMap<Identifier, (Option<String>, u64)> = HashMap::new();
|
||||
// Store cached commits before locking wallet
|
||||
let mut total_change = 0;
|
||||
for (id, _, change_amount) in &context.get_outputs() {
|
||||
output_commits.insert(
|
||||
id.clone(),
|
||||
(
|
||||
wallet.calc_commit_for_cache(keychain_mask, *change_amount, &id)?,
|
||||
*change_amount,
|
||||
),
|
||||
);
|
||||
total_change += change_amount;
|
||||
}
|
||||
|
||||
debug!("Change amount is: {}", total_change);
|
||||
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
|
||||
let tx_entry = {
|
||||
let lock_inputs = context.get_inputs();
|
||||
let slate_id = slate.id;
|
||||
let height = current_height;
|
||||
let parent_key_id = context.parent_key_id.clone();
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
let log_id = batch.next_tx_log_id(&parent_key_id)?;
|
||||
let mut t = TxLogEntry::new(parent_key_id.clone(), TxLogEntryType::TxSent, log_id);
|
||||
t.tx_slate_id = Some(slate_id);
|
||||
let filename = format!("{}.grintx", slate_id);
|
||||
t.stored_tx = Some(filename);
|
||||
t.fee = context.fee;
|
||||
t.ttl_cutoff_height = match slate.ttl_cutoff_height {
|
||||
0 => None,
|
||||
n => Some(n),
|
||||
};
|
||||
|
||||
if let Ok(e) = slate.calc_excess(keychain.secp()) {
|
||||
t.kernel_excess = Some(e)
|
||||
}
|
||||
if let Some(e) = excess_override {
|
||||
t.kernel_excess = Some(e)
|
||||
}
|
||||
t.kernel_lookup_min_height = Some(current_height);
|
||||
|
||||
let mut amount_debited = 0;
|
||||
t.num_inputs = lock_inputs.len();
|
||||
for id in lock_inputs {
|
||||
let mut coin = batch.get(&id.0, &id.1).unwrap();
|
||||
coin.tx_log_entry = Some(log_id);
|
||||
amount_debited += coin.value;
|
||||
batch.lock_output(&mut coin)?;
|
||||
}
|
||||
|
||||
t.amount_debited = amount_debited;
|
||||
|
||||
// store extra payment proof info, if required
|
||||
if let Some(ref p) = slate.payment_proof {
|
||||
let sender_address_path = match context.payment_proof_derivation_index {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(Error::PaymentProof(
|
||||
"Payment proof derivation index required".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let sender_key = address::address_from_derivation_path(
|
||||
&keychain,
|
||||
&parent_key_id,
|
||||
sender_address_path,
|
||||
)?;
|
||||
let sender_address = OnionV3Address::from_private(&sender_key.0)?;
|
||||
t.payment_proof = Some(StoredProofInfo {
|
||||
receiver_address: p.receiver_address,
|
||||
receiver_signature: p.receiver_signature,
|
||||
sender_address: sender_address.to_ed25519()?,
|
||||
sender_address_path,
|
||||
sender_signature: None,
|
||||
});
|
||||
};
|
||||
|
||||
// write the output representing our change
|
||||
for (id, _, _) in &context.get_outputs() {
|
||||
t.num_outputs += 1;
|
||||
let (commit, change_amount) = output_commits.get(&id).unwrap().clone();
|
||||
t.amount_credited += change_amount;
|
||||
batch.save(OutputData {
|
||||
root_key_id: parent_key_id.clone(),
|
||||
key_id: id.clone(),
|
||||
n_child: id.to_path().last_path_index(),
|
||||
commit,
|
||||
mmr_index: None,
|
||||
value: change_amount,
|
||||
status: OutputStatus::Unconfirmed,
|
||||
height,
|
||||
lock_height: 0,
|
||||
is_coinbase: false,
|
||||
tx_log_entry: Some(log_id),
|
||||
})?;
|
||||
}
|
||||
batch.save_tx_log_entry(t.clone(), &parent_key_id)?;
|
||||
batch.commit()?;
|
||||
t
|
||||
};
|
||||
wallet.store_tx(
|
||||
&format!("{}", tx_entry.tx_slate_id.unwrap()),
|
||||
slate.tx_or_err()?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a new output in the wallet for the recipient,
|
||||
/// returning the key of the fresh output
|
||||
/// Also creates a new transaction containing the output
|
||||
pub fn build_recipient_output<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &mut Slate,
|
||||
current_height: u64,
|
||||
parent_key_id: Identifier,
|
||||
use_test_rng: bool,
|
||||
is_initiator: bool,
|
||||
) -> Result<(Identifier, Context, TxLogEntry), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// Create a potential output for this transaction
|
||||
let key_id = next_available_key(wallet, keychain_mask).unwrap();
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
let key_id_inner = key_id.clone();
|
||||
let amount = slate.amount;
|
||||
let height = current_height;
|
||||
|
||||
let slate_id = slate.id;
|
||||
slate.add_transaction_elements(
|
||||
&keychain,
|
||||
&ProofBuilder::new(&keychain),
|
||||
vec![build::output(amount, key_id.clone())],
|
||||
)?;
|
||||
|
||||
// Add blinding sum to our context
|
||||
let mut context = Context::new(keychain.secp(), &parent_key_id, use_test_rng, is_initiator);
|
||||
|
||||
context.add_output(&key_id, &None, amount);
|
||||
context.amount = amount;
|
||||
context.fee = slate.fee_fields.as_opt();
|
||||
let commit = wallet.calc_commit_for_cache(keychain_mask, amount, &key_id_inner)?;
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
let log_id = batch.next_tx_log_id(&parent_key_id)?;
|
||||
let mut t = TxLogEntry::new(parent_key_id.clone(), TxLogEntryType::TxReceived, log_id);
|
||||
t.tx_slate_id = Some(slate_id);
|
||||
t.amount_credited = amount;
|
||||
t.num_outputs = 1;
|
||||
t.ttl_cutoff_height = match slate.ttl_cutoff_height {
|
||||
0 => None,
|
||||
n => Some(n),
|
||||
};
|
||||
// when invoicing, this will be invalid
|
||||
if let Ok(e) = slate.calc_excess(keychain.secp()) {
|
||||
t.kernel_excess = Some(e)
|
||||
}
|
||||
t.kernel_lookup_min_height = Some(current_height);
|
||||
batch.save(OutputData {
|
||||
root_key_id: parent_key_id.clone(),
|
||||
key_id: key_id_inner.clone(),
|
||||
mmr_index: None,
|
||||
n_child: key_id_inner.to_path().last_path_index(),
|
||||
commit,
|
||||
value: amount,
|
||||
status: OutputStatus::Unconfirmed,
|
||||
height,
|
||||
lock_height: 0,
|
||||
is_coinbase: false,
|
||||
tx_log_entry: Some(log_id),
|
||||
})?;
|
||||
batch.save_tx_log_entry(t.clone(), &parent_key_id)?;
|
||||
batch.commit()?;
|
||||
|
||||
Ok((key_id, context, t))
|
||||
}
|
||||
|
||||
/// Builds a transaction to send to someone from the HD seed associated with the
|
||||
/// wallet and the amount to send. Handles reading through the wallet data file,
|
||||
/// selecting outputs to spend and building the change.
|
||||
pub fn select_send_tx<'a, T: ?Sized, C, K, B>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
amount: u64,
|
||||
amount_includes_fee: bool,
|
||||
current_height: u64,
|
||||
minimum_confirmations: u64,
|
||||
max_outputs: usize,
|
||||
change_outputs: usize,
|
||||
selection_strategy_is_use_all: bool,
|
||||
parent_key_id: &Identifier,
|
||||
include_inputs_in_sum: bool,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<Box<build::Append<K, B>>>,
|
||||
Vec<OutputData>,
|
||||
Vec<(u64, Identifier, Option<u64>)>, // change amounts and derivations
|
||||
u64, // fee
|
||||
),
|
||||
Error,
|
||||
>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
B: ProofBuild,
|
||||
{
|
||||
let (coins, _total, amount, fee) = select_coins_and_fee(
|
||||
wallet,
|
||||
amount,
|
||||
amount_includes_fee,
|
||||
current_height,
|
||||
minimum_confirmations,
|
||||
max_outputs,
|
||||
change_outputs,
|
||||
selection_strategy_is_use_all,
|
||||
&parent_key_id,
|
||||
)?;
|
||||
|
||||
// build transaction skeleton with inputs and change
|
||||
let (parts, change_amounts_derivations) = inputs_and_change(
|
||||
&coins,
|
||||
wallet,
|
||||
keychain_mask,
|
||||
amount,
|
||||
fee,
|
||||
change_outputs,
|
||||
include_inputs_in_sum,
|
||||
)?;
|
||||
|
||||
Ok((parts, coins, change_amounts_derivations, fee))
|
||||
}
|
||||
|
||||
/// Select outputs and calculating fee.
|
||||
pub fn select_coins_and_fee<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
amount: u64,
|
||||
amount_includes_fee: bool,
|
||||
current_height: u64,
|
||||
minimum_confirmations: u64,
|
||||
max_outputs: usize,
|
||||
change_outputs: usize,
|
||||
selection_strategy_is_use_all: bool,
|
||||
parent_key_id: &Identifier,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<OutputData>,
|
||||
u64, // total
|
||||
u64, // amount
|
||||
u64, // fee
|
||||
),
|
||||
Error,
|
||||
>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// select some spendable coins from the wallet
|
||||
let (max_outputs, mut coins) = select_coins(
|
||||
wallet,
|
||||
amount,
|
||||
current_height,
|
||||
minimum_confirmations,
|
||||
max_outputs,
|
||||
selection_strategy_is_use_all,
|
||||
parent_key_id,
|
||||
);
|
||||
|
||||
// sender is responsible for setting the fee on the partial tx
|
||||
// recipient should double check the fee calculation and not blindly trust the
|
||||
// sender
|
||||
|
||||
// First attempt to spend without change
|
||||
let mut fee = tx_fee(coins.len(), 1, 1);
|
||||
let mut total: u64 = coins.iter().map(|c| c.value).sum();
|
||||
let mut amount_with_fee = match amount_includes_fee {
|
||||
true => amount,
|
||||
false => amount + fee,
|
||||
};
|
||||
|
||||
if total == 0 {
|
||||
return Err(Error::NotEnoughFunds {
|
||||
available: 0,
|
||||
available_disp: amount_to_hr_string(0, false),
|
||||
needed: amount_with_fee,
|
||||
needed_disp: amount_to_hr_string(amount_with_fee, false),
|
||||
});
|
||||
}
|
||||
|
||||
// The amount with fee is more than the total values of our max outputs
|
||||
if total < amount_with_fee && coins.len() == max_outputs {
|
||||
return Err(Error::NotEnoughFunds {
|
||||
available: total,
|
||||
available_disp: amount_to_hr_string(total, false),
|
||||
needed: amount_with_fee,
|
||||
needed_disp: amount_to_hr_string(amount_with_fee, false),
|
||||
});
|
||||
}
|
||||
|
||||
let num_outputs = change_outputs + 1;
|
||||
|
||||
// We need to add a change address or amount with fee is more than total
|
||||
if total != amount_with_fee {
|
||||
fee = tx_fee(coins.len(), num_outputs, 1);
|
||||
amount_with_fee = match amount_includes_fee {
|
||||
true => amount,
|
||||
false => amount + fee,
|
||||
};
|
||||
|
||||
// Here check if we have enough outputs for the amount including fee otherwise
|
||||
// look for other outputs and check again
|
||||
while total < amount_with_fee {
|
||||
// End the loop if we have selected all the outputs and still not enough funds
|
||||
if coins.len() == max_outputs {
|
||||
return Err(Error::NotEnoughFunds {
|
||||
available: total,
|
||||
available_disp: amount_to_hr_string(total, false),
|
||||
needed: amount_with_fee,
|
||||
needed_disp: amount_to_hr_string(amount_with_fee, false),
|
||||
});
|
||||
}
|
||||
|
||||
// select some spendable coins from the wallet
|
||||
coins = select_coins(
|
||||
wallet,
|
||||
amount_with_fee,
|
||||
current_height,
|
||||
minimum_confirmations,
|
||||
max_outputs,
|
||||
selection_strategy_is_use_all,
|
||||
parent_key_id,
|
||||
)
|
||||
.1;
|
||||
fee = tx_fee(coins.len(), num_outputs, 1);
|
||||
total = coins.iter().map(|c| c.value).sum();
|
||||
amount_with_fee = match amount_includes_fee {
|
||||
true => amount,
|
||||
false => amount + fee,
|
||||
};
|
||||
}
|
||||
}
|
||||
// If original amount includes fee, the new amount should
|
||||
// be reduced, to accommodate the fee.
|
||||
let new_amount = match amount_includes_fee {
|
||||
true => amount.checked_sub(fee).ok_or(Error::GenericError(
|
||||
format!("Transaction amount is too small to include fee").into(),
|
||||
))?,
|
||||
false => amount,
|
||||
};
|
||||
Ok((coins, total, new_amount, fee))
|
||||
}
|
||||
|
||||
/// Selects inputs and change for a transaction
|
||||
pub fn inputs_and_change<'a, T: ?Sized, C, K, B>(
|
||||
coins: &[OutputData],
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
amount: u64,
|
||||
fee: u64,
|
||||
num_change_outputs: usize,
|
||||
include_inputs_in_sum: bool,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<Box<build::Append<K, B>>>,
|
||||
Vec<(u64, Identifier, Option<u64>)>,
|
||||
),
|
||||
Error,
|
||||
>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
B: ProofBuild,
|
||||
{
|
||||
let mut parts = vec![];
|
||||
|
||||
// calculate the total across all inputs, and how much is left
|
||||
let total: u64 = coins.iter().map(|c| c.value).sum();
|
||||
|
||||
// if we are spending 10,000 coins to send 1,000 then our change will be 9,000
|
||||
// if the fee is 80 then the recipient will receive 1000 and our change will be
|
||||
// 8,920
|
||||
let change = total - amount - fee;
|
||||
|
||||
// build inputs using the appropriate derived key_ids
|
||||
if include_inputs_in_sum {
|
||||
for coin in coins {
|
||||
if coin.is_coinbase {
|
||||
parts.push(build::coinbase_input(coin.value, coin.key_id.clone()));
|
||||
} else {
|
||||
parts.push(build::input(coin.value, coin.key_id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut change_amounts_derivations = vec![];
|
||||
|
||||
if change == 0 {
|
||||
debug!("No change (sending exactly amount + fee), no change outputs to build");
|
||||
} else {
|
||||
debug!(
|
||||
"Building change outputs: total change: {} ({} outputs)",
|
||||
change, num_change_outputs
|
||||
);
|
||||
|
||||
let part_change = change / num_change_outputs as u64;
|
||||
let remainder_change = change % part_change;
|
||||
|
||||
for x in 0..num_change_outputs {
|
||||
// n-1 equal change_outputs and a final one accounting for any remainder
|
||||
let change_amount = if x == (num_change_outputs - 1) {
|
||||
part_change + remainder_change
|
||||
} else {
|
||||
part_change
|
||||
};
|
||||
|
||||
let change_key = wallet.next_child(keychain_mask).unwrap();
|
||||
|
||||
change_amounts_derivations.push((change_amount, change_key.clone(), None));
|
||||
parts.push(build::output(change_amount, change_key));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((parts, change_amounts_derivations))
|
||||
}
|
||||
|
||||
/// Select spendable coins from a wallet.
|
||||
/// Default strategy is to spend the maximum number of outputs (up to
|
||||
/// max_outputs). Alternative strategy is to spend smallest outputs first
|
||||
/// but only as many as necessary. When we introduce additional strategies
|
||||
/// we should pass something other than a bool in.
|
||||
pub fn select_coins<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
amount: u64,
|
||||
current_height: u64,
|
||||
minimum_confirmations: u64,
|
||||
max_outputs: usize,
|
||||
select_all: bool,
|
||||
parent_key_id: &Identifier,
|
||||
) -> (usize, Vec<OutputData>)
|
||||
// max_outputs_available, Outputs
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// first find all eligible outputs based on number of confirmations
|
||||
let mut eligible = wallet
|
||||
.iter()
|
||||
.filter(|out| {
|
||||
out.root_key_id == *parent_key_id
|
||||
&& out.eligible_to_spend(current_height, minimum_confirmations)
|
||||
})
|
||||
.collect::<Vec<OutputData>>();
|
||||
|
||||
let max_available = eligible.len();
|
||||
|
||||
// sort eligible outputs by increasing value
|
||||
eligible.sort_by_key(|out| out.value);
|
||||
|
||||
// use a sliding window to identify potential sets of possible outputs to spend
|
||||
// Case of amount > total amount of max_outputs(500):
|
||||
// The limit exists because by default, we always select as many inputs as
|
||||
// possible in a transaction, to reduce both the Output set and the fees.
|
||||
// But that only makes sense up to a point, hence the limit to avoid being too
|
||||
// greedy. But if max_outputs(500) is actually not enough to cover the whole
|
||||
// amount, the wallet should allow going over it to satisfy what the user
|
||||
// wants to send. So the wallet considers max_outputs more of a soft limit.
|
||||
if eligible.len() > max_outputs {
|
||||
for window in eligible.windows(max_outputs) {
|
||||
let windowed_eligibles = window.to_vec();
|
||||
if let Some(outputs) = select_from(amount, select_all, windowed_eligibles) {
|
||||
return (max_available, outputs);
|
||||
}
|
||||
}
|
||||
// Not exist in any window of which total amount >= amount.
|
||||
// Then take coins from the smallest one up to the total amount of selected
|
||||
// coins = the amount.
|
||||
if let Some(outputs) = select_from(amount, false, eligible.clone()) {
|
||||
debug!(
|
||||
"Extending maximum number of outputs. {} outputs selected.",
|
||||
outputs.len()
|
||||
);
|
||||
return (max_available, outputs);
|
||||
}
|
||||
} else if let Some(outputs) = select_from(amount, select_all, eligible.clone()) {
|
||||
return (max_available, outputs);
|
||||
}
|
||||
|
||||
// we failed to find a suitable set of outputs to spend,
|
||||
// so return the largest amount we can so we can provide guidance on what is
|
||||
// possible
|
||||
eligible.reverse();
|
||||
(
|
||||
max_available,
|
||||
eligible.iter().take(max_outputs).cloned().collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn select_from(amount: u64, select_all: bool, outputs: Vec<OutputData>) -> Option<Vec<OutputData>> {
|
||||
let total = outputs.iter().fold(0, |acc, x| acc + x.value);
|
||||
if total >= amount {
|
||||
if select_all {
|
||||
Some(outputs.to_vec())
|
||||
} else {
|
||||
let mut selected_amount = 0;
|
||||
Some(
|
||||
outputs
|
||||
.iter()
|
||||
.take_while(|out| {
|
||||
let res = selected_amount < amount;
|
||||
selected_amount += out.value;
|
||||
res
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Repopulates output in the slate's tranacstion
|
||||
/// with outputs from the stored context
|
||||
/// change outputs and tx log entry
|
||||
/// Remove the explicitly stored excess
|
||||
pub fn repopulate_tx<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &mut Slate,
|
||||
context: &Context,
|
||||
update_fee: bool,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// restore the original amount, fee
|
||||
slate.amount = context.amount;
|
||||
if update_fee {
|
||||
slate.fee_fields = context
|
||||
.fee
|
||||
.ok_or_else(|| Error::Fee("Missing fee fields".into()))?;
|
||||
}
|
||||
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
|
||||
// restore my signature data
|
||||
slate.add_participant_info(&keychain, &context, None)?;
|
||||
|
||||
let mut parts = vec![];
|
||||
for (id, _, value) in &context.get_inputs() {
|
||||
let input = wallet.iter().find(|out| out.key_id == *id);
|
||||
if let Some(i) = input {
|
||||
if i.is_coinbase {
|
||||
parts.push(build::coinbase_input(*value, i.key_id.clone()));
|
||||
} else {
|
||||
parts.push(build::input(*value, i.key_id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, _, value) in &context.get_outputs() {
|
||||
let output = wallet.iter().find(|out| out.key_id == *id);
|
||||
if let Some(i) = output {
|
||||
parts.push(build::output(*value, i.key_id.clone()));
|
||||
}
|
||||
}
|
||||
let _ = slate.add_transaction_elements(&keychain, &ProofBuilder::new(&keychain), parts)?;
|
||||
// restore the original offset
|
||||
slate.tx_or_err_mut()?.offset = slate.offset.clone();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
// Copyright 2023 The Grim 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.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||
use ed25519_dalek::{Signer, Verifier};
|
||||
use ed25519_dalek::Keypair as DalekKeypair;
|
||||
use ed25519_dalek::PublicKey as DalekPublicKey;
|
||||
use ed25519_dalek::SecretKey as DalekSecretKey;
|
||||
use ed25519_dalek::Signature as DalekSignature;
|
||||
use grin_core::consensus::valid_header_version;
|
||||
use grin_core::core::FeeFields;
|
||||
use grin_core::core::HeaderVersion;
|
||||
use grin_keychain::{Identifier, Keychain};
|
||||
use grin_util::Mutex;
|
||||
use grin_util::secp::key::SecretKey;
|
||||
use grin_util::secp::pedersen;
|
||||
use grin_wallet_libwallet::{Context, NodeClient, StoredProofInfo, TxLogEntryType, WalletBackend};
|
||||
use grin_wallet_libwallet::{address, Error};
|
||||
use grin_wallet_libwallet::InitTxArgs;
|
||||
use grin_wallet_libwallet::Slate;
|
||||
use grin_wallet_util::OnionV3Address;
|
||||
use lazy_static::lazy_static;
|
||||
use log::trace;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::wallet::selection::{build_recipient_output, build_send_tx, select_coins_and_fee};
|
||||
use crate::wallet::updater::{cancel_tx_and_outputs, refresh_outputs, retrieve_outputs, retrieve_txs};
|
||||
|
||||
/// Static value to increment UUIDs of slates.
|
||||
lazy_static! {
|
||||
static ref SLATE_COUNTER: Mutex<u8> = Mutex::new(0);
|
||||
}
|
||||
|
||||
/// Creates a new slate for a transaction, can be called by anyone involved in
|
||||
/// the transaction (sender(s), receiver(s)).
|
||||
pub fn new_tx_slate<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
amount: u64,
|
||||
is_invoice: bool,
|
||||
num_participants: u8,
|
||||
use_test_rng: bool,
|
||||
ttl_blocks: Option<u64>,
|
||||
) -> Result<Slate, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let current_height = wallet.w2n_client().get_chain_tip()?.0;
|
||||
let mut slate = Slate::blank(num_participants, is_invoice);
|
||||
if let Some(b) = ttl_blocks {
|
||||
slate.ttl_cutoff_height = current_height + b;
|
||||
}
|
||||
if use_test_rng {
|
||||
{
|
||||
let sc = SLATE_COUNTER.lock();
|
||||
let bytes = [4, 54, 67, 12, 43, 2, 98, 76, 32, 50, 87, 5, 1, 33, 43, *sc];
|
||||
slate.id = Uuid::from_slice(&bytes).unwrap();
|
||||
}
|
||||
*SLATE_COUNTER.lock() += 1;
|
||||
}
|
||||
slate.amount = amount;
|
||||
|
||||
if valid_header_version(current_height, HeaderVersion(1)) {
|
||||
slate.version_info.block_header_version = 1;
|
||||
}
|
||||
|
||||
if valid_header_version(current_height, HeaderVersion(2)) {
|
||||
slate.version_info.block_header_version = 2;
|
||||
}
|
||||
|
||||
if valid_header_version(current_height, HeaderVersion(3)) {
|
||||
slate.version_info.block_header_version = 3;
|
||||
}
|
||||
|
||||
// Set the features explicitly to 0 here.
|
||||
// This will generate a Plain kernel (rather than a HeightLocked kernel).
|
||||
slate.kernel_features = 0;
|
||||
|
||||
Ok(slate)
|
||||
}
|
||||
|
||||
/// Add inputs to the slate (effectively becoming the sender).
|
||||
pub fn add_inputs_to_slate<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &mut Slate,
|
||||
current_height: u64,
|
||||
minimum_confirmations: u64,
|
||||
max_outputs: usize,
|
||||
num_change_outputs: usize,
|
||||
selection_strategy_is_use_all: bool,
|
||||
parent_key_id: &Identifier,
|
||||
is_initiator: bool,
|
||||
use_test_rng: bool,
|
||||
amount_includes_fee: bool,
|
||||
) -> Result<Context, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// sender should always refresh outputs
|
||||
refresh_outputs(wallet, keychain_mask, parent_key_id, false)?;
|
||||
|
||||
// Sender selects outputs into a new slate and save our corresponding keys in
|
||||
// a transaction context. The secret key in our transaction context will be
|
||||
// randomly selected. This returns the public slate, and a closure that locks
|
||||
// our inputs and outputs once we're convinced the transaction exchange went
|
||||
// according to plan
|
||||
// This function is just a big helper to do all of that, in theory
|
||||
// this process can be split up in any way
|
||||
let mut context = build_send_tx(
|
||||
wallet,
|
||||
&wallet.keychain(keychain_mask)?,
|
||||
keychain_mask,
|
||||
slate,
|
||||
current_height,
|
||||
minimum_confirmations,
|
||||
max_outputs,
|
||||
num_change_outputs,
|
||||
selection_strategy_is_use_all,
|
||||
None,
|
||||
parent_key_id.clone(),
|
||||
use_test_rng,
|
||||
is_initiator,
|
||||
amount_includes_fee,
|
||||
)?;
|
||||
|
||||
// Generate a kernel offset and subtract from our context's secret key. Store
|
||||
// the offset in the slate's transaction kernel, and adds our public key
|
||||
// information to the slate
|
||||
slate.fill_round_1(&wallet.keychain(keychain_mask)?, &mut context)?;
|
||||
|
||||
context.initial_sec_key = context.sec_key.clone();
|
||||
|
||||
if !is_initiator {
|
||||
// perform partial sig
|
||||
slate.fill_round_2(
|
||||
&wallet.keychain(keychain_mask)?,
|
||||
&context.sec_key,
|
||||
&context.sec_nonce,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Add receiver output to the slate.
|
||||
pub fn add_output_to_slate<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &mut Slate,
|
||||
current_height: u64,
|
||||
parent_key_id: &Identifier,
|
||||
is_initiator: bool,
|
||||
use_test_rng: bool,
|
||||
) -> Result<Context, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
// create an output using the amount in the slate
|
||||
let (_, mut context, mut tx) = build_recipient_output(
|
||||
wallet,
|
||||
keychain_mask,
|
||||
slate,
|
||||
current_height,
|
||||
parent_key_id.clone(),
|
||||
use_test_rng,
|
||||
is_initiator,
|
||||
)?;
|
||||
|
||||
// fill public keys
|
||||
slate.fill_round_1(&keychain, &mut context)?;
|
||||
|
||||
context.initial_sec_key = context.sec_key.clone();
|
||||
|
||||
if !is_initiator {
|
||||
// perform partial sig
|
||||
slate.fill_round_2(&keychain, &context.sec_key, &context.sec_nonce)?;
|
||||
// update excess in stored transaction
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
tx.kernel_excess = Some(slate.calc_excess(keychain.secp())?);
|
||||
batch.save_tx_log_entry(tx.clone(), &parent_key_id)?;
|
||||
batch.commit()?;
|
||||
}
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Create context, without adding inputs to slate.
|
||||
pub fn create_late_lock_context<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &mut Slate,
|
||||
current_height: u64,
|
||||
init_tx_args: &InitTxArgs,
|
||||
parent_key_id: &Identifier,
|
||||
use_test_rng: bool,
|
||||
) -> Result<Context, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// sender should always refresh outputs
|
||||
refresh_outputs(wallet, keychain_mask, parent_key_id, false)?;
|
||||
|
||||
// we're just going to run a selection to get the potential fee,
|
||||
// but this won't be locked
|
||||
let (_coins, _total, _amount, fee) = select_coins_and_fee(
|
||||
wallet,
|
||||
init_tx_args.amount,
|
||||
init_tx_args.amount_includes_fee.unwrap_or(false),
|
||||
current_height,
|
||||
init_tx_args.minimum_confirmations,
|
||||
init_tx_args.max_outputs as usize,
|
||||
init_tx_args.num_change_outputs as usize,
|
||||
init_tx_args.selection_strategy_is_use_all,
|
||||
&parent_key_id,
|
||||
)?;
|
||||
slate.fee_fields = FeeFields::new(0, fee)?;
|
||||
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
|
||||
// Create our own private context
|
||||
let mut context = Context::new(keychain.secp(), &parent_key_id, use_test_rng, true);
|
||||
context.fee = Some(slate.fee_fields);
|
||||
context.amount = slate.amount;
|
||||
context.late_lock_args = Some(init_tx_args.clone());
|
||||
|
||||
// Generate a blinding factor for the tx and add
|
||||
// public key info to the slate
|
||||
slate.fill_round_1(&wallet.keychain(keychain_mask)?, &mut context)?;
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Complete a transaction.
|
||||
pub fn complete_tx<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
slate: &mut Slate,
|
||||
context: &Context,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// when self sending invoice tx, use initiator nonce to finalize
|
||||
let (sec_key, sec_nonce) = {
|
||||
if context.initial_sec_key != context.sec_key
|
||||
&& context.initial_sec_nonce != context.sec_nonce
|
||||
{
|
||||
(
|
||||
context.initial_sec_key.clone(),
|
||||
context.initial_sec_nonce.clone(),
|
||||
)
|
||||
} else {
|
||||
(context.sec_key.clone(), context.sec_nonce.clone())
|
||||
}
|
||||
};
|
||||
slate.fill_round_2(&wallet.keychain(keychain_mask)?, &sec_key, &sec_nonce)?;
|
||||
|
||||
// Final transaction can be built by anyone at this stage
|
||||
trace!("Slate to finalize is: {}", slate);
|
||||
slate.finalize(&wallet.keychain(keychain_mask)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rollback outputs associated with a transaction in the wallet.
|
||||
pub fn cancel_tx<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
parent_key_id: &Identifier,
|
||||
tx_id: Option<u32>,
|
||||
tx_slate_id: Option<Uuid>,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let mut tx_id_string = String::new();
|
||||
if let Some(tx_id) = tx_id {
|
||||
tx_id_string = tx_id.to_string();
|
||||
} else if let Some(tx_slate_id) = tx_slate_id {
|
||||
tx_id_string = tx_slate_id.to_string();
|
||||
}
|
||||
let tx_vec = retrieve_txs(
|
||||
wallet,
|
||||
tx_id,
|
||||
tx_slate_id,
|
||||
None,
|
||||
Some(&parent_key_id),
|
||||
false,
|
||||
)?;
|
||||
if tx_vec.len() != 1 {
|
||||
return Err(Error::TransactionDoesntExist(tx_id_string));
|
||||
}
|
||||
let tx = tx_vec[0].clone();
|
||||
match tx.tx_type {
|
||||
TxLogEntryType::TxSent | TxLogEntryType::TxReceived | TxLogEntryType::TxReverted => {}
|
||||
_ => return Err(Error::TransactionNotCancellable(tx_id_string)),
|
||||
}
|
||||
if tx.confirmed {
|
||||
return Err(Error::TransactionNotCancellable(tx_id_string));
|
||||
}
|
||||
// get outputs associated with tx
|
||||
let res = retrieve_outputs(
|
||||
wallet,
|
||||
keychain_mask,
|
||||
false,
|
||||
Some(tx.id),
|
||||
Some(&parent_key_id),
|
||||
)?;
|
||||
let outputs = res.iter().map(|m| m.output.clone()).collect();
|
||||
cancel_tx_and_outputs(wallet, keychain_mask, tx, outputs, parent_key_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the stored transaction (this update needs to happen when the TX is finalised).
|
||||
pub fn update_stored_tx<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
context: &Context,
|
||||
slate: &Slate,
|
||||
is_invoiced: bool,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// finalize command
|
||||
let tx_vec = retrieve_txs(wallet, None, Some(slate.id), None, None, false)?;
|
||||
let mut tx = None;
|
||||
// don't want to assume this is the right tx, in case of self-sending
|
||||
for t in tx_vec {
|
||||
if t.tx_type == TxLogEntryType::TxSent && !is_invoiced {
|
||||
tx = Some(t);
|
||||
break;
|
||||
}
|
||||
if t.tx_type == TxLogEntryType::TxReceived && is_invoiced {
|
||||
tx = Some(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut tx = match tx {
|
||||
Some(t) => t,
|
||||
None => return Err(Error::TransactionDoesntExist(slate.id.to_string())),
|
||||
};
|
||||
let parent_key = tx.parent_key_id.clone();
|
||||
{
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
tx.kernel_excess = Some(slate.calc_excess(keychain.secp())?);
|
||||
}
|
||||
|
||||
if let Some(ref p) = slate.clone().payment_proof {
|
||||
let derivation_index = match context.payment_proof_derivation_index {
|
||||
Some(i) => i,
|
||||
None => 0,
|
||||
};
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
let parent_key_id = wallet.parent_key_id();
|
||||
let excess = slate.calc_excess(keychain.secp())?;
|
||||
let sender_key =
|
||||
address::address_from_derivation_path(&keychain, &parent_key_id, derivation_index)?;
|
||||
let sender_address = OnionV3Address::from_private(&sender_key.0)?;
|
||||
let sig =
|
||||
create_payment_proof_signature(slate.amount, &excess, p.sender_address, sender_key)?;
|
||||
tx.payment_proof = Some(StoredProofInfo {
|
||||
receiver_address: p.receiver_address,
|
||||
receiver_signature: p.receiver_signature,
|
||||
sender_address_path: derivation_index,
|
||||
sender_address: sender_address.to_ed25519()?,
|
||||
sender_signature: Some(sig),
|
||||
})
|
||||
}
|
||||
|
||||
wallet.store_tx(&format!("{}", tx.tx_slate_id.unwrap()), slate.tx_or_err()?)?;
|
||||
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
batch.save_tx_log_entry(tx, &parent_key)?;
|
||||
batch.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn payment_proof_message(
|
||||
amount: u64,
|
||||
kernel_commitment: &pedersen::Commitment,
|
||||
sender_address: DalekPublicKey,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let mut msg = Vec::new();
|
||||
msg.write_u64::<BigEndian>(amount)?;
|
||||
msg.append(&mut kernel_commitment.0.to_vec());
|
||||
msg.append(&mut sender_address.to_bytes().to_vec());
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub fn _decode_payment_proof_message(
|
||||
msg: &[u8],
|
||||
) -> Result<(u64, pedersen::Commitment, DalekPublicKey), Error> {
|
||||
let mut rdr = Cursor::new(msg);
|
||||
let amount = rdr.read_u64::<BigEndian>()?;
|
||||
let mut commit_bytes = [0u8; 33];
|
||||
for i in 0..33 {
|
||||
commit_bytes[i] = rdr.read_u8()?;
|
||||
}
|
||||
let mut sender_address_bytes = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
sender_address_bytes[i] = rdr.read_u8()?;
|
||||
}
|
||||
|
||||
Ok((
|
||||
amount,
|
||||
pedersen::Commitment::from_vec(commit_bytes.to_vec()),
|
||||
DalekPublicKey::from_bytes(&sender_address_bytes).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a payment proof.
|
||||
pub fn create_payment_proof_signature(
|
||||
amount: u64,
|
||||
kernel_commitment: &pedersen::Commitment,
|
||||
sender_address: DalekPublicKey,
|
||||
sec_key: SecretKey,
|
||||
) -> Result<DalekSignature, Error> {
|
||||
let msg = payment_proof_message(amount, kernel_commitment, sender_address)?;
|
||||
let d_skey = match DalekSecretKey::from_bytes(&sec_key.0) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
return Err(Error::ED25519Key(format!("{}", e)));
|
||||
}
|
||||
};
|
||||
let pub_key: DalekPublicKey = (&d_skey).into();
|
||||
let keypair = DalekKeypair {
|
||||
public: pub_key,
|
||||
secret: d_skey,
|
||||
};
|
||||
Ok(keypair.sign(&msg))
|
||||
}
|
||||
|
||||
/// Verify all aspects of a completed payment proof on the current slate.
|
||||
pub fn verify_slate_payment_proof<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
parent_key_id: &Identifier,
|
||||
context: &Context,
|
||||
slate: &Slate,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let tx_vec = retrieve_txs(
|
||||
wallet,
|
||||
None,
|
||||
Some(slate.id),
|
||||
None,
|
||||
Some(parent_key_id),
|
||||
false,
|
||||
)?;
|
||||
if tx_vec.is_empty() {
|
||||
return Err(Error::PaymentProof(
|
||||
"TxLogEntry with original proof info not found (is account correct?)".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let orig_proof_info = tx_vec[0].clone().payment_proof;
|
||||
|
||||
if orig_proof_info.is_some() && slate.payment_proof.is_none() {
|
||||
return Err(Error::PaymentProof(
|
||||
"Expected Payment Proof for this Transaction is not present".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref p) = slate.clone().payment_proof {
|
||||
let orig_proof_info = match orig_proof_info {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
return Err(Error::PaymentProof(
|
||||
"Original proof info not stored in tx".to_owned(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
let index = match context.payment_proof_derivation_index {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return Err(Error::PaymentProof(
|
||||
"Payment proof derivation index required".to_owned(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let orig_sender_sk =
|
||||
address::address_from_derivation_path(&keychain, parent_key_id, index)?;
|
||||
let orig_sender_address = OnionV3Address::from_private(&orig_sender_sk.0)?;
|
||||
if p.sender_address != orig_sender_address.to_ed25519()? {
|
||||
return Err(Error::PaymentProof(
|
||||
"Sender address on slate does not match original sender address".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
if orig_proof_info.receiver_address != p.receiver_address {
|
||||
return Err(Error::PaymentProof(
|
||||
"Recipient address on slate does not match original recipient address".to_owned(),
|
||||
));
|
||||
}
|
||||
let msg = payment_proof_message(
|
||||
slate.amount,
|
||||
&slate.calc_excess(&keychain.secp())?,
|
||||
orig_sender_address.to_ed25519()?,
|
||||
)?;
|
||||
let sig = match p.receiver_signature {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(Error::PaymentProof(
|
||||
"Recipient did not provide requested proof signature".to_owned(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if p.receiver_address.verify(&msg, &sig).is_err() {
|
||||
return Err(Error::PaymentProof("Invalid proof signature".to_owned()));
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
// Copyright 2023 The Grim 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.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use grin_keychain::{Identifier, Keychain, SwitchCommitmentType};
|
||||
use grin_util as util;
|
||||
use grin_util::secp::key::SecretKey;
|
||||
use grin_util::secp::pedersen;
|
||||
use grin_util::static_secp_instance;
|
||||
use grin_wallet_libwallet::{
|
||||
NodeClient, OutputData, OutputStatus, TxLogEntry, TxLogEntryType, WalletBackend, WalletInfo,
|
||||
};
|
||||
use grin_wallet_libwallet::{
|
||||
OutputCommitMapping, RetrieveTxQueryArgs, RetrieveTxQuerySortField,
|
||||
RetrieveTxQuerySortOrder,
|
||||
};
|
||||
use grin_wallet_libwallet::Error;
|
||||
use log::{debug, warn};
|
||||
use num_bigint::BigInt;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Retrieve all of the outputs (doesn't attempt to update from node)
|
||||
pub fn retrieve_outputs<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
show_spent: bool,
|
||||
tx_id: Option<u32>,
|
||||
parent_key_id: Option<&Identifier>,
|
||||
) -> Result<Vec<OutputCommitMapping>, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// just read the wallet here, no need for a write lock
|
||||
let mut outputs = wallet
|
||||
.iter()
|
||||
.filter(|out| show_spent || out.status != OutputStatus::Spent)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// only include outputs with a given tx_id if provided
|
||||
if let Some(id) = tx_id {
|
||||
outputs = outputs
|
||||
.into_iter()
|
||||
.filter(|out| out.tx_log_entry == Some(id))
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
|
||||
if let Some(k) = parent_key_id {
|
||||
outputs = outputs
|
||||
.iter()
|
||||
.filter(|o| o.root_key_id == *k)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
outputs.sort_by_key(|out| out.n_child);
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
|
||||
let res = outputs
|
||||
.into_iter()
|
||||
.map(|output| {
|
||||
let commit = match output.commit.clone() {
|
||||
Some(c) => pedersen::Commitment::from_vec(util::from_hex(&c).unwrap()),
|
||||
None => keychain
|
||||
.commit(output.value, &output.key_id, SwitchCommitmentType::Regular)
|
||||
.unwrap(), // TODO: proper support for different switch commitment schemes
|
||||
};
|
||||
OutputCommitMapping { output, commit }
|
||||
})
|
||||
.collect();
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Apply advanced filtering to resultset from retrieve_txs below
|
||||
pub fn apply_advanced_tx_list_filtering<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
query_args: &RetrieveTxQueryArgs,
|
||||
) -> Vec<TxLogEntry>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// Apply simple bool, GTE or LTE fields
|
||||
let txs_iter: Box<dyn Iterator<Item = TxLogEntry>> = Box::new(
|
||||
wallet
|
||||
.tx_log_iter()
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.exclude_cancelled {
|
||||
if v {
|
||||
tx_entry.tx_type != TxLogEntryType::TxReceivedCancelled
|
||||
&& tx_entry.tx_type != TxLogEntryType::TxSentCancelled
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.include_outstanding_only {
|
||||
if v {
|
||||
!tx_entry.confirmed
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.include_confirmed_only {
|
||||
if v {
|
||||
tx_entry.confirmed
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.include_sent_only {
|
||||
if v {
|
||||
tx_entry.tx_type == TxLogEntryType::TxSent
|
||||
|| tx_entry.tx_type == TxLogEntryType::TxSentCancelled
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.include_received_only {
|
||||
if v {
|
||||
tx_entry.tx_type == TxLogEntryType::TxReceived
|
||||
|| tx_entry.tx_type == TxLogEntryType::TxReceivedCancelled
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.include_coinbase_only {
|
||||
if v {
|
||||
tx_entry.tx_type == TxLogEntryType::ConfirmedCoinbase
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.include_reverted_only {
|
||||
if v {
|
||||
tx_entry.tx_type == TxLogEntryType::TxReverted
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.min_id {
|
||||
tx_entry.id >= v
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.max_id {
|
||||
tx_entry.id <= v
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.min_amount {
|
||||
if tx_entry.tx_type == TxLogEntryType::TxSent
|
||||
|| tx_entry.tx_type == TxLogEntryType::TxSentCancelled
|
||||
{
|
||||
BigInt::from(tx_entry.amount_debited)
|
||||
- BigInt::from(tx_entry.amount_credited)
|
||||
>= BigInt::from(v)
|
||||
} else {
|
||||
BigInt::from(tx_entry.amount_credited)
|
||||
- BigInt::from(tx_entry.amount_debited)
|
||||
>= BigInt::from(v)
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.max_amount {
|
||||
if tx_entry.tx_type == TxLogEntryType::TxSent
|
||||
|| tx_entry.tx_type == TxLogEntryType::TxSentCancelled
|
||||
{
|
||||
BigInt::from(tx_entry.amount_debited)
|
||||
- BigInt::from(tx_entry.amount_credited)
|
||||
<= BigInt::from(v)
|
||||
} else {
|
||||
BigInt::from(tx_entry.amount_credited)
|
||||
- BigInt::from(tx_entry.amount_debited)
|
||||
<= BigInt::from(v)
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.min_creation_timestamp {
|
||||
tx_entry.creation_ts >= v
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.min_confirmed_timestamp {
|
||||
tx_entry.creation_ts <= v
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.min_confirmed_timestamp {
|
||||
if let Some(t) = tx_entry.confirmation_ts {
|
||||
t >= v
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|tx_entry| {
|
||||
if let Some(v) = query_args.max_confirmed_timestamp {
|
||||
if let Some(t) = tx_entry.confirmation_ts {
|
||||
t <= v
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut return_txs: Vec<TxLogEntry> = txs_iter.collect();
|
||||
|
||||
// Now apply requested sorting
|
||||
if let Some(ref s) = query_args.sort_field {
|
||||
match s {
|
||||
RetrieveTxQuerySortField::Id => {
|
||||
return_txs.sort_by_key(|tx| tx.id);
|
||||
}
|
||||
RetrieveTxQuerySortField::CreationTimestamp => {
|
||||
return_txs.sort_by_key(|tx| tx.creation_ts);
|
||||
}
|
||||
RetrieveTxQuerySortField::ConfirmationTimestamp => {
|
||||
return_txs.sort_by_key(|tx| tx.confirmation_ts);
|
||||
}
|
||||
RetrieveTxQuerySortField::TotalAmount => {
|
||||
return_txs.sort_by_key(|tx| {
|
||||
if tx.tx_type == TxLogEntryType::TxSent
|
||||
|| tx.tx_type == TxLogEntryType::TxSentCancelled
|
||||
{
|
||||
BigInt::from(tx.amount_debited) - BigInt::from(tx.amount_credited)
|
||||
} else {
|
||||
BigInt::from(tx.amount_credited) - BigInt::from(tx.amount_debited)
|
||||
}
|
||||
});
|
||||
}
|
||||
RetrieveTxQuerySortField::AmountCredited => {
|
||||
return_txs.sort_by_key(|tx| tx.amount_credited);
|
||||
}
|
||||
RetrieveTxQuerySortField::AmountDebited => {
|
||||
return_txs.sort_by_key(|tx| tx.amount_debited);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return_txs.sort_by_key(|tx| tx.id);
|
||||
}
|
||||
|
||||
if let Some(ref s) = query_args.sort_order {
|
||||
match s {
|
||||
RetrieveTxQuerySortOrder::Desc => return_txs.reverse(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit if requested
|
||||
if let Some(l) = query_args.limit {
|
||||
return_txs = return_txs.into_iter().take(l as usize).collect()
|
||||
}
|
||||
|
||||
return_txs
|
||||
}
|
||||
|
||||
/// Retrieve all of the transaction entries, or a particular entry
|
||||
/// if `parent_key_id` is set, only return entries from that key
|
||||
pub fn retrieve_txs<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
tx_id: Option<u32>,
|
||||
tx_slate_id: Option<Uuid>,
|
||||
query_args: Option<RetrieveTxQueryArgs>,
|
||||
parent_key_id: Option<&Identifier>,
|
||||
outstanding_only: bool,
|
||||
) -> Result<Vec<TxLogEntry>, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let mut txs;
|
||||
// Adding in new transaction list query logic. If `tx_id` or `tx_slate_id`
|
||||
// is provided, then `query_args` is ignored and old logic is followed.
|
||||
if query_args.is_some() && tx_id.is_none() && tx_slate_id.is_none() {
|
||||
txs = apply_advanced_tx_list_filtering(wallet, &query_args.unwrap())
|
||||
} else {
|
||||
txs = wallet
|
||||
.tx_log_iter()
|
||||
.filter(|tx_entry| {
|
||||
let f_pk = match parent_key_id {
|
||||
Some(k) => tx_entry.parent_key_id == *k,
|
||||
None => true,
|
||||
};
|
||||
let f_tx_id = match tx_id {
|
||||
Some(i) => tx_entry.id == i,
|
||||
None => true,
|
||||
};
|
||||
let f_txs = match tx_slate_id {
|
||||
Some(t) => tx_entry.tx_slate_id == Some(t),
|
||||
None => true,
|
||||
};
|
||||
let f_outstanding = match outstanding_only {
|
||||
true => {
|
||||
!tx_entry.confirmed
|
||||
&& (tx_entry.tx_type == TxLogEntryType::TxReceived
|
||||
|| tx_entry.tx_type == TxLogEntryType::TxSent
|
||||
|| tx_entry.tx_type == TxLogEntryType::TxReverted)
|
||||
}
|
||||
false => true,
|
||||
};
|
||||
f_pk && f_tx_id && f_txs && f_outstanding
|
||||
})
|
||||
.collect();
|
||||
txs.sort_by_key(|tx| tx.creation_ts);
|
||||
}
|
||||
Ok(txs)
|
||||
}
|
||||
|
||||
/// Refreshes the outputs in a wallet with the latest information
|
||||
/// from a node
|
||||
pub fn refresh_outputs<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
parent_key_id: &Identifier,
|
||||
update_all: bool,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let height = wallet.w2n_client().get_chain_tip()?.0;
|
||||
refresh_output_state(wallet, keychain_mask, height, parent_key_id, update_all)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// build a local map of wallet outputs keyed by commit
|
||||
/// and a list of outputs we want to query the node for
|
||||
pub fn map_wallet_outputs<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
parent_key_id: &Identifier,
|
||||
update_all: bool,
|
||||
) -> Result<HashMap<pedersen::Commitment, (Identifier, Option<u64>, Option<u32>, bool)>, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let mut wallet_outputs = HashMap::new();
|
||||
let keychain = wallet.keychain(keychain_mask)?;
|
||||
let unspents: Vec<OutputData> = wallet
|
||||
.iter()
|
||||
.filter(|x| x.root_key_id == *parent_key_id && x.status != OutputStatus::Spent)
|
||||
.collect();
|
||||
|
||||
let tx_entries = retrieve_txs(wallet, None, None, None, Some(&parent_key_id), true)?;
|
||||
|
||||
// Only select outputs that are actually involved in an outstanding transaction
|
||||
let unspents = match update_all {
|
||||
false => unspents
|
||||
.into_iter()
|
||||
.filter(|x| match x.tx_log_entry.as_ref() {
|
||||
Some(t) => tx_entries.iter().any(|te| te.id == *t),
|
||||
None => true,
|
||||
})
|
||||
.collect(),
|
||||
true => unspents,
|
||||
};
|
||||
|
||||
for out in unspents {
|
||||
let commit = match out.commit.clone() {
|
||||
Some(c) => pedersen::Commitment::from_vec(util::from_hex(&c).unwrap()),
|
||||
None => keychain
|
||||
.commit(out.value, &out.key_id, SwitchCommitmentType::Regular)
|
||||
.unwrap(), // TODO: proper support for different switch commitment schemes
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
/// Cancel transaction and associated outputs
|
||||
pub fn cancel_tx_and_outputs<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
mut tx: TxLogEntry,
|
||||
outputs: Vec<OutputData>,
|
||||
parent_key_id: &Identifier,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
|
||||
for mut o in outputs {
|
||||
// unlock locked outputs
|
||||
if o.status == OutputStatus::Unconfirmed || o.status == OutputStatus::Reverted {
|
||||
batch.delete(&o.key_id, &o.mmr_index)?;
|
||||
}
|
||||
if o.status == OutputStatus::Locked {
|
||||
o.status = OutputStatus::Unspent;
|
||||
batch.save(o)?;
|
||||
}
|
||||
}
|
||||
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()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply refreshed API output data to the wallet
|
||||
pub fn apply_api_outputs<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
wallet_outputs: &HashMap<pedersen::Commitment, (Identifier, Option<u64>, Option<u32>, bool)>,
|
||||
api_outputs: &HashMap<pedersen::Commitment, (String, u64, u64)>,
|
||||
reverted_kernels: HashSet<u32>,
|
||||
height: u64,
|
||||
parent_key_id: &Identifier,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
// now for each commit, find the output in the wallet and the corresponding
|
||||
// api output (if it exists) and refresh it in-place in the wallet.
|
||||
// Note: minimizing the time we spend holding the wallet lock.
|
||||
{
|
||||
let last_confirmed_height = wallet.last_confirmed_height()?;
|
||||
// If the server height is less than our confirmed height, don't apply
|
||||
// these changes as the chain is syncing, incorrect or forking
|
||||
if height < last_confirmed_height {
|
||||
warn!(
|
||||
"Not updating outputs as the height of the node's chain \
|
||||
is less than the last reported wallet update height."
|
||||
);
|
||||
warn!("Please wait for sync on node to complete or fork to resolve and try again.");
|
||||
return Ok(());
|
||||
}
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
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) => {
|
||||
// if this is a coinbase tx being confirmed, it's recordable in tx log
|
||||
if output.is_coinbase && output.status == OutputStatus::Unconfirmed {
|
||||
let log_id = batch.next_tx_log_id(parent_key_id)?;
|
||||
let mut t = TxLogEntry::new(
|
||||
parent_key_id.clone(),
|
||||
TxLogEntryType::ConfirmedCoinbase,
|
||||
log_id,
|
||||
);
|
||||
t.confirmed = true;
|
||||
t.amount_credited = output.value;
|
||||
t.amount_debited = 0;
|
||||
t.num_outputs = 1;
|
||||
// calculate kernel excess for coinbase
|
||||
{
|
||||
let secp = static_secp_instance();
|
||||
let secp = secp.lock();
|
||||
let over_commit = secp.commit_value(output.value)?;
|
||||
let excess = secp.commit_sum(vec![*commit], vec![over_commit])?;
|
||||
t.kernel_excess = Some(excess);
|
||||
t.kernel_lookup_min_height = Some(height);
|
||||
}
|
||||
t.update_confirmation_ts();
|
||||
output.tx_log_entry = Some(log_id);
|
||||
batch.save_tx_log_entry(t, &parent_key_id)?;
|
||||
}
|
||||
// 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
|
||||
|| 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)?;
|
||||
}
|
||||
}
|
||||
output.height = o.1;
|
||||
output.mark_unspent();
|
||||
}
|
||||
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)?;
|
||||
}
|
||||
batch.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a single api query to retrieve the latest output data from the node.
|
||||
/// So we can refresh the local wallet outputs.
|
||||
pub fn refresh_output_state<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
height: u64,
|
||||
parent_key_id: &Identifier,
|
||||
update_all: bool,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
debug!("Refreshing wallet outputs");
|
||||
|
||||
// build a local map of wallet outputs keyed by commit
|
||||
// and a list of outputs we want to query the node for
|
||||
let wallet_outputs = map_wallet_outputs(wallet, keychain_mask, parent_key_id, update_all)?;
|
||||
|
||||
let wallet_output_keys = wallet_outputs.keys().copied().collect();
|
||||
|
||||
let api_outputs = wallet
|
||||
.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,
|
||||
)?;
|
||||
clean_old_unconfirmed(wallet, keychain_mask, height)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_reverted_kernels<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
wallet_outputs: &HashMap<pedersen::Commitment, (Identifier, Option<u64>, Option<u32>, bool)>,
|
||||
api_outputs: &HashMap<pedersen::Commitment, (String, u64, u64)>,
|
||||
parent_key_id: &Identifier,
|
||||
) -> Result<HashSet<u32>, 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>,
|
||||
height: u64,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
if height < 50 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut ids_to_del = vec![];
|
||||
for out in wallet.iter() {
|
||||
if out.status == OutputStatus::Unconfirmed
|
||||
&& out.height > 0
|
||||
&& out.height < height - 50
|
||||
&& out.is_coinbase
|
||||
{
|
||||
ids_to_del.push(out.key_id.clone())
|
||||
}
|
||||
}
|
||||
let mut batch = wallet.batch(keychain_mask)?;
|
||||
for id in ids_to_del {
|
||||
batch.delete(&id, &None)?;
|
||||
}
|
||||
batch.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve summary info about the wallet
|
||||
/// caller should refresh first if desired
|
||||
pub fn retrieve_info<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
parent_key_id: &Identifier,
|
||||
minimum_confirmations: u64,
|
||||
) -> Result<WalletInfo, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let current_height = wallet.last_confirmed_height()?;
|
||||
let outputs = wallet
|
||||
.iter()
|
||||
.filter(|out| out.root_key_id == *parent_key_id);
|
||||
|
||||
let mut unspent_total = 0;
|
||||
let mut immature_total = 0;
|
||||
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 {
|
||||
OutputStatus::Unspent => {
|
||||
if out.is_coinbase && out.lock_height > current_height {
|
||||
immature_total += out.value;
|
||||
} else if out.num_confirmations(current_height) < minimum_confirmations {
|
||||
// Treat anything less than minimum confirmations as "unconfirmed".
|
||||
unconfirmed_total += out.value;
|
||||
} else {
|
||||
unspent_total += out.value;
|
||||
}
|
||||
}
|
||||
OutputStatus::Unconfirmed => {
|
||||
// We ignore unconfirmed coinbase outputs completely.
|
||||
if !out.is_coinbase {
|
||||
if minimum_confirmations == 0 {
|
||||
unconfirmed_total += out.value;
|
||||
} else {
|
||||
awaiting_finalization_total += out.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputStatus::Locked => {
|
||||
locked_total += out.value;
|
||||
}
|
||||
OutputStatus::Reverted => reverted_total += out.value,
|
||||
OutputStatus::Spent => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WalletInfo {
|
||||
last_confirmed_height: current_height,
|
||||
minimum_confirmations,
|
||||
total: unspent_total + unconfirmed_total + immature_total,
|
||||
amount_awaiting_finalization: awaiting_finalization_total,
|
||||
amount_awaiting_confirmation: unconfirmed_total,
|
||||
amount_immature: immature_total,
|
||||
amount_locked: locked_total,
|
||||
amount_currently_spendable: unspent_total,
|
||||
amount_reverted: reverted_total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rollback outputs associated with a transaction in the wallet
|
||||
pub fn cancel_tx<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
keychain_mask: Option<&SecretKey>,
|
||||
parent_key_id: &Identifier,
|
||||
tx_id: Option<u32>,
|
||||
tx_slate_id: Option<Uuid>,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let mut tx_id_string = String::new();
|
||||
if let Some(tx_id) = tx_id {
|
||||
tx_id_string = tx_id.to_string();
|
||||
} else if let Some(tx_slate_id) = tx_slate_id {
|
||||
tx_id_string = tx_slate_id.to_string();
|
||||
}
|
||||
let tx_vec = retrieve_txs(
|
||||
wallet,
|
||||
tx_id,
|
||||
tx_slate_id,
|
||||
None,
|
||||
Some(&parent_key_id),
|
||||
false,
|
||||
)?;
|
||||
if tx_vec.len() != 1 {
|
||||
return Err(Error::TransactionDoesntExist(tx_id_string));
|
||||
}
|
||||
let tx = tx_vec[0].clone();
|
||||
match tx.tx_type {
|
||||
TxLogEntryType::TxSent | TxLogEntryType::TxReceived | TxLogEntryType::TxReverted => {}
|
||||
_ => return Err(Error::TransactionNotCancellable(tx_id_string)),
|
||||
}
|
||||
if tx.confirmed {
|
||||
return Err(Error::TransactionNotCancellable(tx_id_string));
|
||||
}
|
||||
// get outputs associated with tx
|
||||
let res = retrieve_outputs(
|
||||
wallet,
|
||||
keychain_mask,
|
||||
false,
|
||||
Some(tx.id),
|
||||
Some(&parent_key_id),
|
||||
)?;
|
||||
let outputs = res.iter().map(|m| m.output.clone()).collect();
|
||||
cancel_tx_and_outputs(wallet, keychain_mask, tx, outputs, parent_key_id)?;
|
||||
Ok(())
|
||||
}
|
||||
+473
-25
@@ -12,51 +12,499 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use crate::node::NodeConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
use grin_core::global;
|
||||
use grin_keychain::{ExtKeychain, Identifier, Keychain};
|
||||
use grin_util::types::ZeroingString;
|
||||
use grin_wallet_api::{Foreign, ForeignCheckMiddlewareFn, Owner};
|
||||
use grin_wallet_impls::{DefaultLCProvider, DefaultWalletImpl, HTTPNodeClient};
|
||||
use grin_wallet_libwallet::{Error, NodeClient, NodeVersionInfo, OutputStatus, Slate, slate_versions, SlatepackArmor, Slatepacker, SlatepackerArgs, TxLogEntry, wallet_lock, WalletBackend, WalletInfo, WalletInst, WalletLCProvider};
|
||||
use grin_wallet_libwallet::Error::GenericError;
|
||||
use log::debug;
|
||||
use parking_lot::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::node::NodeConfig;
|
||||
use crate::wallet::selection::lock_tx_context;
|
||||
use crate::wallet::tx::{add_inputs_to_slate, new_tx_slate};
|
||||
use crate::wallet::updater::{cancel_tx, refresh_output_state, retrieve_txs};
|
||||
use crate::wallet::WalletConfig;
|
||||
|
||||
/// Wallet loaded from config.
|
||||
/// Wallet instance and config wrapper.
|
||||
#[derive(Clone)]
|
||||
pub struct Wallet {
|
||||
/// Identifier for a wallet, name of wallet directory.
|
||||
id: OsString,
|
||||
/// Base path for wallet data.
|
||||
pub(crate) path: String,
|
||||
/// Loaded file config.
|
||||
/// Wallet instance, exists when wallet is open.
|
||||
instance: Option<WalletInstance>,
|
||||
/// Wallet data path.
|
||||
path: String,
|
||||
/// Wallet configuration.
|
||||
pub(crate) config: WalletConfig,
|
||||
}
|
||||
|
||||
impl Wallet {
|
||||
/// Create new wallet from provided name.
|
||||
pub fn create(name: String ) {
|
||||
/// Wallet instance type.
|
||||
type WalletInstance = Arc<
|
||||
Mutex<
|
||||
Box<
|
||||
dyn WalletInst<
|
||||
'static,
|
||||
DefaultLCProvider<'static, HTTPNodeClient, ExtKeychain>,
|
||||
HTTPNodeClient,
|
||||
ExtKeychain,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
>;
|
||||
|
||||
impl Wallet {
|
||||
/// Create and open new wallet.
|
||||
pub fn create(
|
||||
name: String,
|
||||
password: String,
|
||||
mnemonic: String,
|
||||
external_node_url: Option<String>
|
||||
) -> Result<Wallet, Error> {
|
||||
let config = WalletConfig::create(name.clone(), external_node_url);
|
||||
let wallet = Self::create_wallet_instance(config.clone())?;
|
||||
let mut w_lock = wallet.lock();
|
||||
let p = w_lock.lc_provider()?;
|
||||
|
||||
// Create wallet.
|
||||
p.create_wallet(None,
|
||||
Some(ZeroingString::from(mnemonic.clone())),
|
||||
mnemonic.len(),
|
||||
ZeroingString::from(password.clone()),
|
||||
false,
|
||||
)?;
|
||||
|
||||
// Open wallet.
|
||||
p.open_wallet(None, ZeroingString::from(password), false, false)?;
|
||||
|
||||
let w = Wallet {
|
||||
instance: Some(wallet.clone()),
|
||||
path: config.get_data_path(),
|
||||
config,
|
||||
};
|
||||
Ok(w)
|
||||
}
|
||||
|
||||
/// Load wallet from provided data path.
|
||||
pub fn load(data_path: PathBuf) -> Option<Wallet> {
|
||||
if !data_path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
/// Initialize wallet from provided data path.
|
||||
pub fn init(data_path: PathBuf) -> Option<Wallet> {
|
||||
let wallet_config = WalletConfig::load(data_path.clone());
|
||||
if let Some(config) = wallet_config {
|
||||
// Set id as wallet directory name.
|
||||
let id = data_path.file_name().unwrap().to_os_string();
|
||||
let path = data_path.to_str().unwrap().to_string();
|
||||
return Some(Self { id, path, config });
|
||||
return Some(Self { instance: None, path, config });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get wallet node connection URL.
|
||||
pub fn get_connection_url(&self) -> String {
|
||||
match self.config.get_external_node_url() {
|
||||
None => {
|
||||
format!("http://{}", NodeConfig::get_api_address())
|
||||
/// Check if wallet is open (instance exists).
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.instance.is_some()
|
||||
}
|
||||
|
||||
/// Create wallet instance from provided config.
|
||||
fn create_wallet_instance(config: WalletConfig) -> Result<WalletInstance, Error> {
|
||||
// Assume global chain type has already been initialized.
|
||||
let chain_type = AppConfig::chain_type();
|
||||
if !global::GLOBAL_CHAIN_TYPE.is_init() {
|
||||
global::init_global_chain_type(chain_type);
|
||||
} else {
|
||||
global::set_global_chain_type(chain_type);
|
||||
global::set_local_chain_type(chain_type);
|
||||
}
|
||||
|
||||
// Setup node client.
|
||||
let (node_api_url, node_secret) = if let Some(url) = config.get_external_node_url() {
|
||||
(url.to_string(), None)
|
||||
} else {
|
||||
(NodeConfig::get_api_address(), NodeConfig::get_api_secret())
|
||||
};
|
||||
let node_client = HTTPNodeClient::new(&node_api_url, node_secret)?;
|
||||
|
||||
// Create wallet instance.
|
||||
let wallet = Self::inst_wallet::<
|
||||
DefaultLCProvider<HTTPNodeClient, ExtKeychain>,
|
||||
HTTPNodeClient,
|
||||
ExtKeychain,
|
||||
>(config, node_client)?;
|
||||
Ok(wallet)
|
||||
}
|
||||
|
||||
/// Instantiate wallet from provided node client and config.
|
||||
fn inst_wallet<L, C, K>(
|
||||
config: WalletConfig,
|
||||
node_client: C,
|
||||
) -> Result<Arc<Mutex<Box<dyn WalletInst<'static, L, C, K>>>>, Error>
|
||||
where
|
||||
DefaultWalletImpl<'static, C>: WalletInst<'static, L, C, K>,
|
||||
L: WalletLCProvider<'static, C, K>,
|
||||
C: NodeClient + 'static,
|
||||
K: Keychain + 'static,
|
||||
{
|
||||
let mut wallet = Box::new(DefaultWalletImpl::<'static, C>::new(node_client).unwrap())
|
||||
as Box<dyn WalletInst<'static, L, C, K>>;
|
||||
let lc = wallet.lc_provider()?;
|
||||
lc.set_top_level_directory(config.get_data_path().as_str())?;
|
||||
Ok(Arc::new(Mutex::new(wallet)))
|
||||
}
|
||||
|
||||
/// Open wallet.
|
||||
pub fn open_wallet(&mut self, password: ZeroingString) -> Result<(), Error> {
|
||||
if let None = self.instance {
|
||||
let wallet = Self::create_wallet_instance(self.config.clone())?;
|
||||
let mut wallet_lock = wallet.lock();
|
||||
let lc = wallet_lock.lc_provider()?;
|
||||
lc.open_wallet(None, password, false, false)?;
|
||||
self.instance = Some(wallet.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close wallet.
|
||||
pub fn close_wallet(&self) -> Result<(), Error> {
|
||||
if let Some(wallet) = &self.instance {
|
||||
let mut wallet_lock = wallet.lock();
|
||||
let lc = wallet_lock.lc_provider()?;
|
||||
lc.close_wallet(None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create transaction.
|
||||
fn tx_create(
|
||||
&self,
|
||||
amount: u64,
|
||||
minimum_confirmations: u64,
|
||||
selection_strategy_is_use_all: bool,
|
||||
) -> Result<(Vec<TxLogEntry>, String), Error> {
|
||||
let wallet = self.instance.clone().ok_or(GenericError("Wallet was not open".to_string()))?;
|
||||
let parent_key_id = {
|
||||
wallet_lock!(wallet.clone(), w);
|
||||
w.parent_key_id().clone()
|
||||
};
|
||||
|
||||
let slate = {
|
||||
wallet_lock!(wallet, w);
|
||||
let mut slate = new_tx_slate(&mut **w, amount, false, 2, false, None)?;
|
||||
let height = w.w2n_client().get_chain_tip()?.0;
|
||||
|
||||
let context = add_inputs_to_slate(
|
||||
&mut **w,
|
||||
None,
|
||||
&mut slate,
|
||||
height,
|
||||
minimum_confirmations,
|
||||
500,
|
||||
1,
|
||||
selection_strategy_is_use_all,
|
||||
&parent_key_id,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
)?;
|
||||
|
||||
{
|
||||
let mut batch = w.batch(None)?;
|
||||
batch.save_private_context(slate.id.as_bytes(), &context)?;
|
||||
batch.commit()?;
|
||||
}
|
||||
|
||||
lock_tx_context(&mut **w, None, &slate, height, &context, None)?;
|
||||
slate.compact()?;
|
||||
slate
|
||||
};
|
||||
|
||||
let packer = Slatepacker::new(SlatepackerArgs {
|
||||
sender: None, // sender
|
||||
recipients: vec![],
|
||||
dec_key: None,
|
||||
});
|
||||
let slatepack = packer.create_slatepack(&slate)?;
|
||||
let api = Owner::new(self.instance.clone().unwrap(), None);
|
||||
let txs = api.retrieve_txs(None, false, None, Some(slate.id), None)?;
|
||||
let result = (
|
||||
txs.1,
|
||||
SlatepackArmor::encode(&slatepack)?,
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Callback to check slate compatibility at current node.
|
||||
fn check_middleware(
|
||||
name: ForeignCheckMiddlewareFn,
|
||||
node_version_info: Option<NodeVersionInfo>,
|
||||
slate: Option<&Slate>,
|
||||
) -> Result<(), Error> {
|
||||
match name {
|
||||
ForeignCheckMiddlewareFn::BuildCoinbase => Ok(()),
|
||||
_ => {
|
||||
let mut bhv = 3;
|
||||
if let Some(n) = node_version_info {
|
||||
bhv = n.block_header_version;
|
||||
}
|
||||
if let Some(s) = slate {
|
||||
if bhv > 4
|
||||
&& s.version_info.block_header_version
|
||||
< slate_versions::GRIN_BLOCK_HEADER_VERSION
|
||||
{
|
||||
Err(Error::Compatibility(
|
||||
"Incoming Slate is not compatible with this wallet. \
|
||||
Please upgrade the node or use a different one."
|
||||
.into(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some(url) => url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive transaction.
|
||||
fn tx_receive(
|
||||
&self,
|
||||
account: &str,
|
||||
slate_armored: &str
|
||||
) -> Result<(Vec<TxLogEntry>, String), Error> {
|
||||
let wallet = self.instance.clone().ok_or(GenericError("Wallet was not open".to_string()))?;
|
||||
let foreign_api = Foreign::new(wallet.clone(), None, Some(Self::check_middleware), false);
|
||||
let owner_api = Owner::new(wallet, None);
|
||||
|
||||
let mut slate =
|
||||
owner_api.slate_from_slatepack_message(None, slate_armored.to_owned(), vec![0])?;
|
||||
let slatepack = owner_api.decode_slatepack_message(None, slate_armored.to_owned(), vec![0])?;
|
||||
|
||||
let _ret_address = slatepack.sender;
|
||||
|
||||
slate = foreign_api.receive_tx(&slate, Some(&account), None)?;
|
||||
let txs = owner_api.retrieve_txs(None, false, None, Some(slate.id), None)?;
|
||||
let packer = Slatepacker::new(SlatepackerArgs {
|
||||
sender: None, // sender
|
||||
recipients: vec![],
|
||||
dec_key: None,
|
||||
});
|
||||
let slatepack = packer.create_slatepack(&slate)?;
|
||||
let result = (
|
||||
txs.1,
|
||||
SlatepackArmor::encode(&slatepack)?,
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Cancel transaction.
|
||||
fn tx_cancel(&self, id: u32) -> Result<String, Error> {
|
||||
let wallet = self.instance.clone().ok_or(GenericError("Wallet was not open".to_string()))?;
|
||||
wallet_lock!(wallet, w);
|
||||
let parent_key_id = w.parent_key_id();
|
||||
cancel_tx(&mut **w, None, &parent_key_id, Some(id), None)?;
|
||||
Ok("".to_owned())
|
||||
}
|
||||
|
||||
/// Get transaction info.
|
||||
pub fn get_tx(&self, tx_slate_id: &str) -> Result<(bool, Vec<TxLogEntry>), Error> {
|
||||
let api = Owner::new(self.instance.clone().unwrap(), None);
|
||||
let uuid = Uuid::parse_str(tx_slate_id).unwrap();
|
||||
let txs = api.retrieve_txs(None, true, None, Some(uuid), None)?;
|
||||
Ok(txs)
|
||||
}
|
||||
|
||||
/// Finalize transaction.
|
||||
fn tx_finalize(&self, slate_armored: &str) -> Result<(bool, Vec<TxLogEntry>), Error> {
|
||||
let wallet = self.instance.clone().ok_or(GenericError("Wallet was not open".to_string()))?;
|
||||
let owner_api = Owner::new(wallet, None);
|
||||
let mut slate =
|
||||
owner_api.slate_from_slatepack_message(None, slate_armored.to_owned(), vec![0])?;
|
||||
let slatepack = owner_api.decode_slatepack_message(None, slate_armored.to_owned(), vec![0])?;
|
||||
|
||||
let _ret_address = slatepack.sender;
|
||||
|
||||
slate = owner_api.finalize_tx(None, &slate)?;
|
||||
let txs = owner_api.retrieve_txs(None, false, None, Some(slate.id), None)?;
|
||||
Ok(txs)
|
||||
}
|
||||
|
||||
/// Post transaction to node for broadcasting.
|
||||
fn tx_post(&self, tx_slate_id: &str) -> Result<(), Error> {
|
||||
let wallet = self.instance.clone().ok_or(GenericError("Wallet was not open".to_string()))?;
|
||||
let api = Owner::new(wallet, None);
|
||||
let tx_uuid = Uuid::parse_str(tx_slate_id).unwrap();
|
||||
let (_, txs) = api.retrieve_txs(None, true, None, Some(tx_uuid.clone()), None)?;
|
||||
if txs[0].confirmed {
|
||||
return Err(Error::from(GenericError(format!(
|
||||
"Transaction with id {} is already confirmed. Not posting.",
|
||||
tx_slate_id
|
||||
))));
|
||||
}
|
||||
let stored_tx = api.get_stored_tx(None, None, Some(&tx_uuid))?;
|
||||
match stored_tx {
|
||||
Some(stored_tx) => {
|
||||
api.post_tx(None, &stored_tx, true)?;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(Error::from(GenericError(format!(
|
||||
"Transaction with id {} does not have transaction data. Not posting.",
|
||||
tx_slate_id
|
||||
)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get transactions and base wallet info.
|
||||
pub fn get_txs_info(
|
||||
&self,
|
||||
minimum_confirmations: u64
|
||||
) -> Result<(bool, Vec<TxLogEntry>, WalletInfo), Error> {
|
||||
let wallet = self.instance.clone().ok_or(GenericError("Wallet was not open".to_string()))?;
|
||||
let refreshed = Self::update_state(wallet.clone()).unwrap_or(false);
|
||||
let wallet_info = {
|
||||
wallet_lock!(wallet, w);
|
||||
let parent_key_id = w.parent_key_id();
|
||||
Self::get_info(&mut **w, &parent_key_id, minimum_confirmations)?
|
||||
};
|
||||
let api = Owner::new(wallet, None);
|
||||
|
||||
let txs = api.retrieve_txs(None, false, None, None, None)?;
|
||||
Ok((refreshed, txs.1, wallet_info))
|
||||
}
|
||||
|
||||
/// Update wallet instance state.
|
||||
fn update_state<'a, L, C, K>(
|
||||
wallet_inst: Arc<Mutex<Box<dyn WalletInst<'a, L, C, K>>>>,
|
||||
) -> Result<bool, Error>
|
||||
where
|
||||
L: WalletLCProvider<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let parent_key_id = {
|
||||
wallet_lock!(wallet_inst, w);
|
||||
w.parent_key_id().clone()
|
||||
};
|
||||
let mut client = {
|
||||
wallet_lock!(wallet_inst, w);
|
||||
w.w2n_client().clone()
|
||||
};
|
||||
let tip = client.get_chain_tip()?;
|
||||
|
||||
// Step1: Update outputs and transactions purely based on UTXO state.
|
||||
{
|
||||
wallet_lock!(wallet_inst, w);
|
||||
if !match refresh_output_state(&mut **w, None, tip.0, &parent_key_id, true) {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
} {
|
||||
// We are unable to contact the node.
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let mut txs = {
|
||||
wallet_lock!(wallet_inst, w);
|
||||
retrieve_txs(&mut **w, None, None, None, Some(&parent_key_id), true)?
|
||||
};
|
||||
|
||||
for tx in txs.iter_mut() {
|
||||
// Step 2: Cancel any transactions with an expired TTL.
|
||||
if let Some(e) = tx.ttl_cutoff_height {
|
||||
if tip.0 >= e {
|
||||
wallet_lock!(wallet_inst, w);
|
||||
let parent_key_id = w.parent_key_id();
|
||||
cancel_tx(&mut **w, None, &parent_key_id, Some(tx.id), None)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Step 3: Update outstanding transactions with no change outputs by kernel.
|
||||
if tx.confirmed {
|
||||
continue;
|
||||
}
|
||||
if tx.amount_debited != 0 && tx.amount_credited != 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = tx.kernel_excess {
|
||||
let res = client.get_kernel(&e, tx.kernel_lookup_min_height, Some(tip.0));
|
||||
let kernel = match res {
|
||||
Ok(k) => k,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
if let Some(k) = kernel {
|
||||
debug!("Kernel Retrieved: {:?}", k);
|
||||
wallet_lock!(wallet_inst, w);
|
||||
let mut batch = w.batch(None)?;
|
||||
tx.confirmed = true;
|
||||
tx.update_confirmation_ts();
|
||||
batch.save_tx_log_entry(tx.clone(), &parent_key_id)?;
|
||||
batch.commit()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
/// Get summary info about the wallet.
|
||||
pub fn get_info<'a, T: ?Sized, C, K>(
|
||||
wallet: &mut T,
|
||||
parent_key_id: &Identifier,
|
||||
minimum_confirmations: u64,
|
||||
) -> Result<WalletInfo, Error>
|
||||
where
|
||||
T: WalletBackend<'a, C, K>,
|
||||
C: NodeClient + 'a,
|
||||
K: Keychain + 'a,
|
||||
{
|
||||
let current_height = wallet.last_confirmed_height()?;
|
||||
let outputs = wallet
|
||||
.iter()
|
||||
.filter(|out| out.root_key_id == *parent_key_id);
|
||||
|
||||
let mut unspent_total = 0;
|
||||
let mut immature_total = 0;
|
||||
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 {
|
||||
OutputStatus::Unspent => {
|
||||
if out.is_coinbase && out.lock_height > current_height {
|
||||
immature_total += out.value;
|
||||
} else if out.num_confirmations(current_height) < minimum_confirmations {
|
||||
// Treat anything less than minimum confirmations as "unconfirmed".
|
||||
unconfirmed_total += out.value;
|
||||
} else {
|
||||
unspent_total += out.value;
|
||||
}
|
||||
}
|
||||
OutputStatus::Unconfirmed => {
|
||||
// We ignore unconfirmed coinbase outputs completely.
|
||||
if !out.is_coinbase {
|
||||
if minimum_confirmations == 0 {
|
||||
unconfirmed_total += out.value;
|
||||
} else {
|
||||
awaiting_finalization_total += out.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputStatus::Locked => {
|
||||
locked_total += out.value;
|
||||
}
|
||||
OutputStatus::Reverted => reverted_total += out.value,
|
||||
OutputStatus::Spent => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WalletInfo {
|
||||
last_confirmed_height: current_height,
|
||||
minimum_confirmations,
|
||||
total: unspent_total + unconfirmed_total + immature_total,
|
||||
amount_awaiting_finalization: awaiting_finalization_total,
|
||||
amount_awaiting_confirmation: unconfirmed_total,
|
||||
amount_immature: immature_total,
|
||||
amount_locked: locked_total,
|
||||
amount_currently_spendable: unspent_total,
|
||||
amount_reverted: reverted_total,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user