add contract ttl

This commit is contained in:
wiesche
2026-09-05 10:06:00 +02:00
parent 97bd1ae08d
commit fb99cd6c9b
20 changed files with 246 additions and 18 deletions
+5 -2
View File
@@ -1690,14 +1690,16 @@ pub struct ContractNewArgs {
pub make_outputs: Option<Vec<u64>>,
/// Minimum number of confirmations required for an input
pub minimum_confirmations: u64,
/// Blocks until wallets should stop signing the contract
pub ttl_blocks: Option<u64>,
/// Select and lock outputs early
pub add_outputs: bool,
// Future features
/// Custom fee contribution
pub fee_rate: Option<u32>,
/// Save slatepack to a specific filename
pub outfile: Option<String>,
/// Select outputs early
pub add_outputs: bool,
}
impl ContractNewArgs {
@@ -1720,6 +1722,7 @@ impl ContractNewArgs {
fn to_api_args(&self) -> Result<ContractNewArgsAPI, Error> {
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()),
+10
View File
@@ -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()]),
+17 -3
View File
@@ -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(
+2
View File
@@ -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()
@@ -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 {
+55
View File
@@ -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));
+4 -1
View File
@@ -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
+14 -7
View File
@@ -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
+2 -1
View File
@@ -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
+25 -1
View File
@@ -32,6 +32,7 @@ pub fn new<C, K>(
w: &mut WalletBackend<C, K>,
keychain_mask: Option<&SecretKey>,
setup_args: &ContractSetupArgsAPI,
ttl_blocks: Option<u64>,
slate_id: Option<Uuid>,
) -> Result<Slate, Error>
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<C, K>(
w: &mut WalletBackend<C, K>,
keychain_mask: Option<&SecretKey>,
setup_args: &ContractSetupArgsAPI,
ttl_blocks: Option<u64>,
slate_id: Option<Uuid>,
) -> 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().
+1
View File
@@ -138,6 +138,7 @@ where
},
proof_args: Default::default(),
},
None,
self_spend_id,
)?;
let finished_slate = sign(
+15 -2
View File
@@ -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<C, K>(w: &mut WalletBackend<C, K>, 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<C, K>(
w: &mut WalletBackend<C, K>,
@@ -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()
+7
View File
@@ -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()) {
+4
View File
@@ -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()?
+30
View File
@@ -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<u64>,
}
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::<ContractNewArgsAPI>(json.clone())
.unwrap()
.ttl_blocks,
Some(20)
);
json.as_object_mut().unwrap().remove("ttl_blocks");
assert_eq!(
serde_json::from_value::<ContractNewArgsAPI>(json)
.unwrap()
.ttl_blocks,
None
);
}
#[test]
fn own_commitment_status_json() {
for (status, value) in [
+12
View File
@@ -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<u64>, 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()
+3
View File
@@ -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<u32>,
/// Contract signing deadline copied from the slate
pub contract_ttl_cutoff_height: Option<u64>,
}
impl Context {
@@ -454,6 +456,7 @@ impl Context {
calculated_excess: None,
setup_args: None,
log_id: None,
contract_ttl_cutoff_height: None,
}
}
}
+5
View File
@@ -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:
+13
View File
@@ -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,
+21 -1
View File
@@ -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()