diff --git a/controller/src/command.rs b/controller/src/command.rs index 7f88569..85fa7f3 100644 --- a/controller/src/command.rs +++ b/controller/src/command.rs @@ -1690,14 +1690,16 @@ pub struct ContractNewArgs { pub make_outputs: Option>, /// Minimum number of confirmations required for an input pub minimum_confirmations: u64, + /// Blocks until wallets should stop signing the contract + pub ttl_blocks: Option, + /// Select and lock outputs early + pub add_outputs: bool, // Future features /// Custom fee contribution pub fee_rate: Option, /// Save slatepack to a specific filename pub outfile: Option, - /// Select outputs early - pub add_outputs: bool, } impl ContractNewArgs { @@ -1720,6 +1722,7 @@ impl ContractNewArgs { fn to_api_args(&self) -> Result { let net_change = self.get_net_change()?; Ok(ContractNewArgsAPI { + ttl_blocks: self.ttl_blocks, setup_args: ContractSetupArgsAPI { src_acct_name: match self.src_acct_name.as_ref() { Some(v) => Some(v.to_string()), diff --git a/controller/src/display.rs b/controller/src/display.rs index 75a5486..31b8649 100644 --- a/controller/src/display.rs +++ b/controller/src/display.rs @@ -654,6 +654,11 @@ fn contract_view_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->"Encrypted for This Wallet", bGC->yes_no(was_encrypted)]); + let deadline = match slate.ttl_cutoff_height { + 0 => "None".to_string(), + height => height.to_string(), + }; + table.add_row(row![bFC->"Signing Deadline Height", bGC->deadline]); table.add_row(row![bFC->"Participants", bGC->view.num_participants]); table.add_row(row![bFC->"Signatures", bGC->view.num_sigs]); table.add_row(row![bFC->"Transfer Amount", bGC->amount_to_hr_string(slate.amount, false)]); @@ -787,6 +792,7 @@ mod tests { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); let mut slate = Slate::blank(2, false); slate.amount = 1_000_000_000; + slate.ttl_cutoff_height = 42; slate.fee_fields = FeeFields::new(0, 4_000_000).unwrap(); let input_commit = Commitment::from_vec(vec![3]); let output_commit = Commitment::from_vec(vec![4]); @@ -810,6 +816,7 @@ mod tests { let table = contract_view_table(&slate, &view, true); assert_eq!(table_value(&table, "Encrypted for This Wallet"), "Yes"); + assert_eq!(table_value(&table, "Signing Deadline Height"), "42"); assert_eq!(table_value(&table, "Transfer Amount"), "1.000000000"); assert_eq!( table_value(&table, "Current Transaction Fee"), @@ -825,6 +832,9 @@ mod tests { let table = contract_view_table(&slate, &view, false); assert_eq!(table_value(&table, "Encrypted for This Wallet"), "No"); + slate.ttl_cutoff_height = 0; + let table = contract_view_table(&slate, &view, false); + assert_eq!(table_value(&table, "Signing Deadline Height"), "None"); slate.tx = Some(Transaction::new( Inputs::CommitOnly(vec![input_commit.into()]), diff --git a/controller/tests/contract_accounts_switch.rs b/controller/tests/contract_accounts_switch.rs index 889bfb7..35c9f33 100644 --- a/controller/tests/contract_accounts_switch.rs +++ b/controller/tests/contract_accounts_switch.rs @@ -34,7 +34,7 @@ use std::path::PathBuf; /// contract accounts testing when switching between accounts during transaction building fn contract_accounts_switch_impl(test_dir: &'static str) -> Result<(), libwallet::Error> { // create two wallets with some extra accounts and don't mine anything in them - let (wallets, _chain, stopper, _bh) = create_wallets( + let (wallets, _chain, stopper, bh) = create_wallets( vec![ vec![("default", 0), ("account1", 1), ("account2", 2)], vec![("default", 0), ("account1", 3), ("account2", 4)], @@ -110,6 +110,7 @@ fn contract_accounts_switch_impl(test_dir: &'static str) -> Result<(), libwallet |api, m| { // Send wallet inititates a standard transaction with --send=5 let args = &ContractNewArgsAPI { + ttl_blocks: Some(20), setup_args: ContractSetupArgsAPI { selection_args: common::contract_selection_args(), net_change: Some(-5_000_000_000), @@ -122,6 +123,7 @@ fn contract_accounts_switch_impl(test_dir: &'static str) -> Result<(), libwallet }, )?; assert_eq!(slate.state, SlateState::Standard1); + assert_eq!(slate.ttl_cutoff_height, bh + 20); // Receiver gets their coins on account2 where they can payjoin { @@ -146,16 +148,24 @@ fn contract_accounts_switch_impl(test_dir: &'static str) -> Result<(), libwallet )?; assert_eq!(slate.state, SlateState::Standard2); - // Switch account for wallet1 to account2 and finish the transaction (should use account1 to complete) + // Switch accounts and finish the transaction (should use account1 to complete) { wallet_inst!(wallet1, w); - w.set_parent_key_id_by_name("account2")?; + w.set_parent_key_id_by_name("default")?; } wallet::controller::owner_single_use( wallet1.clone(), mask1, PathBuf::from(test_dir), |api, m| { + let (_, wallet_info) = api.retrieve_summary_info(m, false, 1)?; + assert_eq!(wallet_info.last_confirmed_height, 0); + let mut expired = slate.clone(); + expired.ttl_cutoff_height = bh; + assert!(matches!( + api.contract_sign(m, &expired, &ContractSetupArgsAPI::default()), + Err(libwallet::Error::TransactionExpired) + )); let args = &ContractSetupArgsAPI { selection_args: common::contract_selection_args(), ..Default::default() @@ -165,6 +175,10 @@ fn contract_accounts_switch_impl(test_dir: &'static str) -> Result<(), libwallet }, )?; assert_eq!(slate.state, SlateState::Standard3); + { + wallet_inst!(wallet1, w); + w.set_parent_key_id_by_name("account2")?; + } // post tx and mine a block to wallet1::account2 wallet::controller::owner_single_use( diff --git a/controller/tests/contract_rsr.rs b/controller/tests/contract_rsr.rs index b3a8127..9c1c0f3 100644 --- a/controller/tests/contract_rsr.rs +++ b/controller/tests/contract_rsr.rs @@ -60,6 +60,7 @@ fn contract_rsr_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> // Receive wallet initiates an invoice transaction through the foreign API. let args = &ContractNewArgsAPI { + ttl_blocks: None, setup_args: ContractSetupArgsAPI { selection_args: common::contract_selection_args(), net_change: Some(5_000_000_000), @@ -88,6 +89,7 @@ fn contract_rsr_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> recv_mask.cloned(), |api| { let rejected = ContractNewArgsAPI { + ttl_blocks: args.ttl_blocks, setup_args: ContractSetupArgsAPI { net_change: Some(-5_000_000_000), ..args.setup_args.clone() diff --git a/controller/tests/contract_self_spend_custom.rs b/controller/tests/contract_self_spend_custom.rs index add3ac4..6f888b2 100644 --- a/controller/tests/contract_self_spend_custom.rs +++ b/controller/tests/contract_self_spend_custom.rs @@ -279,6 +279,7 @@ fn contract_min_confirmations_impl(test_dir: &'static str) -> Result<(), libwall slate = api.contract_new( m, &ContractNewArgsAPI { + ttl_blocks: None, setup_args: ContractSetupArgsAPI { net_change: Some(-(consensus::GRIN_BASE as i64)), selection_args: OutputSelectionArgs { diff --git a/controller/tests/contract_srs.rs b/controller/tests/contract_srs.rs index 076c8ce..171fa01 100644 --- a/controller/tests/contract_srs.rs +++ b/controller/tests/contract_srs.rs @@ -70,6 +70,12 @@ fn contract_srs_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> let recv_wallet = wallets[1].0.clone(); let recv_mask = wallets[1].1.as_ref(); let participant_fee = my_fee_contribution(1, 1, 1, 2)?.fee(); + let ttl_cutoff = bh + 20; + let ttl_error = format!( + "Contract TTL changed from {} to {}", + ttl_cutoff, + ttl_cutoff + 1 + ); let mut slate = Slate::blank(0, false); // this gets overriden below @@ -80,6 +86,7 @@ fn contract_srs_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> |api, m| { // Send wallet inititates a standard transaction with --send=5 let mut args = ContractNewArgsAPI { + ttl_blocks: Some(20), setup_args: ContractSetupArgsAPI { selection_args: common::contract_selection_args(), net_change: Some(-5_000_000_000), @@ -96,11 +103,22 @@ fn contract_srs_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> assert_eq!(common::wallet_progress(api, m)?, progress); args.setup_args.num_participants = 2; + args.ttl_blocks = Some(0); + let err = api.contract_new(m, &mut args).unwrap_err(); + assert!(matches!( + err, + libwallet::Error::GenericError(ref msg) + if msg == "Contract TTL must be at least 1 block" + )); + args.ttl_blocks = Some(20); slate = api.contract_new(m, &mut args)?; + let (_, txs) = api.retrieve_txs(m, false, None, Some(slate.id), None)?; + assert_eq!(txs[0].ttl_cutoff_height, Some(ttl_cutoff)); Ok(()) }, )?; assert_eq!(slate.state, SlateState::Standard1); + assert_eq!(slate.ttl_cutoff_height, ttl_cutoff); common::assert_basic_contract_slate( &slate, common::ExpectedContractSlate { @@ -302,6 +320,7 @@ fn contract_srs_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> }, )?; assert_eq!(slate.state, SlateState::Standard2); + assert_eq!(slate.ttl_cutoff_height, ttl_cutoff); common::assert_basic_contract_slate( &slate, common::ExpectedContractSlate { @@ -487,6 +506,32 @@ fn contract_srs_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> send_mask, PathBuf::from(test_dir), |api, m| { + let progress = common::wallet_progress(api, m)?; + let mut expired = slate.clone(); + expired.ttl_cutoff_height = bh; + let err = api + .contract_sign(m, &expired, &ContractSetupArgsAPI::default()) + .unwrap_err(); + assert!(matches!(err, libwallet::Error::TransactionExpired)); + + let mut changed = slate.clone(); + changed.ttl_cutoff_height += 1; + let err = match api.contract_view(m, &changed) { + Err(err) => err, + Ok(_) => panic!("changed contract TTL accepted"), + }; + assert!(matches!( + err, + libwallet::Error::GenericError(ref msg) if msg == &ttl_error + )); + let err = api + .contract_sign(m, &changed, &ContractSetupArgsAPI::default()) + .unwrap_err(); + assert!(matches!( + err, + libwallet::Error::GenericError(ref msg) if msg == &ttl_error + )); + assert_eq!(common::wallet_progress(api, m)?, progress); let args = &mut ContractSetupArgsAPI { selection_args: common::contract_selection_args(), ..Default::default() @@ -525,6 +570,16 @@ fn contract_srs_tx_impl(test_dir: &'static str) -> Result<(), libwallet::Error> send_mask, PathBuf::from(test_dir), |api, m| { + let mut changed = slate.clone(); + changed.ttl_cutoff_height += 1; + let err = match api.contract_view(m, &changed) { + Err(err) => err, + Ok(_) => panic!("changed contract TTL accepted"), + }; + assert!(matches!( + err, + libwallet::Error::GenericError(ref msg) if msg == &ttl_error + )); let view = api.contract_view(m, &slate)?; assert_eq!(view.own_commitment_status, OwnCommitmentStatus::Unknown); assert_eq!(view.own_fee, Some(participant_fee)); diff --git a/doc/contracts.md b/doc/contracts.md index 87973df..2b85ecc 100644 --- a/doc/contracts.md +++ b/doc/contracts.md @@ -38,6 +38,9 @@ is used for the fee estimate, so any required inputs need enough confirmations a A value of `0` allows unconfirmed non-coinbase outputs. Open contracts do not reserve funds, so a late-locked contract can fail if another transaction spends the available outputs first. +`contract new --ttl_blocks N` stops contract signing after `N` blocks. Finalized transactions +can still be posted afterward. + Each side's account is fixed when it first joins the contract. Changing the active account later does not affect setup or signing; they use the account stored in the context. @@ -69,7 +72,7 @@ account selected with `--account`. the API. Only invoice proofs are supported and they require the experimental Slate V5 format. The full proposal is described in [Early Payment Proofs](https://github.com/mimblewimble/grin-rfcs/pull/70) -* Custom fee rates and contract TTLs are not supported +* Custom fee rates are not supported * There are no contract-specific history, lookup or transport commands. Payment proof memos cannot be set through the contract API or CLI. The current proof stores a memo type and 32 bytes instead of the text and hash described by the early payment proofs diff --git a/libwallet/src/api_impl/owner.rs b/libwallet/src/api_impl/owner.rs index 3f3de27..d6a3dd0 100644 --- a/libwallet/src/api_impl/owner.rs +++ b/libwallet/src/api_impl/owner.rs @@ -1383,12 +1383,13 @@ where C: NodeClient, K: Keychain, { - // Refuse if TTL is expired - let last_confirmed_height = w.last_confirmed_height()?; - if slate.ttl_cutoff_height != 0 { - if last_confirmed_height >= slate.ttl_cutoff_height { - return Err(Error::TransactionExpired); - } + check_ttl_at_height(slate, w.last_confirmed_height()?) +} + +/// Check a slate TTL against the given block height +pub(crate) fn check_ttl_at_height(slate: &Slate, height: u64) -> Result<(), Error> { + if slate.ttl_cutoff_height != 0 && height >= slate.ttl_cutoff_height { + return Err(Error::TransactionExpired); } Ok(()) } @@ -1587,7 +1588,13 @@ where C: NodeClient, K: Keychain, { - contract::new(&mut *w, keychain_mask, &args.setup_args, None) + contract::new( + &mut *w, + keychain_mask, + &args.setup_args, + args.ttl_blocks, + None, + ) } /// View a transaction contract diff --git a/libwallet/src/contract/actions/README.md b/libwallet/src/contract/actions/README.md index 5f44e25..7c33acf 100644 --- a/libwallet/src/contract/actions/README.md +++ b/libwallet/src/contract/actions/README.md @@ -41,7 +41,8 @@ Separating side effects until the 'save_step' part would make these functions mu clearer to callers. - For a payjoin, `--use-inputs any` picks an input for us; naming the commitment would be clearer, for the API as much as the CLI. - - Add custom fee rates and contract TTLs, or document them as unsupported. + - Add custom contract fee rates. + - Support `--outfile` for contract slatepacks. - Raise the replacement fee when `revoke` races a transaction already in the mempool. - Merge invoice-proof retrieval with the existing payment-proof API. - Add a Grin node API for kernel lookup by MMR index, then use the witness index instead diff --git a/libwallet/src/contract/actions/new.rs b/libwallet/src/contract/actions/new.rs index 44982a8..194cd93 100644 --- a/libwallet/src/contract/actions/new.rs +++ b/libwallet/src/contract/actions/new.rs @@ -32,6 +32,7 @@ pub fn new( w: &mut WalletBackend, keychain_mask: Option<&SecretKey>, setup_args: &ContractSetupArgsAPI, + ttl_blocks: Option, slate_id: Option, ) -> Result where @@ -40,7 +41,7 @@ where { contract::utils::verify_num_participants(setup_args.num_participants)?; // Compute state for 'new' - let (slate, context) = compute(w, keychain_mask, setup_args, slate_id)?; + let (slate, context) = compute(w, keychain_mask, setup_args, ttl_blocks, slate_id)?; // Atomically commit state contract::utils::save_step(w, keychain_mask, &slate, context, setup_args.add_outputs)?; @@ -53,6 +54,7 @@ pub fn compute( w: &mut WalletBackend, keychain_mask: Option<&SecretKey>, setup_args: &ContractSetupArgsAPI, + ttl_blocks: Option, slate_id: Option, ) -> Result<(Slate, Context), Error> where @@ -68,8 +70,30 @@ where let num_participants = setup_args.num_participants; let mut slate = Slate::blank(num_participants, net_change > 0); // Use a caller-supplied id when given, so a retried creation reuses the same context. + let mut reused_context = false; if let Some(id) = slate_id { slate.id = id; + match w.get_private_context(keychain_mask, slate.id.as_bytes()) { + Ok(context) => { + slate.ttl_cutoff_height = context.contract_ttl_cutoff_height.unwrap_or(0); + reused_context = true; + } + Err(Error::NotFoundErr(_)) => {} + Err(e) => return Err(e), + } + } + if !reused_context { + if let Some(blocks) = ttl_blocks { + if blocks == 0 { + return Err(Error::GenericError( + "Contract TTL must be at least 1 block".to_string(), + )); + } + let height = w.w2n_client().get_chain_tip()?.0; + slate.ttl_cutoff_height = height.checked_add(blocks).ok_or_else(|| { + Error::GenericError("Contract TTL exceeds the maximum block height".to_string()) + })?; + } } // We set slate.amount to contain the _positive_ net_change for the other party so they can derive expectations. // unsigned_abs avoids the i64::MIN overflow panic of abs(). diff --git a/libwallet/src/contract/actions/revoke.rs b/libwallet/src/contract/actions/revoke.rs index cb39b56..3c59c8d 100644 --- a/libwallet/src/contract/actions/revoke.rs +++ b/libwallet/src/contract/actions/revoke.rs @@ -138,6 +138,7 @@ where }, proof_args: Default::default(), }, + None, self_spend_id, )?; let finished_slate = sign( diff --git a/libwallet/src/contract/actions/setup.rs b/libwallet/src/contract/actions/setup.rs index cfd8656..085bae8 100644 --- a/libwallet/src/contract/actions/setup.rs +++ b/libwallet/src/contract/actions/setup.rs @@ -14,7 +14,7 @@ //! Implementation of contract setup -use crate::api_impl::owner::check_ttl; +use crate::api_impl::owner::check_ttl_at_height; use crate::backend::WalletBackend; use crate::contract; use crate::contract::types::ContractSetupArgsAPI; @@ -24,6 +24,18 @@ use crate::grin_util::secp::key::SecretKey; use crate::slate::Slate; use crate::types::{Context, NodeClient}; +// Contract deadlines use the node tip because the active account may not own the context +fn check_contract_ttl(w: &mut WalletBackend, slate: &Slate) -> Result<(), Error> +where + C: NodeClient, + K: Keychain, +{ + if slate.ttl_cutoff_height == 0 { + return Ok(()); + } + check_ttl_at_height(slate, w.w2n_client().get_chain_tip()?.0) +} + /// Perform a contract setup pub fn setup( w: &mut WalletBackend, @@ -64,10 +76,11 @@ where K: Keychain, { let mut sl = slate.clone(); - check_ttl(w, &sl)?; + check_contract_ttl(w, &sl)?; // Get or create the Context and check the setup arguments let mut context = contract::context::get_or_create(w, keychain_mask, &mut sl, setup_args)?; + contract::utils::verify_ttl(context.contract_ttl_cutoff_height, &sl)?; let context_args = context .setup_args .as_ref() diff --git a/libwallet/src/contract/actions/view.rs b/libwallet/src/contract/actions/view.rs index b5a3f59..8c1d51f 100644 --- a/libwallet/src/contract/actions/view.rs +++ b/libwallet/src/contract/actions/view.rs @@ -92,6 +92,13 @@ where .find(|tx| contract::utils::is_signed_tx(tx, slate.id)); let has_signed = signed_tx.is_some(); let tx = signed_tx.or_else(|| txs.first()); + let stored_ttl = context + .as_ref() + .map(|context| context.contract_ttl_cutoff_height) + .or_else(|| tx.map(|tx| tx.ttl_cutoff_height)); + if let Some(expected) = stored_ttl { + contract::utils::verify_ttl(expected, slate)?; + } // Once we have signed, the context identifies the commitments we added. If it has // already been removed, the slate no longer carries enough information to do that. let own_commitment_status = if slate.tx.is_none() || (context.is_none() && !txs.is_empty()) { diff --git a/libwallet/src/contract/context.rs b/libwallet/src/contract/context.rs index 6a52245..c8e6a28 100644 --- a/libwallet/src/contract/context.rs +++ b/libwallet/src/contract/context.rs @@ -119,6 +119,10 @@ where // Store the full setup args on the context; later steps (add_outputs) read the // selection arguments from here. context.setup_args = Some(setup_args.clone()); + context.contract_ttl_cutoff_height = match slate.ttl_cutoff_height { + 0 => None, + height => Some(height), + }; debug!( "Setting Context.net_change as: {}", context.get_net_change()? diff --git a/libwallet/src/contract/types.rs b/libwallet/src/contract/types.rs index a0f7689..74a3777 100644 --- a/libwallet/src/contract/types.rs +++ b/libwallet/src/contract/types.rs @@ -15,6 +15,7 @@ //! Types related to a contract use crate::error::Error; +use crate::grin_core::libtx::secp_ser; pub use crate::slate::PaymentProofType as ProofType; use crate::slate_versions::ser as dalek_ser; use ed25519_dalek::VerifyingKey as DalekPublicKey; @@ -154,11 +155,16 @@ impl Default for ContractSetupArgsAPI { pub struct ContractNewArgsAPI { /// Setup args - contract new also initiates the setup by default pub setup_args: ContractSetupArgsAPI, + /// Blocks until wallets should stop signing the contract + /// Only used when creating a new contract context + #[serde(with = "secp_ser::opt_string_or_u64", default)] + pub ttl_blocks: Option, } impl Default for ContractNewArgsAPI { fn default() -> ContractNewArgsAPI { ContractNewArgsAPI { + ttl_blocks: None, setup_args: ContractSetupArgsAPI { src_acct_name: None, net_change: None, @@ -265,6 +271,30 @@ mod tests { ); } + #[test] + fn contract_ttl_json() { + let mut args = ContractNewArgsAPI::default(); + args.ttl_blocks = Some(20); + let mut json = serde_json::to_value(&args).unwrap(); + assert_eq!(json["ttl_blocks"], "20"); + + json["ttl_blocks"] = serde_json::json!(20); + assert_eq!( + serde_json::from_value::(json.clone()) + .unwrap() + .ttl_blocks, + Some(20) + ); + + json.as_object_mut().unwrap().remove("ttl_blocks"); + assert_eq!( + serde_json::from_value::(json) + .unwrap() + .ttl_blocks, + None + ); + } + #[test] fn own_commitment_status_json() { for (status, value) in [ diff --git a/libwallet/src/contract/utils.rs b/libwallet/src/contract/utils.rs index c18c0b0..a089bbc 100644 --- a/libwallet/src/contract/utils.rs +++ b/libwallet/src/contract/utils.rs @@ -375,6 +375,18 @@ pub(super) fn verify_num_participants(num_participants: u8) -> Result<(), Error> Ok(()) } +/// Ensure the slate still carries the stored contract deadline +pub(super) fn verify_ttl(expected: Option, slate: &Slate) -> Result<(), Error> { + let expected = expected.unwrap_or(0); + if expected != slate.ttl_cutoff_height { + return Err(Error::GenericError(format!( + "Contract TTL changed from {} to {}", + expected, slate.ttl_cutoff_height + ))); + } + Ok(()) +} + /// Whether a transaction log entry shows that this wallet signed the slate. pub(super) fn is_signed_tx(tx: &TxLogEntry, slate_id: Uuid) -> bool { tx.tx_slate_id == Some(slate_id) && tx.kernel_excess.is_some() diff --git a/libwallet/src/types.rs b/libwallet/src/types.rs index 23cf08a..0ec0330 100644 --- a/libwallet/src/types.rs +++ b/libwallet/src/types.rs @@ -403,6 +403,8 @@ pub struct Context { /// history might need to search through a list to update a txlogentry, so we /// keep the id in the context. pub log_id: Option, + /// Contract signing deadline copied from the slate + pub contract_ttl_cutoff_height: Option, } impl Context { @@ -454,6 +456,7 @@ impl Context { calculated_excess: None, setup_args: None, log_id: None, + contract_ttl_cutoff_height: None, } } } diff --git a/src/bin/grin-wallet.yml b/src/bin/grin-wallet.yml index f8cc363..3623814 100644 --- a/src/bin/grin-wallet.yml +++ b/src/bin/grin-wallet.yml @@ -503,6 +503,11 @@ subcommands: long: min_conf default_value: "10" takes_value: true + - ttl_blocks: + help: Positive number of blocks until wallets stop signing the contract + short: b + long: ttl_blocks + takes_value: true - sign: about: Sign a contract args: diff --git a/src/cmd/wallet_args.rs b/src/cmd/wallet_args.rs index 664c854..6644e8c 100644 --- a/src/cmd/wallet_args.rs +++ b/src/cmd/wallet_args.rs @@ -1032,6 +1032,18 @@ pub fn parse_contract_new_args( parse_required(args, "minimum_confirmations")?, "minimum_confirmations", )?; + let ttl_blocks = match args.value_of("ttl_blocks") { + Some(value) => { + let blocks = parse_u64(value, "ttl_blocks")?; + if blocks == 0 { + return Err(ParseError::ArgumentError( + "Contract TTL must be at least 1 block".to_string(), + )); + } + Some(blocks) + } + None => None, + }; let as_json = args.is_present("as-json"); let no_payjoin = args.is_present("no-payjoin"); let use_inputs = match args.value_of("use-inputs") { @@ -1087,6 +1099,7 @@ pub fn parse_contract_new_args( use_inputs: use_inputs, make_outputs: make_outputs, minimum_confirmations, + ttl_blocks, // Not yet wired to CLI flags: fee_rate: None, outfile: None, diff --git a/tests/cmd_line_contract.rs b/tests/cmd_line_contract.rs index fe1c66e..586e05c 100644 --- a/tests/cmd_line_contract.rs +++ b/tests/cmd_line_contract.rs @@ -248,7 +248,7 @@ fn rejects_conflicting_input_options() { } #[test] -fn parses_contract_min_confirmations() { +fn parses_contract_options() { let yml = load_yaml!("../src/bin/grin-wallet.yml"); let app = App::from_yaml(yml); let account = "default".to_string(); @@ -262,6 +262,8 @@ fn parses_contract_min_confirmations() { "1", "--min_conf", "3", + "--ttl_blocks", + "20", ]) .unwrap(); let contract = args.subcommand_matches("contract").unwrap(); @@ -269,6 +271,7 @@ fn parses_contract_min_confirmations() { let parsed = grin_wallet::cmd::wallet_args::parse_contract_new_args(new_args, &account).unwrap(); assert_eq!(parsed.minimum_confirmations, 3); + assert_eq!(parsed.ttl_blocks, Some(20)); let expected = grin_wallet_libwallet::contract::types::DEFAULT_MINIMUM_CONFIRMATIONS; let args = app @@ -280,6 +283,23 @@ fn parses_contract_min_confirmations() { let parsed = grin_wallet::cmd::wallet_args::parse_contract_new_args(new_args, &account).unwrap(); assert_eq!(parsed.minimum_confirmations, expected); + assert_eq!(parsed.ttl_blocks, None); + let args = app + .clone() + .get_matches_from_safe(vec!["grin-wallet", "contract", "new", "-s", "1", "-b", "0"]) + .unwrap(); + let contract = args.subcommand_matches("contract").unwrap(); + let new_args = contract.subcommand_matches("new").unwrap(); + let err = match grin_wallet::cmd::wallet_args::parse_contract_new_args(new_args, &account) { + Err(err) => err, + Ok(_) => panic!("zero contract TTL accepted"), + }; + assert_eq!( + err, + grin_wallet::cmd::wallet_args::ParseError::ArgumentError( + "Contract TTL must be at least 1 block".to_string() + ) + ); let args = app .clone()