From 6b1313406c8dee19f10d42d0767474860c69aab1 Mon Sep 17 00:00:00 2001 From: Peter White Date: Sun, 15 Dec 2024 23:47:21 -0700 Subject: [PATCH] Test wallet integration (#383) * chore: get_payload_works * test(wallet-integration): up contract get payload works * get paylaod from server works * test(wallet-integration): retrieve upload call data works * test(wallet-integration): retrieve instantiate call data works * style: better comments * test: try higher wait times in CI * test(wallet-integration): bump sleep time * test(wallet-integration): even more sleep time * test(wallet-integration): maybe a port problem ? * revert 0075e94 * test(wallet-integration): better unit tests * test(wallet-integration): wait for wallet signature * test(wallet-integration): assert on received payload * test(wallet-integration): use tokio spawn * test(wallet-integration): add some waiting time * test(wallet-integration): use cargo run * style: nightly fmt * test(wallet-integration): 500s sleep time * test(wallet-integration): ignore some tests * test(wallet-integration): get instantiate call data * test(wallet-integration): integration tests improvements * test(wallet-integration): bump sleep time * test(wallet-integration): merge integration tests * test(wallet-integration): remove sign_call_data test * test(wallet-integration): use random free port * test(wallet-integration): define gas_limit & proof_size * fix: merge issues --------- Co-authored-by: Alejandro Martinez Andres <11448715+al3mart@users.noreply.github.com> --- Cargo.lock | 12 +- crates/pop-cli/Cargo.toml | 2 + crates/pop-cli/src/commands/up/contract.rs | 134 +++++++++++++++++- crates/pop-cli/src/wallet_integration.rs | 2 +- crates/pop-cli/tests/contract.rs | 115 ++++++++++++++- crates/pop-contracts/Cargo.toml | 1 + crates/pop-contracts/src/up.rs | 41 ++++++ crates/pop-contracts/tests/files/testing.wasm | Bin 3710 -> 0 bytes 8 files changed, 290 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba7959cd6..41b45ce71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "Inflector" @@ -2004,7 +2004,7 @@ checksum = "cd7e35aee659887cbfb97aaf227ac12cad1a9d7c71e55ff3376839ed4e282d08" [[package]] name = "contract-build" version = "5.0.1" -source = "git+https://github.com/use-ink/cargo-contract?branch=peter/chore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" +source = "git+https://github.com/use-ink/cargo-contract?branch=peter%2Fchore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" dependencies = [ "anyhow", "blake2", @@ -2044,7 +2044,7 @@ dependencies = [ [[package]] name = "contract-extrinsics" version = "5.0.1" -source = "git+https://github.com/use-ink/cargo-contract?branch=peter/chore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" +source = "git+https://github.com/use-ink/cargo-contract?branch=peter%2Fchore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" dependencies = [ "anyhow", "blake2", @@ -2076,7 +2076,7 @@ dependencies = [ [[package]] name = "contract-metadata" version = "5.0.1" -source = "git+https://github.com/use-ink/cargo-contract?branch=peter/chore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" +source = "git+https://github.com/use-ink/cargo-contract?branch=peter%2Fchore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" dependencies = [ "anyhow", "impl-serde 0.5.0", @@ -2089,7 +2089,7 @@ dependencies = [ [[package]] name = "contract-transcode" version = "5.0.1" -source = "git+https://github.com/use-ink/cargo-contract?branch=peter/chore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" +source = "git+https://github.com/use-ink/cargo-contract?branch=peter%2Fchore-make-types-pub#7c8fc481912d7a6f416a0f72e37840123064f90d" dependencies = [ "anyhow", "base58", @@ -9318,6 +9318,7 @@ dependencies = [ "clap", "cliclack", "console", + "contract-extrinsics", "dirs", "duct", "env_logger 0.11.5", @@ -9387,6 +9388,7 @@ dependencies = [ "duct", "flate2", "heck 0.5.0", + "hex", "ink_env", "mockito", "pop-common", diff --git a/crates/pop-cli/Cargo.toml b/crates/pop-cli/Cargo.toml index 2d511c77b..90ed64ca7 100644 --- a/crates/pop-cli/Cargo.toml +++ b/crates/pop-cli/Cargo.toml @@ -53,9 +53,11 @@ tower-http = { workspace = true, features = ["fs", "cors"] } [dev-dependencies] assert_cmd.workspace = true +contract-extrinsics.workspace = true predicates.workspace = true subxt.workspace = true subxt-signer.workspace = true +sp-weights.workspace = true [features] default = ["contract", "parachain", "telemetry"] diff --git a/crates/pop-cli/src/commands/up/contract.rs b/crates/pop-cli/src/commands/up/contract.rs index f2c26076f..34a06e6e4 100644 --- a/crates/pop-cli/src/commands/up/contract.rs +++ b/crates/pop-cli/src/commands/up/contract.rs @@ -355,6 +355,7 @@ impl UpContractCommand { let weight_limit = if self.gas_limit.is_some() && self.proof_size.is_some() { Weight::from_parts(self.gas_limit.unwrap(), self.proof_size.unwrap()) } else { + // Frontend will do dry run and update call data. Weight::from_parts(0, 0) }; let call_data = get_instantiate_payload(instantiate_exec, weight_limit).await?; @@ -403,6 +404,16 @@ fn display_contract_info(spinner: &ProgressBar, address: String, code_hash: Opti #[cfg(test)] mod tests { use super::*; + use pop_common::{find_free_port, set_executable_permission}; + use pop_contracts::{contracts_node_generator, mock_build_process, new_environment}; + use std::{ + env, + process::{Child, Command}, + time::Duration, + }; + use subxt::{tx::Payload, SubstrateConfig}; + use tempfile::TempDir; + use tokio::time::sleep; use url::Url; fn default_up_contract_command() -> UpContractCommand { @@ -423,6 +434,28 @@ mod tests { } } + async fn start_test_environment() -> anyhow::Result<(Child, u16, TempDir)> { + let random_port = find_free_port(); + let temp_dir = new_environment("testing")?; + let current_dir = env::current_dir().expect("Failed to get current directory"); + mock_build_process( + temp_dir.path().join("testing"), + current_dir.join("../pop-contracts/tests/files/testing.contract"), + current_dir.join("../pop-contracts/tests/files/testing.json"), + )?; + let cache = temp_dir.path().join(""); + let binary = contracts_node_generator(cache.clone(), None).await?; + binary.source(false, &(), true).await?; + set_executable_permission(binary.path())?; + let process = run_contracts_node(binary.path(), None, random_port).await?; + Ok((process, random_port, temp_dir)) + } + + fn stop_test_environment(id: &str) -> anyhow::Result<()> { + Command::new("kill").args(["-s", "TERM", id]).spawn()?.wait()?; + Ok(()) + } + #[test] fn conversion_up_contract_command_to_up_opts_works() -> anyhow::Result<()> { let command = default_up_contract_command(); @@ -444,15 +477,106 @@ mod tests { Ok(()) } + #[tokio::test] async fn get_upload_call_data_works() -> anyhow::Result<()> { - todo!() + let (contracts_node_process, port, temp_dir) = start_test_environment().await?; + let localhost_url = format!("ws://127.0.0.1:{}", port); + sleep(Duration::from_secs(20)).await; + + let up_contract_opts = UpContractCommand { + path: Some(temp_dir.path().join("testing")), + constructor: "new".to_string(), + args: vec![], + value: "0".to_string(), + gas_limit: None, + proof_size: None, + salt: None, + url: Url::parse(&localhost_url).expect("given url is valid"), + suri: "//Alice".to_string(), + dry_run: false, + upload_only: true, + skip_confirm: true, + use_wallet: true, + }; + + let rpc_client = subxt::backend::rpc::RpcClient::from_url(&up_contract_opts.url).await?; + let client = subxt::OnlineClient::::from_rpc_client(rpc_client).await?; + + // Retrieve call data based on the above command options. + let (retrieved_call_data, _) = match up_contract_opts.get_contract_data().await { + Ok(data) => data, + Err(e) => { + error(format!("An error occurred getting the call data: {e}"))?; + return Err(e); + }, + }; + // We have retrieved some payload. + assert!(!retrieved_call_data.is_empty()); + + // Craft encoded call data for an upload code call. + let contract_code = get_contract_code(up_contract_opts.path.as_ref()).await?; + let storage_deposit_limit: Option = None; + let upload_code = contract_extrinsics::extrinsic_calls::UploadCode::new( + contract_code, + storage_deposit_limit, + contract_extrinsics::upload::Determinism::Enforced, + ); + let expected_call_data = upload_code.build(); + let mut encoded_expected_call_data = Vec::::new(); + expected_call_data + .encode_call_data_to(&client.metadata(), &mut encoded_expected_call_data)?; + + // Retrieved call data and calculated match. + assert_eq!(retrieved_call_data, encoded_expected_call_data); + + // Stop running contracts-node + stop_test_environment(&contracts_node_process.id().to_string())?; + Ok(()) } + #[tokio::test] async fn get_instantiate_call_data_works() -> anyhow::Result<()> { - todo!() - } + let (contracts_node_process, port, temp_dir) = start_test_environment().await?; + let localhost_url = format!("ws://127.0.0.1:{}", port); + sleep(Duration::from_secs(20)).await; + + let up_contract_opts = UpContractCommand { + path: Some(temp_dir.path().join("testing")), + constructor: "new".to_string(), + args: vec!["false".to_string()], + value: "0".to_string(), + gas_limit: Some(200_000_000), + proof_size: Some(30_000), + salt: None, + url: Url::parse(&localhost_url).expect("given url is valid"), + suri: "//Alice".to_string(), + dry_run: false, + upload_only: false, + skip_confirm: true, + use_wallet: true, + }; - async fn wait_for_signature_works() -> anyhow::Result<()> { - todo!() + // Retrieve call data based on the above command options. + let (retrieved_call_data, _) = match up_contract_opts.get_contract_data().await { + Ok(data) => data, + Err(e) => { + error(format!("An error occurred getting the call data: {e}"))?; + return Err(e); + }, + }; + // We have retrieved some payload. + assert!(!retrieved_call_data.is_empty()); + + // Craft instantiate call data. + let weight = Weight::from_parts(200_000_000, 30_000); + let expected_call_data = + get_instantiate_payload(set_up_deployment(up_contract_opts.into()).await?, weight) + .await?; + // Retrieved call data matches the one crafted above. + assert_eq!(retrieved_call_data, expected_call_data); + + // Stop running contracts-node + stop_test_environment(&contracts_node_process.id().to_string())?; + Ok(()) } } diff --git a/crates/pop-cli/src/wallet_integration.rs b/crates/pop-cli/src/wallet_integration.rs index 57b2924e5..b179f21ce 100644 --- a/crates/pop-cli/src/wallet_integration.rs +++ b/crates/pop-cli/src/wallet_integration.rs @@ -360,7 +360,7 @@ mod tests { .await .expect("Failed to parse response"); - assert_eq!(response, json!({"status": "success"})); + assert_eq!(response, json!({"status": "success"}).to_string()); assert_eq!(wim.state.lock().await.signed_payload, Some("0xDEADBEEF".to_string())); assert_eq!(wim.is_running(), false); diff --git a/crates/pop-cli/tests/contract.rs b/crates/pop-cli/tests/contract.rs index 77c8dba5d..6effb4d25 100644 --- a/crates/pop-cli/tests/contract.rs +++ b/crates/pop-cli/tests/contract.rs @@ -2,19 +2,57 @@ use anyhow::Result; use assert_cmd::Command; -use pop_common::{set_executable_permission, templates::Template}; +use pop_common::{find_free_port, set_executable_permission, templates::Template}; use pop_contracts::{ contracts_node_generator, dry_run_gas_estimate_instantiate, instantiate_smart_contract, run_contracts_node, set_up_deployment, Contract, UpOpts, }; -use std::{path::Path, process::Command as Cmd}; +use serde::{Deserialize, Serialize}; +use std::{path::Path, process::Command as Cmd, time::Duration}; use strum::VariantArray; +use subxt::{config::DefaultExtrinsicParamsBuilder as Params, tx::Payload, utils::to_hex}; +use subxt_signer::sr25519::dev; +use tokio::time::sleep; use url::Url; +// This struct implements the [`Payload`] trait and is used to submit +// pre-encoded SCALE call data directly, without the dynamic construction of transactions. +struct CallData(Vec); +impl Payload for CallData { + fn encode_call_data_to( + &self, + _: &subxt::Metadata, + out: &mut Vec, + ) -> Result<(), subxt::ext::subxt_core::Error> { + out.extend_from_slice(&self.0); + Ok(()) + } +} + +// TransactionData has been copied from wallet_integration.rs +/// Transaction payload to be sent to frontend for signing. +#[derive(Serialize, Debug)] +#[cfg_attr(test, derive(Deserialize, Clone))] +pub struct TransactionData { + chain_rpc: String, + call_data: Vec, +} +impl TransactionData { + pub fn new(chain_rpc: String, call_data: Vec) -> Self { + Self { chain_rpc, call_data } + } + pub fn call_data(&self) -> Vec { + self.call_data.clone() + } +} + /// Test the contract lifecycle: new, build, up, call #[tokio::test] async fn contract_lifecycle() -> Result<()> { - const DEFAULT_PORT: u16 = 9944; + const WALLET_INT_URI: &str = "http://127.0.0.1:9090"; + const WAIT_SECS: u64 = 240; + let endpoint_port = find_free_port(); + let default_endpoint: &str = &format!("ws://127.0.0.1:{}", endpoint_port); let temp = tempfile::tempdir().unwrap(); let temp_dir = temp.path(); //let temp_dir = Path::new("./"); //For testing locally @@ -46,14 +84,15 @@ async fn contract_lifecycle() -> Result<()> { let binary = contracts_node_generator(temp_dir.to_path_buf().clone(), None).await?; binary.source(false, &(), true).await?; set_executable_permission(binary.path())?; - let process = run_contracts_node(binary.path(), None, DEFAULT_PORT).await?; + let process = run_contracts_node(binary.path(), None, endpoint_port).await?; + sleep(Duration::from_secs(5)).await; // Only upload the contract // pop up contract --upload-only Command::cargo_bin("pop") .unwrap() .current_dir(&temp_dir.join("test_contract")) - .args(&["up", "contract", "--upload-only"]) + .args(&["up", "contract", "--upload-only", "--url", default_endpoint]) .assert() .success(); // Instantiate contract, only dry-run @@ -70,6 +109,8 @@ async fn contract_lifecycle() -> Result<()> { "--suri", "//Alice", "--dry-run", + "--url", + default_endpoint, ]) .assert() .success(); @@ -83,7 +124,7 @@ async fn contract_lifecycle() -> Result<()> { gas_limit: None, proof_size: None, salt: None, - url: Url::parse("ws://127.0.0.1:9944")?, + url: Url::parse(default_endpoint)?, suri: "//Alice".to_string(), }) .await?; @@ -103,6 +144,8 @@ async fn contract_lifecycle() -> Result<()> { "get", "--suri", "//Alice", + "--url", + default_endpoint, ]) .assert() .success(); @@ -122,10 +165,70 @@ async fn contract_lifecycle() -> Result<()> { "--suri", "//Alice", "-x", + "--url", + default_endpoint, ]) .assert() .success(); + // pop up contract --upload-only --use-wallet + // Will run http server for wallet integration. + // Using `cargo run --` as means for the CI to pass. + // Possibly there's room for improvement here. + let _ = tokio::process::Command::new("cargo") + .args(&[ + "run", + "--", + "up", + "contract", + "--upload-only", + "--use-wallet", + "--skip-confirm", + "--dry-run", + "-p", + temp_dir.join("test_contract").to_str().expect("to_str"), + "--url", + default_endpoint, + ]) + .spawn()?; + // Wait a moment for node and server to be up. + sleep(Duration::from_secs(WAIT_SECS)).await; + + // Request payload from server. + let response = reqwest::get(&format!("{}/payload", WALLET_INT_URI)) + .await + .expect("Failed to get payload") + .json::() + .await + .expect("Failed to parse payload"); + // We have received some payload. + assert!(!response.call_data().is_empty()); + + let rpc_client = subxt::backend::rpc::RpcClient::from_url(default_endpoint).await?; + let client = subxt::OnlineClient::::from_rpc_client(rpc_client).await?; + + // Sign payload. + let signer = dev::alice(); + let payload = CallData(response.call_data()); + let ext_params = Params::new().build(); + let signed = client.tx().create_signed(&payload, &signer, ext_params).await?; + + // Submit signed payload. This kills the wallet integration server. + let _ = reqwest::Client::new() + .post(&format!("{}/submit", WALLET_INT_URI)) + .json(&to_hex(signed.encoded())) + .send() + .await + .expect("Failed to submit payload") + .text() + .await + .expect("Failed to parse JSON response"); + + // Request payload from server after signed payload has been sent. + // Server should not be running! + let response = reqwest::get(&format!("{}/payload", WALLET_INT_URI)).await; + assert!(response.is_err()); + // Stop the process contracts-node Cmd::new("kill") .args(["-s", "TERM", &process.id().to_string()]) diff --git a/crates/pop-contracts/Cargo.toml b/crates/pop-contracts/Cargo.toml index 94a2c6e72..898b09489 100644 --- a/crates/pop-contracts/Cargo.toml +++ b/crates/pop-contracts/Cargo.toml @@ -42,3 +42,4 @@ pop-common = { path = "../pop-common", version = "0.5.0" } dirs.workspace = true mockito.workspace = true tokio-test.workspace = true +hex = "0.4.3" \ No newline at end of file diff --git a/crates/pop-contracts/src/up.rs b/crates/pop-contracts/src/up.rs index d31d0c7f1..3c73d2328 100644 --- a/crates/pop-contracts/src/up.rs +++ b/crates/pop-contracts/src/up.rs @@ -110,6 +110,7 @@ pub async fn set_up_upload( let upload_exec: UploadExec = UploadCommandBuilder::new(extrinsic_opts).done().await?; + Ok(upload_exec) } @@ -415,8 +416,13 @@ mod tests { run_contracts_node, }; use anyhow::Result; + use hex::FromHex; use pop_common::{find_free_port, set_executable_permission}; use std::{env, process::Command, time::Duration}; + use subxt::{ + config::{substrate::BlakeTwo256, Hasher}, + utils::H256, + }; use tokio::time::sleep; use url::Url; @@ -470,6 +476,41 @@ mod tests { Ok(()) } + #[tokio::test] + async fn get_payload_works() -> Result<()> { + let temp_dir = new_environment("testing")?; + let current_dir = env::current_dir().expect("Failed to get current directory"); + mock_build_process( + temp_dir.path().join("testing"), + current_dir.join("./tests/files/testing.contract"), + current_dir.join("./tests/files/testing.json"), + )?; + let up_opts = UpOpts { + path: Some(temp_dir.path().join("testing")), + constructor: "new".to_string(), + args: ["false".to_string()].to_vec(), + value: "1000".to_string(), + gas_limit: None, + proof_size: None, + salt: None, + url: Url::parse(CONTRACTS_NETWORK_URL)?, + suri: "//Alice".to_string(), + }; + let contract_code = get_contract_code(up_opts.path.as_ref()).await?; + let call_data = get_upload_payload(contract_code, CONTRACTS_NETWORK_URL).await?; + let payload_hash = BlakeTwo256::hash(&call_data); + // We know that for the above opts the payload hash should be: + // 0x98c24584107b3a01d12e8e02c0bb634d15dc86123c44d186206813ede42f478d + let expected_hash: H256 = H256::from( + <[u8; 32]>::from_hex( + "98c24584107b3a01d12e8e02c0bb634d15dc86123c44d186206813ede42f478d", + ) + .expect("Invalid hex string"), + ); + assert_eq!(expected_hash, payload_hash); + Ok(()) + } + #[tokio::test] async fn dry_run_gas_estimate_instantiate_works() -> Result<()> { let temp_dir = new_environment("testing")?; diff --git a/crates/pop-contracts/tests/files/testing.wasm b/crates/pop-contracts/tests/files/testing.wasm index 430888669464e770d310139f29e8af1ebf865ba4..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 3710 zcmb_fO>ZPu6}|7Hx~pC8aXC1S84uE{iiI30?T;bxhzSyYkzz9%A+uqL>>j(uxZBen zSNAv>DN1{y2#GCQ#0H5i8-4|1#S$U0VGA2J2nk5ed9T|aN+LFl<*Daa@1A?_x#!;3 zE#2WWB_isb)`1?6<-m-`V>LbyIj~r9$(_kxa-hOX?BcK*>ro>aR^8Lh?6?~34@YNp z_qY-h_BN7}!Sfd*k?`03Z2099*z2C|*VX7nJrEnM<}bRZFRJ}f-5m^{R&`w+iI{3| z(RfxpJF8!6rE~MfVxsU*8i-r0G*gWXMC-<)87qHzX|u8Tf4@rjxcoH9n)BKoc27^` zn~kID`RUn9xjXaUrN!69DQ9-iF>FHZX2 zoR>+jFisVgZwwAP!(-anj>tn1XT9p-t>%NKnKQ28l0E=GJ}*;j0$#bKS0;e_0Jce? zBG3=pKyzwU%a%rT-u4QF$cLE~1m_Y$*tietQQ7oM=S2&#HTzsOorQ5>r*N=#*0nAI zW@%k>RDTU}1F;kc(;+~lt6c+01Q;9BQP zJgAPeA85lN+6ms3h;YK42}eV!npF4877Hr2Db zo1sVqd%(S+6dZ{Xi7JaWKpB-nVI%>cE9+891aw{lswp5C9Yw7emt0J>a*ge>9z@cj zRuWVRe?gRi06;+FfnDStV+^_9(9IMFNhU!hR7ew%LA;<2+=j*qX$FO$nQk`~_=L+q zAOduHLf{NG0m>?slmMGb2c>}f|JhX3Rhh-6g7pXu2A)#HWkXXXE}J%0fQwC)UNu$n zwM}(J6awT7LDNqe+G~@s8|1L5Ch3J%Z)RTUu^yXk1foA^tGm!0l!j3SPS{I)U*7pqR3A@Pcalxt>?~of6Z>3xev%T=q~D zUvjcc{Quy}|HOVv&+6@9Ka1wd?*zH5>e(nGAUK)9ct_o%b`_XbX5B9xmfqyPQZ}u9mW9#KT5f2sSZ%Fn% zW{TMk3i+;ps?gQoUWcPBpZ`zT*BPgR(<~@br|~at$=d1nFE7;toq|O`fqOO#+PSp( zyxyi%)3U+GY#G=L+J#yWBq+iFN3VcH#*YSN?Ub`QB?f?K2SA{(00|3>VnD*09Z7|g ze%6NW(I`f;G$fxdX20fj0=74%Y$Mbyy%J7}U%_X9TE;Wxrm8s z#1kk+nYtwZ29+1i8^3f>pzVErftnbfUK9=XI1HD1_&8ufn;XBr3k&1_D)IR@*+p&s zsdj5jZwvxIw~Ji3_||S+w=L34^BKBWMM#(tK>}|Rw<9a~G=kPw{*OS3<_`fB?SBKz zw!x@Tv%GQrpVais&*&{4`m^8=Mr~}k>u`A=gv6H+{{rI2^$_oPM6!fwh2f?c)dEtJ zSoYN^M=Kl{q*S45A#$|is(!oFY$2>x7(Oz?t&yMx=qimL=!DZPo!Gse;VlgqpFk1T<3Se(<8FPCwND(&g%kyOvL!IYK=m*JP zmn?rRu%GO+T7t~3Wx7;Cb352U zVE2gGk%gcx0Q?1J5#YDif+qqbh$}I~I=K&n)}dPr8cqZo|G0B3lr{xdIV0 z)*uXc#b5}-hXn@X=lNp3FeRT=fVksU_Ni86)@J`w>pvb=^>Dp=T7C4S>JHY|4!ia7 z*?L_apA1L!%k^P>xPCG?syR+XyDr;P_-vt)C6{4}aP{8LU5f@f6>v*6N{%lIJ4w6a3!6@BhXB^KN}Idf6G` hi&oV+JUglmJKtH`THCxIVhI~`eF@)v`23K*zW{;(I0gU!