diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 55e093acc..907fbe5d7 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1455,30 +1455,12 @@ Sign, Simulate, and Send transactions ###### **Subcommands:** -* `simulate` — Simulate a transaction envelope from stdin * `hash` — Calculate the hash of a transaction envelope from stdin -* `sign` — Sign a transaction envelope appending the signature to the envelope -* `send` — Send a transaction envelope to the network * `new` — Create a new transaction - - - -## `stellar tx simulate` - -Simulate a transaction envelope from stdin - -**Usage:** `stellar tx simulate [OPTIONS] --source-account ` - -###### **Options:** - -* `--rpc-url ` — RPC server endpoint -* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider -* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server -* `--network ` — Name of network to use from config -* `--source-account ` — Account that where transaction originates from. Alias `source`. Can be an identity (--source alice), a public key (--source GDKW...), a muxed account (--source MDA…), a secret key (--source SC36…), or a seed phrase (--source "kite urban…"). If `--build-only` or `--sim-only` flags were NOT provided, this key will also be used to sign the final transaction. In that case, trying to sign with public key will fail -* `--hd-path ` — If using a seed phrase, which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` -* `--global` — Use global config -* `--config-dir ` — Location of config directory, default is "." +* `send` — Send a transaction envelope to the network +* `set` — Set various options for a transaction +* `simulate` — Simulate a transaction envelope from stdin +* `sign` — Sign a transaction envelope appending the signature to the envelope @@ -1497,43 +1479,6 @@ Calculate the hash of a transaction envelope from stdin -## `stellar tx sign` - -Sign a transaction envelope appending the signature to the envelope - -**Usage:** `stellar tx sign [OPTIONS]` - -###### **Options:** - -* `--sign-with-key ` — Sign with a local key. Can be an identity (--sign-with-key alice), a secret key (--sign-with-key SC36…), or a seed phrase (--sign-with-key "kite urban…"). If using seed phrase, `--hd-path` defaults to the `0` path -* `--hd-path ` — If using a seed phrase to sign, sets which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` -* `--sign-with-lab` — Sign with https://lab.stellar.org -* `--rpc-url ` — RPC server endpoint -* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider -* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server -* `--network ` — Name of network to use from config -* `--global` — Use global config -* `--config-dir ` — Location of config directory, default is "." - - - -## `stellar tx send` - -Send a transaction envelope to the network - -**Usage:** `stellar tx send [OPTIONS]` - -###### **Options:** - -* `--rpc-url ` — RPC server endpoint -* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider -* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server -* `--network ` — Name of network to use from config -* `--global` — Use global config -* `--config-dir ` — Location of config directory, default is "." - - - ## `stellar tx new` Create a new transaction @@ -1802,6 +1747,89 @@ Allows issuing account to configure authorization and trustline flags to an asse +## `stellar tx send` + +Send a transaction envelope to the network + +**Usage:** `stellar tx send [OPTIONS]` + +###### **Options:** + +* `--rpc-url ` — RPC server endpoint +* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider +* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +* `--network ` — Name of network to use from config +* `--global` — Use global config +* `--config-dir ` — Location of config directory, default is "." + + + +## `stellar tx set` + +Set various options for a transaction + +**Usage:** `stellar tx set [OPTIONS]` + +###### **Options:** + +* `--sequence-number ` — Set the transactions sequence number +* `--fee ` — Set the transactions fee +* `--memo-text ` — Set the transactions memo text +* `--memo-id ` — Set the transactions memo id +* `--memo-hash ` — Set the transactions memo hash +* `--memo-return ` — Set the transactions memo return +* `--source-account ` — Change the source account for the transaction +* `--max-time-bound ` — Set the transactions max time bound +* `--min-time-bound ` — Set the transactions min time bound +* `--min-ledger ` — Set the minimum ledger that the transaction is valid +* `--max-ledger ` — Set the max ledger that the transaction is valid. 0 or not present means to max +* `--min-seq-num ` — set mimimum sequence number +* `--min-seq-age ` +* `--min-seq-ledger-gap ` — min sequeence ledger gap +* `--extra-signers ` — Extra signers +* `--no-preconditions` — Set precondition to None + + + +## `stellar tx simulate` + +Simulate a transaction envelope from stdin + +**Usage:** `stellar tx simulate [OPTIONS] --source-account ` + +###### **Options:** + +* `--rpc-url ` — RPC server endpoint +* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider +* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +* `--network ` — Name of network to use from config +* `--source-account ` — Account that where transaction originates from. Alias `source`. Can be an identity (--source alice), a public key (--source GDKW...), a muxed account (--source MDA…), a secret key (--source SC36…), or a seed phrase (--source "kite urban…"). If `--build-only` or `--sim-only` flags were NOT provided, this key will also be used to sign the final transaction. In that case, trying to sign with public key will fail +* `--hd-path ` — If using a seed phrase, which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` +* `--global` — Use global config +* `--config-dir ` — Location of config directory, default is "." + + + +## `stellar tx sign` + +Sign a transaction envelope appending the signature to the envelope + +**Usage:** `stellar tx sign [OPTIONS]` + +###### **Options:** + +* `--sign-with-key ` — Sign with a local key. Can be an identity (--sign-with-key alice), a secret key (--sign-with-key SC36…), or a seed phrase (--sign-with-key "kite urban…"). If using seed phrase, `--hd-path` defaults to the `0` path +* `--hd-path ` — If using a seed phrase to sign, sets which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` +* `--sign-with-lab` — Sign with https://lab.stellar.org +* `--rpc-url ` — RPC server endpoint +* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider +* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +* `--network ` — Name of network to use from config +* `--global` — Use global config +* `--config-dir ` — Location of config directory, default is "." + + + ## `stellar xdr` Decode and encode XDR diff --git a/cmd/crates/soroban-test/tests/it/integration/hello_world.rs b/cmd/crates/soroban-test/tests/it/integration/hello_world.rs index fd38c2012..b9ed0196f 100644 --- a/cmd/crates/soroban-test/tests/it/integration/hello_world.rs +++ b/cmd/crates/soroban-test/tests/it/integration/hello_world.rs @@ -1,10 +1,9 @@ -use predicates::boolean::PredicateBooleanExt; use soroban_cli::{ commands::{ contract::{self, fetch}, txn_result::TxnResult, }, - config::{address::Address, locator, secret}, + config::{locator, secret}, }; use soroban_rpc::GetLatestLedgerResponse; use soroban_test::{AssertExt, TestEnv, LOCAL_NETWORK_PASSPHRASE}; @@ -19,7 +18,7 @@ async fn invoke_view_with_non_existent_source_account() { let sandbox = &TestEnv::new(); let id = deploy_hello(sandbox).await; let world = "world"; - let mut cmd = hello_world_cmd(&id, world); + let cmd = hello_world_cmd(&id, world); let res = sandbox.run_cmd_with(cmd, "").await.unwrap(); assert_eq!(res, TxnResult::Res(format!(r#"["Hello",{world:?}]"#))); } diff --git a/cmd/crates/soroban-test/tests/it/integration/tx.rs b/cmd/crates/soroban-test/tests/it/integration/tx.rs index c3cd2693b..195d975da 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx.rs @@ -5,6 +5,7 @@ use soroban_test::{AssertExt, TestEnv}; use crate::integration::util::{deploy_contract, DeployKind, HELLO_WORLD}; mod operations; +mod set; #[tokio::test] async fn simulate() { diff --git a/cmd/crates/soroban-test/tests/it/integration/tx/set.rs b/cmd/crates/soroban-test/tests/it/integration/tx/set.rs new file mode 100644 index 000000000..b6de7d026 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/tx/set.rs @@ -0,0 +1,34 @@ +use soroban_cli::xdr::{Limits, ReadXdr, TransactionEnvelope}; +use soroban_test::{AssertExt, TestEnv}; + +use crate::integration::util::HELLO_WORLD; + +#[tokio::test] +async fn build_simulate_sign_send() { + let sandbox = &TestEnv::new(); + let tx_base64 = sandbox + .new_assert_cmd("contract") + .arg("install") + .args([ + "--wasm", + HELLO_WORLD.path().as_os_str().to_str().unwrap(), + "--build-only", + ]) + .assert() + .success() + .stdout_as_str(); + let tx_env = TransactionEnvelope::from_xdr_base64(&tx_base64, Limits::none()).unwrap(); + // set transaction options set fee + let new_tx = sandbox + .new_assert_cmd("tx") + .arg("set") + .arg("--fee") + .arg("10000") + .write_stdin(tx_base64.as_bytes()) + .assert() + .success() + .stdout_as_str(); + let tx_env_two = TransactionEnvelope::from_xdr_base64(&new_tx, Limits::none()).unwrap(); + let tx = soroban_cli::commands::tx::xdr::unwrap_envelope_v1(tx_env_two).unwrap(); + assert_eq!(tx.fee, 10000); +} diff --git a/cmd/crates/soroban-test/tests/it/main.rs b/cmd/crates/soroban-test/tests/it/main.rs index 5a0b2a07f..b6105b375 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -6,5 +6,6 @@ mod init; #[cfg(feature = "it")] mod integration; mod plugin; +mod tx; mod util; mod version; diff --git a/cmd/crates/soroban-test/tests/it/tx.rs b/cmd/crates/soroban-test/tests/it/tx.rs new file mode 100644 index 000000000..c51060227 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/tx.rs @@ -0,0 +1,31 @@ +use soroban_cli::xdr::{Limits, ReadXdr, TransactionEnvelope}; +use soroban_test::{AssertExt, TestEnv}; + +const SOURCE: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + +#[tokio::test] +async fn build_simulate_sign_send() { + let sandbox = &TestEnv::new(); + let tx_base64 = sandbox + .new_assert_cmd("tx") + .args(["new", "payment", "--destination", SOURCE, "--amount", "222"]) + .assert() + .success() + .stdout_as_str(); + let tx_env = TransactionEnvelope::from_xdr_base64(&tx_base64, Limits::none()).unwrap(); + let tx = soroban_cli::commands::tx::xdr::unwrap_envelope_v1(tx_env).unwrap(); + assert_eq!(tx.fee, 100); + // set transaction options set fee + let new_tx = sandbox + .new_assert_cmd("tx") + .arg("set") + .arg("--fee") + .arg("10000") + .write_stdin(tx_base64.as_bytes()) + .assert() + .success() + .stdout_as_str(); + let tx_env_two = TransactionEnvelope::from_xdr_base64(&new_tx, Limits::none()).unwrap(); + let tx = soroban_cli::commands::tx::xdr::unwrap_envelope_v1(tx_env_two).unwrap(); + assert_eq!(tx.fee, 10000); +} diff --git a/cmd/soroban-cli/src/commands/tx/mod.rs b/cmd/soroban-cli/src/commands/tx/mod.rs index c0390f92e..9c5230caa 100644 --- a/cmd/soroban-cli/src/commands/tx/mod.rs +++ b/cmd/soroban-cli/src/commands/tx/mod.rs @@ -1,9 +1,11 @@ use super::global; pub mod args; + pub mod hash; pub mod new; pub mod send; +pub mod set; pub mod sign; pub mod simulate; pub mod xdr; @@ -12,17 +14,19 @@ pub use args::Args; #[derive(Debug, clap::Subcommand)] pub enum Cmd { - /// Simulate a transaction envelope from stdin - Simulate(simulate::Cmd), /// Calculate the hash of a transaction envelope from stdin Hash(hash::Cmd), - /// Sign a transaction envelope appending the signature to the envelope - Sign(sign::Cmd), - /// Send a transaction envelope to the network - Send(send::Cmd), /// Create a new transaction #[command(subcommand)] New(new::Cmd), + /// Send a transaction envelope to the network + Send(send::Cmd), + /// Set various options for a transaction + Set(set::Cmd), + /// Simulate a transaction envelope from stdin + Simulate(simulate::Cmd), + /// Sign a transaction envelope appending the signature to the envelope + Sign(sign::Cmd), } #[derive(thiserror::Error, Debug)] @@ -37,6 +41,8 @@ pub enum Error { Sign(#[from] sign::Error), #[error(transparent)] Send(#[from] send::Error), + #[error(transparent)] + Set(#[from] set::Error), } impl Cmd { @@ -47,6 +53,7 @@ impl Cmd { Cmd::New(cmd) => cmd.run(global_args).await?, Cmd::Sign(cmd) => cmd.run(global_args).await?, Cmd::Send(cmd) => cmd.run(global_args).await?, + Cmd::Set(cmd) => cmd.run(global_args)?, }; Ok(()) } diff --git a/cmd/soroban-cli/src/commands/tx/set.rs b/cmd/soroban-cli/src/commands/tx/set.rs new file mode 100644 index 000000000..0ecf05c4b --- /dev/null +++ b/cmd/soroban-cli/src/commands/tx/set.rs @@ -0,0 +1,211 @@ +use soroban_sdk::xdr::WriteXdr; + +use crate::{ + commands::global, + config::address::{self, Address}, + xdr::{self, TransactionEnvelope}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + XdrStdin(#[from] super::xdr::Error), + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error(transparent)] + Address(#[from] address::Error), + #[error("Only transaction supported")] + Unsupported, +} + +#[derive(clap::Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Set the transactions sequence number. + #[arg(long, visible_alias = "seq_num")] + pub sequence_number: Option, + /// Set the transactions fee. + #[arg(long)] + pub fee: Option, + + /// Set the transactions memo text. + #[arg( + long, + conflicts_with = "memo_id", + conflicts_with = "memo_hash", + conflicts_with = "memo_return" + )] + pub memo_text: Option>, + /// Set the transactions memo id. + #[arg( + long, + conflicts_with = "memo_text", + conflicts_with = "memo_hash", + conflicts_with = "memo_return" + )] + pub memo_id: Option, + /// Set the transactions memo hash. + #[arg( + long, + conflicts_with = "memo_text", + conflicts_with = "memo_id", + conflicts_with = "memo_return" + )] + pub memo_hash: Option, + /// Set the transactions memo return. + #[arg( + long, + conflicts_with = "memo_text", + conflicts_with = "memo_id", + conflicts_with = "memo_hash" + )] + pub memo_return: Option, + /// Change the source account for the transaction + #[arg(long, visible_alias = "source")] + pub source_account: Option
, + + // Time bounds and Preconditions + /// Set the transactions max time bound + #[arg(long)] + pub max_time_bound: Option, + /// Set the transactions min time bound + #[arg(long)] + pub min_time_bound: Option, + + /// Set the minimum ledger that the transaction is valid + #[arg(long)] + pub min_ledger: Option, + /// Set the max ledger that the transaction is valid. 0 or not present means to max + #[arg(long)] + pub max_ledger: Option, + /// set mimimum sequence number + #[arg(long)] + pub min_seq_num: Option, + // min sequence age in seconds + #[arg(long)] + pub min_seq_age: Option, + /// min sequeence ledger gap + #[arg(long)] + pub min_seq_ledger_gap: Option, + /// Extra signers + #[arg(long, num_args = 0..=2)] + pub extra_signers: Vec, + /// Set precondition to None + #[arg( + long, + conflicts_with = "extra_signers", + conflicts_with = "min_ledger", + conflicts_with = "max_ledger", + conflicts_with = "min_seq_num", + conflicts_with = "min_seq_age", + conflicts_with = "min_seq_ledger_gap", + conflicts_with = "max_time_bound", + conflicts_with = "min_time_bound" + )] + pub no_preconditions: bool, +} + +impl Cmd { + pub fn run(&self, global: &global::Args) -> Result<(), Error> { + let mut tx = super::xdr::tx_envelope_from_stdin()?; + self.update_tx_env(&mut tx, global)?; + println!("{}", tx.to_xdr_base64(xdr::Limits::none())?); + Ok(()) + } + + pub fn update_tx_env( + &self, + tx_env: &mut TransactionEnvelope, + global: &global::Args, + ) -> Result<(), Error> { + match tx_env { + TransactionEnvelope::Tx(transaction_v1_envelope) => { + if let Some(source_account) = self.source_account.as_ref() { + transaction_v1_envelope.tx.source_account = + source_account.resolve_muxed_account(&global.locator, None)?; + }; + + if let Some(seq_num) = self.sequence_number { + transaction_v1_envelope.tx.seq_num = seq_num.into(); + } + if let Some(fee) = self.fee { + transaction_v1_envelope.tx.fee = fee; + } + + if let Some(memo) = self.memo_text.as_ref() { + transaction_v1_envelope.tx.memo = xdr::Memo::Text(memo.clone()); + } + if let Some(memo) = self.memo_id { + transaction_v1_envelope.tx.memo = xdr::Memo::Id(memo); + } + if let Some(memo) = self.memo_hash.as_ref() { + transaction_v1_envelope.tx.memo = xdr::Memo::Hash(memo.clone()); + } + if let Some(memo) = self.memo_return.as_ref() { + transaction_v1_envelope.tx.memo = xdr::Memo::Return(memo.clone()); + } + if let Some(preconditions) = self.preconditions()? { + transaction_v1_envelope.tx.cond = preconditions; + } + } + TransactionEnvelope::TxV0(_) | TransactionEnvelope::TxFeeBump(_) => { + return Err(Error::Unsupported); + } + }; + Ok(()) + } + + pub fn has_preconditionv2(&self) -> bool { + self.min_ledger.is_some() + || self.max_ledger.is_some() + || self.min_seq_num.is_some() + || self.min_seq_age.is_some() + || self.min_seq_ledger_gap.is_some() + || !self.extra_signers.is_empty() + } + + pub fn preconditions(&self) -> Result, Error> { + let time_bounds = self.timebounds(); + + Ok(if self.no_preconditions { + Some(xdr::Preconditions::None) + } else if self.has_preconditionv2() { + let ledger_bounds = if self.min_ledger.is_some() || self.max_ledger.is_some() { + Some(xdr::LedgerBounds { + min_ledger: self.min_ledger.unwrap_or_default(), + max_ledger: self.max_ledger.unwrap_or_default(), + }) + } else { + None + }; + Some(xdr::Preconditions::V2(xdr::PreconditionsV2 { + ledger_bounds, + time_bounds, + min_seq_num: self.min_seq_num.map(xdr::SequenceNumber), + min_seq_age: self.min_seq_age.unwrap_or_default().into(), + min_seq_ledger_gap: self.min_seq_ledger_gap.unwrap_or_default(), + extra_signers: self.extra_signers.clone().try_into()?, + })) + } else { + None + }) + } + + pub fn timebounds(&self) -> Option { + match (self.min_time_bound, self.max_time_bound) { + (Some(min_time), Some(max_time)) => Some(crate::xdr::TimeBounds { + min_time: min_time.into(), + max_time: max_time.into(), + }), + (min, Some(max_time)) => Some(crate::xdr::TimeBounds { + min_time: min.unwrap_or_default().into(), + max_time: max_time.into(), + }), + (Some(min_time), max) => Some(crate::xdr::TimeBounds { + min_time: min_time.into(), + max_time: max.unwrap_or(u64::MAX).into(), + }), + _ => None, + } + } +} diff --git a/cmd/soroban-cli/src/commands/tx/set/tx.rs b/cmd/soroban-cli/src/commands/tx/set/tx.rs new file mode 100644 index 000000000..68f691582 --- /dev/null +++ b/cmd/soroban-cli/src/commands/tx/set/tx.rs @@ -0,0 +1,119 @@ +use crate::{ + commands::global, + xdr::{self, Transaction, TransactionEnvelope}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Only transaction supported")] + Unsupported, +} + +#[derive(clap::Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Set the transactions sequence number. + #[arg(long, visible_alias = "seq_num")] + pub sequence_number: Option, + /// Set the transactions fee. + #[arg(long)] + pub fee: Option, + /// Set the transactions max time bound + #[arg(long)] + pub max_time_bound: Option, + /// Set the transactions min time bound + #[arg(long)] + pub min_time_bound: Option, + /// Set the transactions memo text. + #[arg( + long, + conflicts_with = "memo_id", + conflicts_with = "memo_hash", + conflicts_with = "memo_return" + )] + pub memo_text: Option>, + /// Set the transactions memo id. + #[arg( + long, + conflicts_with = "memo_text", + conflicts_with = "memo_hash", + conflicts_with = "memo_return" + )] + pub memo_id: Option, + /// Set the transactions memo hash. + #[arg( + long, + conflicts_with = "memo_text", + conflicts_with = "memo_id", + conflicts_with = "memo_return" + )] + pub memo_hash: Option, + /// Set the transactions memo return. + #[arg( + long, + conflicts_with = "memo_text", + conflicts_with = "memo_id", + conflicts_with = "memo_hash" + )] + pub memo_return: Option, +} + +impl Cmd { + pub fn preconditions(&self) -> Option { + match (self.min_time_bound, self.max_time_bound) { + (Some(min_time), Some(max_time)) => Some(crate::xdr::TimeBounds { + min_time: min_time.into(), + max_time: max_time.into(), + }), + (min, Some(max_time)) => Some(crate::xdr::TimeBounds { + min_time: min.unwrap_or_default().into(), + max_time: max_time.into(), + }), + (Some(min_time), max) => Some(crate::xdr::TimeBounds { + min_time: min_time.into(), + max_time: max.unwrap_or(u64::MAX).into(), + }), + _ => None, + } + } + + pub fn run(&self, _: &global::Args, tx: &mut TransactionEnvelope) -> Result<(), Error> { + match tx { + TransactionEnvelope::Tx(transaction_v1_envelope) => { + let Transaction { + source_account, + fee, + seq_num, + cond, + memo, + operations, + ext, + } = transaction_v1_envelope.tx.clone(); + + if let Some(seq_num) = self.sequence_number { + transaction_v1_envelope.tx.seq_num = seq_num.into(); + } + if let Some(fee) = self.fee { + transaction_v1_envelope.tx.fee = fee; + } + + if let Some(memo) = self.memo_text.as_ref() { + transaction_v1_envelope.tx.memo = xdr::Memo::Text(memo.clone()); + } + if let Some(memo) = self.memo_id { + transaction_v1_envelope.tx.memo = xdr::Memo::Id(memo); + } + if let Some(memo) = self.memo_hash.as_ref() { + transaction_v1_envelope.tx.memo = xdr::Memo::Hash(memo.clone()); + } + if let Some(memo) = self.memo_return.as_ref() { + transaction_v1_envelope.tx.memo = xdr::Memo::Return(memo.clone()); + } + } + TransactionEnvelope::TxV0(_) | TransactionEnvelope::TxFeeBump(_) => { + Err(Error::Unsupported)? + } + }; + Ok(()) + } +}