Command line implementation of invoice commands (#96)

* add issue_invoice_tx command

* rustfmt

* add first pass at process_invoice command

* start of process_invoice fn

* rustfmt

* rename issue invoice and process invoice to invoice and pay

* add prompting and display information to pay invoice command

* rustfmt

* support invoice transactions in finalize command

* rustfmt
This commit is contained in:
Yeastplume 2019-05-09 19:06:32 +01:00 committed by GitHub
parent 7a39c7cf3c
commit 6f875c5e92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 530 additions and 15 deletions

View file

@ -35,7 +35,7 @@ use crate::impls::{
LMDBBackend, NullWalletCommAdapter,
};
use crate::impls::{HTTPNodeClient, WalletSeed};
use crate::libwallet::{InitTxArgs, NodeClient, WalletInst};
use crate::libwallet::{InitTxArgs, IssueInvoiceTxArgs, NodeClient, WalletInst};
use crate::{controller, display};
/// Arguments common to all wallet commands
@ -377,13 +377,45 @@ pub fn finalize(
) -> Result<(), Error> {
let adapter = FileWalletCommAdapter::new();
let mut slate = adapter.receive_tx_async(&args.input)?;
controller::owner_single_use(wallet.clone(), |api| {
if let Err(e) = api.verify_slate_messages(&slate) {
error!("Error validating participant messages: {}", e);
return Err(e);
// Rather than duplicating the entire command, we'll just
// try to determine what kind of finalization this is
// based on the slate contents
// for now, we can tell this is an invoice transaction
// if the receipient (participant 1) hasn't completed sigs
let part_data = slate.participant_with_id(1);
let is_invoice = {
match part_data {
None => {
error!("Expected slate participant data missing");
return Err(ErrorKind::ArgumentError(
"Expected Slate participant data missing".into(),
))?;
}
Some(p) => !p.is_complete(),
}
slate = api.finalize_tx(&mut slate).expect("Finalize failed");
};
if is_invoice {
controller::foreign_single_use(wallet.clone(), |api| {
if let Err(e) = api.verify_slate_messages(&slate) {
error!("Error validating participant messages: {}", e);
return Err(e);
}
slate = api.finalize_invoice_tx(&mut slate)?;
Ok(())
})?;
} else {
controller::owner_single_use(wallet.clone(), |api| {
if let Err(e) = api.verify_slate_messages(&slate) {
error!("Error validating participant messages: {}", e);
return Err(e);
}
slate = api.finalize_tx(&mut slate)?;
Ok(())
})?;
}
controller::owner_single_use(wallet.clone(), |api| {
let result = api.post_tx(&slate.tx, args.fluff);
match result {
Ok(_) => {
@ -396,9 +428,130 @@ pub fn finalize(
}
}
})?;
Ok(())
}
/// Issue Invoice Args
pub struct IssueInvoiceArgs {
/// output file
pub dest: String,
/// issue invoice tx args
pub issue_args: IssueInvoiceTxArgs,
}
pub fn issue_invoice_tx(
wallet: Arc<Mutex<WalletInst<impl NodeClient + 'static, keychain::ExtKeychain>>>,
args: IssueInvoiceArgs,
) -> Result<(), Error> {
controller::owner_single_use(wallet.clone(), |api| {
let slate = api.issue_invoice_tx(args.issue_args)?;
let mut tx_file = File::create(args.dest.clone())?;
tx_file.write_all(json::to_string(&slate).unwrap().as_bytes())?;
tx_file.sync_all()?;
Ok(())
})?;
Ok(())
}
/// Arguments for the process_invoice command
pub struct ProcessInvoiceArgs {
pub message: Option<String>,
pub minimum_confirmations: u64,
pub selection_strategy: String,
pub method: String,
pub dest: String,
pub max_outputs: usize,
pub target_slate_version: Option<u16>,
pub input: String,
pub estimate_selection_strategies: bool,
}
/// Process invoice
pub fn process_invoice(
wallet: Arc<Mutex<WalletInst<impl NodeClient + 'static, keychain::ExtKeychain>>>,
args: ProcessInvoiceArgs,
dark_scheme: bool,
) -> Result<(), Error> {
let adapter = FileWalletCommAdapter::new();
let slate = adapter.receive_tx_async(&args.input)?;
controller::owner_single_use(wallet.clone(), |api| {
if args.estimate_selection_strategies {
let strategies = vec!["smallest", "all"]
.into_iter()
.map(|strategy| {
let init_args = InitTxArgs {
src_acct_name: None,
amount: slate.amount,
minimum_confirmations: args.minimum_confirmations,
max_outputs: args.max_outputs as u32,
num_change_outputs: 1u32,
selection_strategy_is_use_all: strategy == "all",
estimate_only: Some(true),
..Default::default()
};
let slate = api.init_send_tx(init_args).unwrap();
(strategy, slate.amount, slate.fee)
})
.collect();
display::estimate(slate.amount, strategies, dark_scheme);
} else {
let init_args = InitTxArgs {
src_acct_name: None,
amount: 0,
minimum_confirmations: args.minimum_confirmations,
max_outputs: args.max_outputs as u32,
num_change_outputs: 1u32,
selection_strategy_is_use_all: args.selection_strategy == "all",
message: args.message.clone(),
target_slate_version: args.target_slate_version,
send_args: None,
..Default::default()
};
if let Err(e) = api.verify_slate_messages(&slate) {
error!("Error validating participant messages: {}", e);
return Err(e);
}
let result = api.process_invoice_tx(&slate, init_args);
let mut slate = match result {
Ok(s) => {
info!(
"Invoice processed: {} grin to {} (strategy '{}')",
core::amount_to_hr_string(slate.amount, false),
args.dest,
args.selection_strategy,
);
s
}
Err(e) => {
info!("Tx not created: {}", e);
return Err(e);
}
};
let adapter = match args.method.as_str() {
"http" => HTTPWalletCommAdapter::new(),
"file" => FileWalletCommAdapter::new(),
"self" => NullWalletCommAdapter::new(),
_ => NullWalletCommAdapter::new(),
};
if adapter.supports_sync() {
slate = adapter.send_tx_sync(&args.dest, &slate)?;
api.tx_lock_outputs(&slate)?;
if args.method == "self" {
controller::foreign_single_use(wallet, |api| {
slate = api.finalize_invoice_tx(&slate)?;
Ok(())
})?;
}
} else {
adapter.send_tx_async(&args.dest, &slate)?;
api.tx_lock_outputs(&slate)?;
}
}
Ok(())
})?;
Ok(())
}
/// Info command args
pub struct InfoArgs {
pub minimum_confirmations: u64,

View file

@ -124,6 +124,7 @@ fn invoice_tx_impl(test_dir: &str) -> Result<(), libwallet::Error> {
..Default::default()
};
slate = api.process_invoice_tx(&slate, args)?;
api.tx_lock_outputs(&slate)?;
Ok(())
})?;

View file

@ -330,9 +330,6 @@ where
batch.commit()?;
}
// Always lock the context for now
selection::lock_tx_context(&mut *w, slate, &context)?;
tx::update_message(&mut *w, &mut ret_slate)?;
Ok(ret_slate)
}

View file

@ -27,14 +27,14 @@ use crate::grin_core::core::verifier_cache::LruVerifierCache;
use crate::grin_core::libtx::{aggsig, build, secp_ser, tx_fee};
use crate::grin_core::map_vec;
use crate::grin_keychain::{BlindSum, BlindingFactor, Keychain};
use crate::grin_util::secp;
use crate::grin_util::secp::key::{PublicKey, SecretKey};
use crate::grin_util::secp::Signature;
use crate::grin_util::RwLock;
use crate::grin_util::{self, secp, RwLock};
use failure::ResultExt;
use rand::rngs::mock::StepRng;
use rand::thread_rng;
use serde_json;
use std::fmt;
use std::sync::Arc;
use uuid::Uuid;
@ -113,6 +113,36 @@ impl ParticipantMessageData {
}
}
impl fmt::Display for ParticipantMessageData {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "")?;
write!(f, "Participant ID {} ", self.id)?;
if self.id == 0 {
writeln!(f, "(Sender)")?;
} else {
writeln!(f, "(Recipient)")?;
}
writeln!(f, "---------------------")?;
let static_secp = grin_util::static_secp_instance();
let static_secp = static_secp.lock();
writeln!(
f,
"Public Key: {}",
&grin_util::to_hex(self.public_key.serialize_vec(&static_secp, true).to_vec())
)?;
let message = match self.message.clone() {
None => "None".to_owned(),
Some(m) => m,
};
writeln!(f, "Message: {}", message)?;
let message_sig = match self.message_sig.clone() {
None => "None".to_owned(),
Some(m) => grin_util::to_hex(m.to_raw_data().to_vec()),
};
writeln!(f, "Message Signature: {}", message_sig)
}
}
/// A 'Slate' is passed around to all parties to build up all of the public
/// transaction data needed to create a finalized transaction. Callers can pass
/// the slate around by whatever means they choose, (but we can provide some
@ -344,7 +374,6 @@ impl Slate {
/// Creates the final signature, callable by either the sender or recipient
/// (after phase 3: sender confirmation)
/// TODO: Only callable by receiver at the moment
pub fn finalize<K>(&mut self, keychain: &K) -> Result<(), Error>
where
K: Keychain,
@ -353,6 +382,16 @@ impl Slate {
self.finalize_transaction(keychain, &final_sig)
}
/// Return the participant with the given id
pub fn participant_with_id(&self, id: usize) -> Option<ParticipantData> {
for p in self.participant_data.iter() {
if p.id as usize == id {
return Some(p.clone());
}
}
None
}
/// Return the sum of public nonces
fn pub_nonce_sum(&self, secp: &secp::Secp256k1) -> Result<PublicKey, Error> {
let pub_nonces = self

View file

@ -29,7 +29,7 @@
//! * TxKernel fields serialized as hex strings instead of arrays:
//! commit
//! signature
//! * version_info field removed
//! * version field removed
//! * VersionCompatInfo struct created with fields and added to beginning of struct
//! version: u16
//! orig_verion: u16,

View file

@ -21,9 +21,10 @@ use failure::Fail;
use grin_wallet_config::WalletConfig;
use grin_wallet_controller::command;
use grin_wallet_controller::{Error, ErrorKind};
use grin_wallet_impls::{instantiate_wallet, WalletSeed};
use grin_wallet_libwallet::{NodeClient, WalletInst};
use grin_wallet_impls::{instantiate_wallet, FileWalletCommAdapter, WalletSeed};
use grin_wallet_libwallet::{IssueInvoiceTxArgs, NodeClient, Slate, WalletInst};
use grin_wallet_util::grin_core as core;
use grin_wallet_util::grin_core::core::amount_to_hr_string;
use grin_wallet_util::grin_keychain as keychain;
use linefeed::terminal::Signal;
use linefeed::{Interface, ReadResult};
@ -140,6 +141,62 @@ fn prompt_recovery_phrase() -> Result<ZeroingString, ParseError> {
Ok(phrase)
}
fn prompt_pay_invoice(slate: &Slate, method: &str, dest: &str) -> Result<bool, ParseError> {
let interface = Arc::new(Interface::new("pay")?);
let amount = amount_to_hr_string(slate.amount, false);
interface.set_report_signal(Signal::Interrupt, true);
interface.set_prompt(
"To proceed, type the exact amount of the invoice as displayed above (or Q/q to quit) > ",
)?;
println!();
println!(
"This command will pay the amount specified in the invoice using your wallet's funds."
);
println!("After you confirm, the following will occur: ");
println!();
println!(
"* {} of your wallet funds will be added to the transaction to pay this invoice.",
amount
);
if method == "http" {
println!("* The resulting transaction will IMMEDIATELY be sent to the wallet listening at: '{}'.", dest);
} else {
println!("* The resulting transaction will be saved to the file '{}', which you can manually send back to the invoice creator.", dest);
}
println!();
println!("The invoice slate's participant info is:");
for m in slate.participant_messages().messages {
println!("{}", m);
}
println!("Please review the above information carefully before proceeding");
println!();
loop {
let res = interface.read_line()?;
match res {
ReadResult::Eof => return Ok(false),
ReadResult::Signal(sig) => {
if sig == Signal::Interrupt {
interface.cancel_read_line()?;
return Err(ParseError::CancelledError);
}
}
ReadResult::Input(line) => {
match line.trim() {
"Q" | "q" => return Err(ParseError::CancelledError),
result => {
if result == amount {
return Ok(true);
} else {
println!("Please enter exact amount of the invoice as shown above or Q to quit");
println!();
}
}
}
}
}
}
}
// instantiate wallet (needed by most functions)
pub fn inst_wallet(
@ -457,6 +514,140 @@ pub fn parse_finalize_args(args: &ArgMatches) -> Result<command::FinalizeArgs, P
})
}
pub fn parse_issue_invoice_args(
args: &ArgMatches,
) -> Result<command::IssueInvoiceArgs, ParseError> {
let amount = parse_required(args, "amount")?;
let amount = core::core::amount_from_hr_string(amount);
let amount = match amount {
Ok(a) => a,
Err(e) => {
let msg = format!(
"Could not parse amount as a number with optional decimal point. e={}",
e
);
return Err(ParseError::ArgumentError(msg));
}
};
// message
let message = match args.is_present("message") {
true => Some(args.value_of("message").unwrap().to_owned()),
false => None,
};
// target slate version to create
let target_slate_version = {
match args.is_present("slate_version") {
true => {
let v = parse_required(args, "slate_version")?;
Some(parse_u64(v, "slate_version")? as u16)
}
false => None,
}
};
// dest (output file)
let dest = parse_required(args, "dest")?;
Ok(command::IssueInvoiceArgs {
dest: dest.into(),
issue_args: IssueInvoiceTxArgs {
dest_acct_name: None,
amount,
message,
target_slate_version,
},
})
}
pub fn parse_process_invoice_args(
args: &ArgMatches,
) -> Result<command::ProcessInvoiceArgs, ParseError> {
// TODO: display and prompt for confirmation of what we're doing
// message
let message = match args.is_present("message") {
true => Some(args.value_of("message").unwrap().to_owned()),
false => None,
};
// minimum_confirmations
let min_c = parse_required(args, "minimum_confirmations")?;
let min_c = parse_u64(min_c, "minimum_confirmations")?;
// selection_strategy
let selection_strategy = parse_required(args, "selection_strategy")?;
// estimate_selection_strategies
let estimate_selection_strategies = args.is_present("estimate_selection_strategies");
// method
let method = parse_required(args, "method")?;
// dest
let dest = {
if method == "self" {
match args.value_of("dest") {
Some(d) => d,
None => "default",
}
} else {
if !estimate_selection_strategies {
parse_required(args, "dest")?
} else {
""
}
}
};
if !estimate_selection_strategies
&& method == "http"
&& !dest.starts_with("http://")
&& !dest.starts_with("https://")
{
let msg = format!(
"HTTP Destination should start with http://: or https://: {}",
dest,
);
return Err(ParseError::ArgumentError(msg));
}
// max_outputs
let max_outputs = 500;
// target slate version to create/send
let target_slate_version = {
match args.is_present("slate_version") {
true => {
let v = parse_required(args, "slate_version")?;
Some(parse_u64(v, "slate_version")? as u16)
}
false => None,
}
};
// file input only
let tx_file = parse_required(args, "input")?;
// Now we need to prompt the user whether they want to do this,
// which requires reading the slate
let adapter = FileWalletCommAdapter::new();
let slate = match adapter.receive_tx_async(&tx_file) {
Ok(s) => s,
Err(e) => return Err(ParseError::ArgumentError(format!("{}", e))),
};
#[cfg(not(test))] // don't prompt during automated testing
prompt_pay_invoice(&slate, method, dest)?;
Ok(command::ProcessInvoiceArgs {
message: message,
minimum_confirmations: min_c,
selection_strategy: selection_strategy.to_owned(),
estimate_selection_strategies,
method: method.to_owned(),
dest: dest.to_owned(),
max_outputs: max_outputs,
target_slate_version: target_slate_version,
input: tx_file.to_owned(),
})
}
pub fn parse_info_args(args: &ArgMatches) -> Result<command::InfoArgs, ParseError> {
// minimum_confirmations
let mc = parse_required(args, "minimum_confirmations")?;
@ -610,6 +801,18 @@ pub fn wallet_command(
let a = arg_parse!(parse_finalize_args(&args));
command::finalize(inst_wallet(), a)
}
("invoice", Some(args)) => {
let a = arg_parse!(parse_issue_invoice_args(&args));
command::issue_invoice_tx(inst_wallet(), a)
}
("pay", Some(args)) => {
let a = arg_parse!(parse_process_invoice_args(&args));
command::process_invoice(
inst_wallet(),
a,
wallet_config.dark_background_color_scheme.unwrap_or(true),
)
}
("info", Some(args)) => {
let a = arg_parse!(parse_info_args(&args));
command::info(

View file

@ -480,6 +480,54 @@ mod wallet_tests {
];
execute_command(&app, test_dir, "wallet1", &client1, arg_vec)?;
// issue an invoice tx, wallet 2
let file_name = format!("{}/invoice.slate", test_dir);
let arg_vec = vec![
"grin-wallet",
"-p",
"password",
"invoice",
"-d",
&file_name,
"-g",
"Please give me your precious grins. Love, Yeast",
"65",
];
execute_command(&app, test_dir, "wallet2", &client2, arg_vec)?;
let output_file_name = format!("{}/invoice.slate.paid", test_dir);
// now pay the invoice tx, wallet 1
let arg_vec = vec![
"grin-wallet",
"-a",
"mining",
"-p",
"password",
"pay",
"-i",
&file_name,
"-d",
&output_file_name,
"-g",
"Here you go",
];
execute_command(&app, test_dir, "wallet1", &client1, arg_vec)?;
// and finalize, wallet 2
let arg_vec = vec![
"grin-wallet",
"-p",
"password",
"finalize",
"-i",
&output_file_name,
];
execute_command(&app, test_dir, "wallet2", &client2, arg_vec)?;
// bit more mining
let _ = test_framework::award_blocks_to_wallet(&chain, wallet1.clone(), 5, false);
//bh += 5;
// txs and outputs (mostly spit out for a visual in test logs)
let arg_vec = vec!["grin-wallet", "-p", "password", "-a", "mining", "txs"];
execute_command(&app, test_dir, "wallet1", &client1, arg_vec)?;
@ -501,6 +549,12 @@ mod wallet_tests {
let arg_vec = vec!["grin-wallet", "-p", "password", "-a", "mining", "outputs"];
execute_command(&app, test_dir, "wallet1", &client1, arg_vec)?;
let arg_vec = vec!["grin-wallet", "-p", "password", "txs"];
execute_command(&app, test_dir, "wallet2", &client2, arg_vec)?;
let arg_vec = vec!["grin-wallet", "-p", "password", "outputs"];
execute_command(&app, test_dir, "wallet2", &client2, arg_vec)?;
// let logging finish
thread::sleep(Duration::from_millis(200));
Ok(())

View file

@ -162,6 +162,74 @@ subcommands:
help: Fluff the transaction (ignore Dandelion relay protocol)
short: f
long: fluff
- invoice:
about: Initialize an invoice transction.
args:
- amount:
help: Number of coins to invoice with optional fraction, e.g. 12.423
index: 1
- message:
help: Optional participant message to include
short: g
long: message
takes_value: true
- slate_version:
help: Target slate version to output/send to receiver
short: v
long: slate_version
takes_value: true
- dest:
help: Name of destination slate output file
short: d
long: dest
takes_value: true
- pay:
about: Spend coins to pay the provided invoice transaction
args:
- minimum_confirmations:
help: Minimum number of confirmations required for an output to be spendable
short: c
long: min_conf
default_value: "10"
takes_value: true
- selection_strategy:
help: Coin/Output selection strategy.
short: s
long: selection
possible_values:
- all
- smallest
default_value: all
takes_value: true
- estimate_selection_strategies:
help: Estimates all possible Coin/Output selection strategies.
short: e
long: estimate-selection
- method:
help: Method for sending the processed invoice back to the invoice creator
short: m
long: method
possible_values:
- file
- http
- self
default_value: file
takes_value: true
- dest:
help: Send the transaction to the provided server (start with http://) or save as file.
short: d
long: dest
takes_value: true
- message:
help: Optional participant message to include
short: g
long: message
takes_value: true
- input:
help: Partial transaction to process, expects the invoicer's transaction file.
short: i
long: input
takes_value: true
- outputs:
about: Raw wallet output info (list of outputs)
- txs: