From 6d6e19832c1c774bba64653ca9c7271edf8ec66a Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 28 Jul 2026 11:13:53 -0400 Subject: [PATCH] Contracts: wire up the contract view command contract view was in the CLI help but the command returned Not implemented, and there was no owner API entry point for the existing libwallet implementation. Add owner::contract_view through to the CLI and the owner RPC, with a display for the result. The slate is taken from --input or prompted for, as receive does. is_executed was hardcoded to false. Derive it from our own tx log entry for the slate, which is confirmed once the contract has executed. --- api/src/owner.rs | 13 +- api/src/owner_rpc.rs | 12 +- controller/src/command.rs | 36 ++++++ controller/src/display.rs | 30 ++++- libwallet/src/api_impl/owner.rs | 18 ++- libwallet/src/contract/actions/view.rs | 8 +- src/bin/grin-wallet.yml | 6 + src/cmd/wallet_args.rs | 34 ++++- tests/cmd_line_contract.rs | 167 +++++++++++++++++++++++++ 9 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 tests/cmd_line_contract.rs diff --git a/api/src/owner.rs b/api/src/owner.rs index ec7a9e1..cf333f5 100644 --- a/api/src/owner.rs +++ b/api/src/owner.rs @@ -25,7 +25,7 @@ use crate::libwallet::api_impl::types::update_tx_slate_state; use crate::libwallet::api_impl::{owner, owner_updater}; use crate::libwallet::contract::proofs::InvoiceProof; use crate::libwallet::contract::types::{ - ContractNewArgsAPI, ContractRevokeArgsAPI, ContractSetupArgsAPI, + ContractNewArgsAPI, ContractRevokeArgsAPI, ContractSetupArgsAPI, ContractView, }; use crate::libwallet::{ AcctPathMapping, BuiltOutput, Error, InitTxArgs, IssueInvoiceTxArgs, NodeClient, @@ -815,6 +815,17 @@ where owner::contract_new(w, keychain_mask, args) } + /// Summarise a contract slate, including its participants, signatures and net change. + pub fn contract_view( + &self, + keychain_mask: Option<&SecretKey>, + slate: &Slate, + ) -> Result { + let mut w_lock = self.wallet_inst.lock(); + let w = w_lock.lc_provider()?.wallet_inst()?; + owner::contract_view(w, keychain_mask, slate) + } + /// Sign a contract, running setup first if it has not been done yet. pub fn contract_sign( &self, diff --git a/api/src/owner_rpc.rs b/api/src/owner_rpc.rs index 288d38e..461a016 100644 --- a/api/src/owner_rpc.rs +++ b/api/src/owner_rpc.rs @@ -22,7 +22,7 @@ use crate::core::core::OutputFeatures; use crate::core::global; use crate::keychain::{Identifier, Keychain}; use crate::libwallet::contract::types::{ - ContractNewArgsAPI, ContractRevokeArgsAPI, ContractSetupArgsAPI, + ContractNewArgsAPI, ContractRevokeArgsAPI, ContractSetupArgsAPI, ContractView, }; use crate::libwallet::{ mwixnet::MixnetReqCreationParams, AcctPathMapping, Amount, BuiltOutput, Error, InitTxArgs, @@ -547,6 +547,12 @@ pub trait OwnerRpc { TODO: Full docs once API has stabilised */ + fn contract_view(&self, token: Token, slate: VersionedSlate) -> Result; + + /** + TODO: Full docs once API has stabilised + */ + fn contract_revoke( &self, token: Token, @@ -2187,6 +2193,10 @@ where VersionedSlate::into_version(slate, version) } + fn contract_view(&self, token: Token, slate: VersionedSlate) -> Result { + Owner::contract_view(self, (&token.keychain_mask).as_ref(), &Slate::from(slate)) + } + fn contract_revoke( &self, token: Token, diff --git a/controller/src/command.rs b/controller/src/command.rs index ac65b3b..ca98c1e 100644 --- a/controller/src/command.rs +++ b/controller/src/command.rs @@ -1814,6 +1814,42 @@ where Ok(()) } +#[derive(Clone)] +pub struct ContractViewArgs { + /// Slatepack file to read the contract from + pub input_file: Option, + /// Slatepack message to read the contract from + pub input_slatepack_message: Option, +} + +pub fn contract_view( + owner_api: &mut Owner, + keychain_mask: Option<&SecretKey>, + args: ContractViewArgs, +) -> Result<(), Error> +where + L: WalletLCProvider<'static, C, K>, + C: NodeClient + 'static, + K: keychain::Keychain + 'static, +{ + let (slate, _) = parse_slatepack( + owner_api, + keychain_mask, + args.input_file, + args.input_slatepack_message, + )?; + + let wallet_inst = owner_api.wallet_inst.clone(); + let config_path = owner_api.config_path(); + controller::owner_single_use(wallet_inst, keychain_mask, config_path, |api, m| { + let view = api.contract_view(m, &slate)?; + display::contract_view(&slate, &view); + Ok(()) + })?; + + Ok(()) +} + #[derive(Clone)] pub struct ContractRevokeArgs { /// Id of a transaction we want to cancel diff --git a/controller/src/display.rs b/controller/src/display.rs index e5f2f88..05e2ce0 100644 --- a/controller/src/display.rs +++ b/controller/src/display.rs @@ -15,8 +15,10 @@ use crate::core::core::FeeFields; use crate::core::core::{self, amount_to_hr_string}; use crate::core::global; +use crate::libwallet::contract::types::ContractView; use crate::libwallet::{ - AcctPathMapping, Error, OutputCommitMapping, OutputStatus, TxLogEntry, ViewWallet, WalletInfo, + AcctPathMapping, Error, OutputCommitMapping, OutputStatus, Slate, TxLogEntry, ViewWallet, + WalletInfo, }; use crate::util::ToHex; use grin_wallet_util::OnionV3Address; @@ -634,3 +636,29 @@ pub fn payment_proof(tx: &TxLogEntry) -> Result<(), Error> { Ok(()) } + +/// Display a summary of a contract slate +pub fn contract_view(slate: &Slate, view: &ContractView) { + println!("\n____ Contract ____\n"); + let mut table = table!(); + + table.add_row(row![bFC->"Slate Id", bGC->slate.id]); + table.add_row(row![bFC->"State", bGC->slate.state]); + table.add_row(row![bFC->"Participants", bGC->view.num_participants]); + table.add_row(row![bFC->"Signatures", bGC->view.num_sigs]); + let suggested = match view.suggested_net_change { + Some(v) => amount_to_hr_string(v.unsigned_abs(), false), + None => String::from("None"), + }; + table.add_row(row![bFC->"Suggested Net Change", bGC->suggested]); + let agreed = match view.agreed_net_change { + Some(v) => amount_to_hr_string(v.unsigned_abs(), false), + None => String::from("None"), + }; + table.add_row(row![bFC->"Agreed Net Change", bGC->agreed]); + table.add_row(row![bFC->"Executed", bGC->view.is_executed]); + + table.set_format(*prettytable::format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR); + table.printstd(); + println!(); +} diff --git a/libwallet/src/api_impl/owner.rs b/libwallet/src/api_impl/owner.rs index 7700891..0241de2 100644 --- a/libwallet/src/api_impl/owner.rs +++ b/libwallet/src/api_impl/owner.rs @@ -29,7 +29,9 @@ use crate::grin_util::{Mutex, ToHex}; use crate::util::OnionV3Address; use crate::api_impl::owner_updater::StatusMessage; -use crate::contract::types::{ContractNewArgsAPI, ContractRevokeArgsAPI, ContractSetupArgsAPI}; +use crate::contract::types::{ + ContractNewArgsAPI, ContractRevokeArgsAPI, ContractSetupArgsAPI, ContractView, +}; use crate::grin_keychain::{BlindingFactor, Identifier, Keychain, SwitchCommitmentType}; use crate::internal::{keys, scan, selection, tx, updater}; @@ -1514,6 +1516,20 @@ where contract::new(&mut *w, keychain_mask, &args.setup_args, None) } +/// View a transaction contract +pub fn contract_view( + w: &mut WalletBackend, + keychain_mask: Option<&SecretKey>, + slate: &Slate, +) -> Result +where + C: NodeClient, + K: Keychain, +{ + let mut slate = slate.clone(); + contract::view(&mut *w, keychain_mask, &mut slate, "") +} + /// Sign transaction contract pub fn contract_sign( w: &mut WalletBackend, diff --git a/libwallet/src/contract/actions/view.rs b/libwallet/src/contract/actions/view.rs index 1c3ff60..72908f7 100644 --- a/libwallet/src/contract/actions/view.rs +++ b/libwallet/src/contract/actions/view.rs @@ -19,6 +19,7 @@ use crate::contract::types::ContractView; use crate::error::Error; use crate::grin_keychain::Keychain; use crate::grin_util::secp::key::SecretKey; +use crate::internal::updater; use crate::slate::{Slate, SlateState}; use crate::types::NodeClient; @@ -61,7 +62,12 @@ where })?), _ => None, }; - let is_executed = false; + // A contract is executed once the transaction it produced has confirmed. That is + // recorded on our own tx log entry for this slate, so no chain lookup is needed; a + // slate we have never signed simply has no entry and is not executed. + let is_executed = updater::retrieve_txs(w, None, Some(slate.id), None, None, false)? + .iter() + .any(|tx| tx.confirmed); // Count signatures present (a participant is "complete" once it has a partial sig). let num_sigs = slate .participant_data diff --git a/src/bin/grin-wallet.yml b/src/bin/grin-wallet.yml index 56c9559..98c24c9 100644 --- a/src/bin/grin-wallet.yml +++ b/src/bin/grin-wallet.yml @@ -541,6 +541,12 @@ subcommands: takes_value: false - view: about: View a contract + args: + - input: + help: Slatepack file containing the contract to view. + short: i + long: input + takes_value: true - revoke: about: Attempt to revoke a contract args: diff --git a/src/cmd/wallet_args.rs b/src/cmd/wallet_args.rs index a65b24f..7be382d 100644 --- a/src/cmd/wallet_args.rs +++ b/src/cmd/wallet_args.rs @@ -1140,6 +1140,35 @@ pub fn parse_contract_setup_args( }) } +pub fn parse_contract_view_args( + args: &ArgMatches, +) -> Result { + let input_file = match args.is_present("input") { + true => { + let file = parse_required(args, "input")?.to_owned(); + if !Path::new(&file).is_file() { + return Err(ParseError::ArgumentError(format!( + "File {} not found.", + &file + ))); + } + Some(file) + } + false => None, + }; + + // As for receive, prompt for the slatepack when no file was given + let mut input_slatepack_message = None; + if input_file.is_none() { + input_slatepack_message = Some(prompt_slatepack()?); + } + + Ok(command::ContractViewArgs { + input_file, + input_slatepack_message, + }) +} + pub fn parse_contract_revoke_args( args: &ArgMatches, ) -> Result { @@ -1489,8 +1518,9 @@ where let broadcast_tx = !sign_args.is_present("no-broadcast"); command::contract_sign(owner_api, km, setup_args, broadcast_tx) } - ("view", Some(_view_args)) => { - Err(Error::ArgumentError(String::from("Not implemented")).into()) + ("view", Some(view_args)) => { + let a = arg_parse!(parse_contract_view_args(&view_args)); + command::contract_view(owner_api, km, a) } ("revoke", Some(revoke_args)) => { let a = arg_parse!(parse_contract_revoke_args(&revoke_args)); diff --git a/tests/cmd_line_contract.rs b/tests/cmd_line_contract.rs new file mode 100644 index 0000000..67c5caf --- /dev/null +++ b/tests/cmd_line_contract.rs @@ -0,0 +1,167 @@ +// Copyright 2022 The Grin Developers +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Test the contract command line works as expected +#[macro_use] +extern crate clap; + +#[macro_use] +extern crate log; + +extern crate grin_wallet; + +use grin_wallet_impls::test_framework::{self, LocalWalletClient, WalletProxy}; + +use clap::App; +use std::thread; +use std::time::Duration; + +use grin_keychain::ExtKeychain; +use grin_wallet_impls::DefaultLCProvider; + +mod common; +use common::{clean_output_dir, execute_command, initial_setup_wallet, instantiate_wallet, setup}; + +/// Return the single slatepack file a wallet has written +fn only_slatepack(test_dir: &str, wallet_name: &str) -> String { + let dir = format!("{}/{}/slatepack", test_dir, wallet_name); + let mut found: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("no slatepack dir {}: {}", dir, e)) + .filter_map(|e| e.ok()) + .map(|e| e.path().to_string_lossy().to_string()) + .filter(|p| p.ends_with(".slatepack")) + .collect(); + assert_eq!(found.len(), 1, "expected one slatepack in {}", dir); + found.pop().unwrap() +} + +fn contract_command_test_impl(test_dir: &str) -> Result<(), grin_wallet_controller::Error> { + setup(test_dir); + let mut wallet_proxy: WalletProxy< + DefaultLCProvider, + LocalWalletClient, + ExtKeychain, + > = WalletProxy::new(test_dir); + let chain = wallet_proxy.chain.clone(); + + let yml = load_yaml!("../src/bin/grin-wallet.yml"); + let app = App::from_yaml(yml); + + // Two wallets, as a contract has two parties + let arg_vec = vec!["grin-wallet", "-p", "password1", "init", "-h"]; + let client1 = LocalWalletClient::new("wallet1", wallet_proxy.tx.clone()); + execute_command(&app, test_dir, "wallet1", &client1, arg_vec)?; + let config1 = initial_setup_wallet(test_dir, "wallet1"); + let (wallet1, mask1_i) = instantiate_wallet( + config1.clone().members.wallet, + client1.clone(), + "password1", + "default", + )?; + wallet_proxy.add_wallet( + "wallet1", + client1.get_send_instance(), + wallet1.clone(), + mask1_i.clone(), + ); + + let arg_vec = vec!["grin-wallet", "-p", "password2", "init", "-h"]; + let client2 = LocalWalletClient::new("wallet2", wallet_proxy.tx.clone()); + execute_command(&app, test_dir, "wallet2", &client2, arg_vec)?; + let config2 = initial_setup_wallet(test_dir, "wallet2"); + let (wallet2, mask2_i) = instantiate_wallet( + config2.clone().members.wallet, + client2.clone(), + "password2", + "default", + )?; + wallet_proxy.add_wallet( + "wallet2", + client2.get_send_instance(), + wallet2.clone(), + mask2_i.clone(), + ); + + thread::spawn(move || { + if let Err(e) = wallet_proxy.run() { + error!("Wallet Proxy error: {}", e); + } + }); + + // Mine into wallet 1 so it has something to send + let mask1 = (&mask1_i).as_ref(); + let _ = test_framework::award_blocks_to_wallet(&chain, wallet1.clone(), mask1, 5, false); + + // Wallet 1 opens a contract sending 1 grin, leaving an unencrypted slatepack because + // no --encrypt-for was given + let arg_vec = vec![ + "grin-wallet", + "-p", + "password1", + "contract", + "new", + "--send", + "1", + ]; + execute_command(&app, test_dir, "wallet1", &client1, arg_vec)?; + + // Wallet 2 views the contract before signing it + let file_name = only_slatepack(test_dir, "wallet1"); + let arg_vec = vec![ + "grin-wallet", + "-p", + "password2", + "contract", + "view", + "-i", + &file_name, + ]; + execute_command(&app, test_dir, "wallet2", &client2, arg_vec)?; + + // Wallet 1 can view its own contract too + let arg_vec = vec![ + "grin-wallet", + "-p", + "password1", + "contract", + "view", + "-i", + &file_name, + ]; + execute_command(&app, test_dir, "wallet1", &client1, arg_vec)?; + + // A file that isn't there is reported, not panicked on + let arg_vec = vec![ + "grin-wallet", + "-p", + "password1", + "contract", + "view", + "-i", + "no/such/file.slatepack", + ]; + assert!(execute_command(&app, test_dir, "wallet1", &client1, arg_vec).is_err()); + + // let logging finish + thread::sleep(Duration::from_millis(200)); + Ok(()) +} + +#[test] +fn wallet_contract_command_line() { + let test_dir = "target/test_output/contract_command_line"; + if let Err(e) = contract_command_test_impl(test_dir) { + panic!("Libwallet Error: {}", e); + } + clean_output_dir(test_dir); +}