diff --git a/.github/workflows/chaos.yml b/.github/workflows/chaos.yml index 55322db92..5df0b80da 100644 --- a/.github/workflows/chaos.yml +++ b/.github/workflows/chaos.yml @@ -88,9 +88,9 @@ jobs: run: go mod download - name: Install Ginkgo CLI run: | - go get github.com/onsi/ginkgo/v2/ginkgo/generators@v2.0.0 - go get github.com/onsi/ginkgo/v2/ginkgo/internal@v2.0.0 - go get github.com/onsi/ginkgo/v2/ginkgo/labels@v2.0.0 + go get github.com/onsi/ginkgo/v2/ginkgo/generators@v2.1.2 + go get github.com/onsi/ginkgo/v2/ginkgo/internal@v2.1.2 + go get github.com/onsi/ginkgo/v2/ginkgo/labels@v2.1.2 go install github.com/onsi/ginkgo/v2/ginkgo - uses: actions/download-artifact@master with: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 43d1d7be6..ca72bc4ab 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,11 +12,11 @@ on: type: string cl_repo: required: true - default: 795953128386.dkr.ecr.us-west-2.amazonaws.com/chainlink + default: public.ecr.aws/z0b1w9r9/chainlink type: string cl_image: required: true - default: develop.latest + default: develop type: string secrets: QA_AWS_ACCESS_KEY_ID: diff --git a/.gitignore b/.gitignore index b7a8a1a05..2462480bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .direnv .vscode +.idea target/ artifacts/bin/ tarpaulin-report.html @@ -10,6 +11,7 @@ node_modules dist .env flow-report.json +report.json .envrc bin @@ -17,4 +19,6 @@ bin packages-ts/gauntlet-terra-contracts/codeIds/test* packages-ts/gauntlet-terra-contracts/networks/.env.test* tests/e2e/logs -networks/.env.test* +packages-ts/gauntlet-terra-contracts/networks/.env.test* +tests/e2e/smoke/rdd/directory* +tests/e2e/smoke/reports \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index d46cb8d16..64b613da2 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ nodejs 14.19.0 -golang 1.17.6 +golang 1.17.7 rust 1.58.1 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ed184857c..7feb217ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,22 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +[[package]] +name = "hello-world" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus", + "cw2", + "proxy-ocr2", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "hermit-abi" version = "0.1.19" @@ -1100,13 +1116,15 @@ version = "0.1.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", + "cosmwasm-storage", "cw-multi-test", "cw-storage-plus", "cw2", "ocr2", - "query-proxy", + "owned", "schemars", "serde", + "thiserror", ] [[package]] @@ -1129,22 +1147,6 @@ dependencies = [ "syn", ] -[[package]] -name = "query-proxy" -version = "0.1.0" -dependencies = [ - "cosmwasm-schema", - "cosmwasm-std", - "cosmwasm-storage", - "cw-multi-test", - "cw-storage-plus", - "cw2", - "owned", - "schemars", - "serde", - "thiserror", -] - [[package]] name = "quote" version = "1.0.10" diff --git a/Cargo.toml b/Cargo.toml index 6963821e9..f170fd08c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "contracts/*", - "crates/*" + "crates/*", + "examples/hello-world", ] [profile.dev] diff --git a/Makefile b/Makefile index 50b9806e5..f02e660f2 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ download: go mod download install: - go get github.com/onsi/ginkgo/v2/ginkgo/generators@v2.0.0 - go get github.com/onsi/ginkgo/v2/ginkgo/internal@v2.0.0 - go get github.com/onsi/ginkgo/v2/ginkgo/labels@v2.0.0 + go get github.com/onsi/ginkgo/v2/ginkgo/generators@v2.1.2 + go get github.com/onsi/ginkgo/v2/ginkgo/internal@v2.1.2 + go get github.com/onsi/ginkgo/v2/ginkgo/labels@v2.1.2 go install github.com/onsi/ginkgo/v2/ginkgo build_js: @@ -45,11 +45,24 @@ artifacts_clean_terrad: build: build_js build_contracts +test_relay_unit: + go build -v ./pkg/terra/... + go test -v ./pkg/terra/... + test_smoke: - SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo -p -procs=2 tests/e2e/smoke + SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo -p -procs=3 tests/e2e/smoke test_ocr: - SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo --focus=@ocr tests/e2e/smoke + SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo --focus=@ocr2 tests/e2e/smoke + +test_ocr_proxy: + SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo --focus=@ocr_proxy tests/e2e/smoke + +test_migration: + SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo tests/e2e/migration test_gauntlet: SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo --focus=@gauntlet tests/e2e/smoke + +test_chaos: + SELECTED_NETWORKS=localterra NETWORK_SETTINGS=$(shell pwd)/tests/e2e/networks.yaml ginkgo tests/e2e/chaos diff --git a/README.md b/README.md index 950b43a73..9ee23537a 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,5 @@ -# Chainlink Terra Integration +# Chainlink Terra -This repository is a monorepo of the various components required for Chainlink on Terra. +## Quick Start -- Terra Contracts (OCR2, ...) -- Terra CL Relay -- Terra Gauntlet -- Terra On-chain Monitoring -- Ops (infrastructure) -- Integration (tests) -- Demos & Examples - -# Local asdf initial setup - - asdf plugin-add golang https://github.com/kennyp/asdf-golang.git - # for other golang requirements for your os go to https://github.com/kennyp/asdf-golang - asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git - asdf plugin-add rust https://github.com/asdf-community/asdf-rust.git - - # Then run - asdf install +For more information, see the [Chainlink Terra Documentation](./docs/). diff --git a/cmd/monitoring/main.go b/cmd/monitoring/main.go index 6508433e1..db67b623f 100644 --- a/cmd/monitoring/main.go +++ b/cmd/monitoring/main.go @@ -12,7 +12,8 @@ import ( func main() { ctx := context.Background() - log := logger.NewLogger().With("project", "terra") + coreLog := logger.NewLogger() + log := logWrapper{coreLog} terraConfig, err := monitoring.ParseTerraConfig() if err != nil { @@ -24,15 +25,16 @@ func main() { terraConfig.ChainID, terraConfig.TendermintURL, terraConfig.ReadTimeout, - log, + coreLog, ) if err != nil { log.Fatalw("failed to create a terra client", "error", err) return } + chainReader := monitoring.NewChainReader(client) envelopeSourceFactory := monitoring.NewEnvelopeSourceFactory( - client, + chainReader, log.With("component", "source-envelope"), ) txResultsFactory := monitoring.NewTxResultsSourceFactory( @@ -41,7 +43,7 @@ func main() { entrypoint, err := relayMonitoring.NewEntrypoint( ctx, - logWrapper{log}, + log, terraConfig, envelopeSourceFactory, txResultsFactory, @@ -52,6 +54,21 @@ func main() { return } + proxySourceFactory := monitoring.NewProxySourceFactory( + chainReader, + log.With("component", "source-proxy"), + ) + if entrypoint.Config.Feature.TestOnlyFakeReaders { + proxySourceFactory = monitoring.NewFakeProxySourceFactory(log.With("component", "fake-proxy-source")) + } + entrypoint.SourceFactories = append(entrypoint.SourceFactories, proxySourceFactory) + + prometheusExporterFactory := monitoring.NewPrometheusExporterFactory( + log.With("component", "terra-prometheus-exporter"), + monitoring.NewMetrics(log.With("component", "terra-metrics")), + ) + entrypoint.ExporterFactories = append(entrypoint.ExporterFactories, prometheusExporterFactory) + entrypoint.Run() log.Info("monitor stopped") } diff --git a/contracts/ocr2/src/contract.rs b/contracts/ocr2/src/contract.rs index 7a2e1ea41..25ed06e67 100644 --- a/contracts/ocr2/src/contract.rs +++ b/contracts/ocr2/src/contract.rs @@ -358,7 +358,7 @@ pub fn execute_accept_proposal( let proposal = PROPOSALS.load(deps.storage, id.u128().into())?; - let response = Response::new().add_attribute("method", "propose_config"); + let response = Response::new().add_attribute("method", "accept_proposal"); // Only approve proposal if finalized require!(proposal.finalized, InvalidInput); @@ -451,6 +451,11 @@ pub fn execute_accept_proposal( .iter() .map(|(_, transmitter, _)| attr("transmitters", transmitter)); + let payees = proposal + .oracles + .iter() + .map(|(_, _, payee)| attr("payees", payee)); + response = response.add_event( Event::new("set_config") .add_attribute( @@ -464,6 +469,7 @@ pub fn execute_accept_proposal( .add_attribute("config_count", config.config_count.to_string()) .add_attributes(signers) .add_attributes(transmitters) + .add_attributes(payees) .add_attribute("f", proposal.f.to_string()) .add_attribute("onchain_config", Binary(onchain_config).to_base64()) .add_attribute( @@ -505,6 +511,9 @@ pub fn execute_propose_config( // validate new config require!(f != 0, InvalidInput); require!(signers_len <= MAX_ORACLES, TooManySigners); + // See corresponding comment https://github.com/smartcontractkit/chainlink-terra/blob/5c229358eea2633922de615be509eb47c5bcb998/pkg/terra/config_digester.go#L30 + // If this requirement of len(transmitters) == len(signers) is removed, we'll need + // to update the config digester to include a length prefix on transmitters. require!(transmitters.len() == signers.len(), InvalidInput); require!(payees.len() == signers.len(), InvalidInput); require!(3 * (usize::from(f)) < signers_len, InvalidInput); @@ -626,6 +635,9 @@ fn validate_answer(deps: Deps, config: &Config, round_id: u32, answer: i128) -> .answer; Some( + // Validation happens in a submessage, which will "will revert any partial state changes due to this message, + // but not revert any state changes in the calling contract." This way the validator going over the gas limit + // or failing validation won't block publishing to the feed. SubMsg::new(WasmMsg::Execute { contract_addr: validator.address.to_string(), msg: to_binary(&ValidatorMsg::Validate { @@ -1129,7 +1141,7 @@ pub fn execute_set_billing_access_controller( // --- pub fn execute_set_billing( - deps: DepsMut, + mut deps: DepsMut, _env: Env, info: MessageInfo, billing_config: Billing, @@ -1145,10 +1157,17 @@ pub fn execute_set_billing( Unauthorized ); + // payout oracles + let (_total, response) = pay_oracles( + &mut deps, + &config, + Response::new().add_attribute("method", "set_billing"), + )?; + config.billing = billing_config; CONFIG.save(deps.storage, &config)?; - Ok(Response::default().add_event( + Ok(response.add_event( Event::new("set_billing") .add_attribute( "recommended_gas_price_micro", @@ -1522,6 +1541,7 @@ pub fn execute_accept_payeeship( #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::Decimal; use cosmwasm_std::testing::{ mock_dependencies, mock_env, mock_info, MockApi, MockQuerier, MockStorage, }; @@ -1698,4 +1718,28 @@ pub(crate) mod tests { let execute_info = mock_info("payee2", &[]); execute(deps.as_mut(), mock_env(), execute_info, msg).unwrap(); } + + #[test] + fn test_calculate_reimbursement() { + use std::str::FromStr; + let recommended_gas_price = Decimal::from_str("0.011000").unwrap(); + let juels_per_fee_coin = u128::from_str("6000000000000000000").unwrap(); // 6e18 juels in 1 luna (i.e. 6 link) + + // Sanity check + let r = calculate_reimbursement( + &Billing { + recommended_gas_price_micro: recommended_gas_price, + observation_payment_gjuels: 0, + transmission_payment_gjuels: 0, + gas_base: Some(84_000), + gas_per_signature: Some(17_000), + gas_adjustment: Some(140), + }, + juels_per_fee_coin, + 1, + ); + // juels = ((gas_per_sig*sigcount + gas_base)*gas_price_uluna/1e6)*juels_per_fee_coin + // juels = ((1 * 17000 + 84000)*1.4*0.011/1e6)*6e18 = 9332400000000000 + assert_eq!(Uint128::from_str("9332400000000000").unwrap(), r); + } } diff --git a/contracts/ocr2/src/integration_tests.rs b/contracts/ocr2/src/integration_tests.rs index 3b5894251..f56a0e447 100644 --- a/contracts/ocr2/src/integration_tests.rs +++ b/contracts/ocr2/src/integration_tests.rs @@ -801,3 +801,207 @@ fn set_link_token() { .unwrap(); assert_eq!(token_addr, new_link_token); } + +#[test] +fn revert_payouts_correctly() { + let mut env = setup(); + + // set billing + let observation_payment = Uint128::from(5 * GIGA); + let reimbursement = Decimal::from_str("0.001871716").unwrap().0; + let recommended_gas_price = Decimal::from_str("0.01133").unwrap(); + let msg = ExecuteMsg::SetBilling { + config: Billing { + recommended_gas_price_micro: recommended_gas_price, + observation_payment_gjuels: 5, + transmission_payment_gjuels: 0, + ..Default::default() + }, + }; + env.router + .execute_contract(env.owner.clone(), env.ocr2_addr.clone(), &msg, &[]) + .unwrap(); + + // withdraw all LINK + let available: LinkAvailableForPaymentResponse = env + .router + .wrap() + .query_wasm_smart(&env.ocr2_addr, &QueryMsg::LinkAvailableForPayment) + .unwrap(); + let msg = ExecuteMsg::WithdrawFunds { + recipient: env.owner.to_string(), + amount: Uint128::from(available.amount as u128), + }; + env.router + .execute_contract(env.owner.clone(), env.ocr2_addr.clone(), &msg, &[]) + .unwrap(); + let available: LinkAvailableForPaymentResponse = env + .router + .wrap() + .query_wasm_smart(&env.ocr2_addr, &QueryMsg::LinkAvailableForPayment) + .unwrap(); + assert_eq!(0, available.amount); + + // transmit round + transmit_report(&mut env, 1, 1); + + // check owed balance + let transmitter = Addr::unchecked("transmitter0"); + let payee = Addr::unchecked("payee0"); + + let owed: Uint128 = env + .router + .wrap() + .query_wasm_smart( + &env.ocr2_addr, + &QueryMsg::OwedPayment { + transmitter: transmitter.to_string(), + }, + ) + .unwrap(); + assert_eq!(reimbursement + observation_payment, owed); + + // attempt to withdraw should fail without LINK token balance + // tests the underlying `pay_oracle` function + let msg = ExecuteMsg::WithdrawPayment { + transmitter: transmitter.to_string(), + }; + assert!(env + .router + .execute_contract(payee.clone(), env.ocr2_addr.clone(), &msg, &[]) + .is_err()); + + // owed balance should not have changed + let owed_new: Uint128 = env + .router + .wrap() + .query_wasm_smart( + &env.ocr2_addr, + &QueryMsg::OwedPayment { + transmitter: transmitter.to_string(), + }, + ) + .unwrap(); + assert_eq!(owed, owed_new); + + // attempt to change LINK token to trigger paying all oracles + // tests the underlying `pay_oracles` function + let new_link_token = env + .router + .instantiate_contract( + env.link_token_id, + env.owner.clone(), + &cw20_base::msg::InstantiateMsg { + name: String::from("Chainlink"), + symbol: String::from("LINK"), + decimals: 18, + initial_balances: vec![Cw20Coin { + address: env.owner.to_string(), + amount: Uint128::from(1_000_000_000_u128), + }], + mint: None, + marketing: None, + }, + &[], + "LINK2", + None, + ) + .unwrap(); + let msg = ExecuteMsg::SetLinkToken { + link_token: new_link_token.to_string(), + recipient: env.owner.to_string(), + }; + assert!(env + .router + .execute_contract(env.owner.clone(), env.ocr2_addr.clone(), &msg, &[]) + .is_err()); + + // oracles owed balance should not have changed + for transmitter in env + .transmitters + .iter() + .enumerate() + .map(|(i, _)| Addr::unchecked(format!("transmitter{}", i))) + { + let balance: Uint128 = env + .router + .wrap() + .query_wasm_smart( + &env.ocr2_addr, + &QueryMsg::OwedPayment { + transmitter: transmitter.to_string(), + }, + ) + .unwrap(); + + if transmitter == "transmitter0" { + assert_eq!(balance, observation_payment + reimbursement); + } else { + assert_eq!(balance, observation_payment); + } + } +} + +#[test] +fn set_billing_payout() { + let mut env = setup(); + // expected in juels + let observation_payment = Uint128::from(5 * GIGA); + let reimbursement = Decimal::from_str("0.001871716").unwrap().0; + + // -- set billing + // price in uLUNA + let recommended_gas_price = Decimal::from_str("0.01133").unwrap(); + let msg = ExecuteMsg::SetBilling { + config: Billing { + recommended_gas_price_micro: recommended_gas_price, + observation_payment_gjuels: 5, + transmission_payment_gjuels: 0, + ..Default::default() + }, + }; + env.router + .execute_contract(env.owner.clone(), env.ocr2_addr.clone(), &msg, &[]) + .unwrap(); + + // -- call transmit + transmit_report(&mut env, 1, 1); + + // -- set billing again + let msg = ExecuteMsg::SetBilling { + config: Billing { + recommended_gas_price_micro: recommended_gas_price, + observation_payment_gjuels: 1, + transmission_payment_gjuels: 1, + ..Default::default() + }, + }; + env.router + .execute_contract(env.owner.clone(), env.ocr2_addr.clone(), &msg, &[]) + .unwrap(); + + // oracles should be paid out (same as changing LINK token) + for payee in env + .transmitters + .iter() + .enumerate() + .map(|(i, _)| Addr::unchecked(format!("payee{}", i))) + { + let cw20::BalanceResponse { balance } = env + .router + .wrap() + .query_wasm_smart( + env.link_token_addr.to_string(), + &cw20::Cw20QueryMsg::Balance { + address: payee.to_string(), + }, + ) + .unwrap(); + + if payee == "payee0" { + assert_eq!(balance, observation_payment + reimbursement); + } else { + assert_eq!(balance, observation_payment); + } + } +} diff --git a/contracts/ocr2/src/state.rs b/contracts/ocr2/src/state.rs index 21e397aa6..9feccaccc 100644 --- a/contracts/ocr2/src/state.rs +++ b/contracts/ocr2/src/state.rs @@ -6,7 +6,9 @@ use cosmwasm_std::{Addr, Binary, Uint128}; use cw20::Cw20Contract; use cw_storage_plus::{Item, Map, U128Key, U32Key}; use owned::Auth; +use std::convert::TryFrom; +use crate::error::ContractError; use crate::Decimal; /// Maximum number of oracles the offchain reporting protocol is designed for @@ -156,9 +158,14 @@ pub fn config_digest_from_data( offchain_config_version: u64, offchain_config: &[u8], ) -> [u8; 32] { + // validate chain_id length fits into u8 + let chain_id_length = u8::try_from(chain_id.len()) + .map_err(|_| ContractError::InvalidInput) + .unwrap(); use blake2::{Blake2s, Digest}; let mut hasher = Blake2s::default(); - hasher.update(chain_id.as_bytes()); + hasher.update(chain_id_length.to_be_bytes()); + hasher.update(&chain_id.as_bytes()); hasher.update(contract_address.as_bytes()); hasher.update(&config_count.to_be_bytes()); hasher.update([(oracles.len() as u8)]); @@ -231,3 +238,25 @@ pub const TRANSMISSIONS: Map = Map::new("transmissions"); // transmitter -> payment address pub const PAYEES: Map<&Addr, Addr> = Map::new("payees"); pub const PROPOSED_PAYEES: Map<&Addr, Addr> = Map::new("proposed_payees"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn invalid_chain_id_length() { + let empty: [u8; 0] = []; + let empty_oracle: [(&Binary, &Addr); 0] = []; + config_digest_from_data( + &"a".repeat(256), + &Addr::unchecked("test"), + 1, + &empty_oracle, + 1, + &empty, + 1, + &empty, + ); + } +} diff --git a/contracts/proxy-ocr2/Cargo.toml b/contracts/proxy-ocr2/Cargo.toml index b72a76c86..1efcb5cbb 100644 --- a/contracts/proxy-ocr2/Cargo.toml +++ b/contracts/proxy-ocr2/Cargo.toml @@ -14,10 +14,13 @@ library = [] [dependencies] ocr2 = { path = "../ocr2", default-features = false } -query-proxy = { path = "../../crates/query-proxy", default-features = false } +owned = { version = "0.1", path = "../../crates/owned" } cosmwasm-std = { version = "0.16.2" } +cosmwasm-storage = { version = "0.16.0" } +cw-storage-plus = "0.9.0" cw2 = "0.9.0" +thiserror = { version = "1.0.24" } schemars = "0.8.3" serde = { version = "1.0.127", default-features = false, features = ["derive"] } diff --git a/contracts/proxy-ocr2/src/integration_tests.rs b/contracts/proxy-ocr2/src/integration_tests.rs index 6a432c1f9..c6a9dfd53 100644 --- a/contracts/proxy-ocr2/src/integration_tests.rs +++ b/contracts/proxy-ocr2/src/integration_tests.rs @@ -37,6 +37,10 @@ mod mock { pub const LATEST_ROUND: Item = Item::new("latest_round"); pub const ROUNDS: Map = Map::new("rounds"); + pub const DECIMALS: u8 = 8; + pub const VERSION: &str = "0.0.0"; + pub const NAME: &str = "mock test"; + pub fn contract() -> Box> { pub fn execute( deps: DepsMut, @@ -47,7 +51,7 @@ mod mock { match msg { ExecuteMsg::Insert(round) => { let round_id = LATEST_ROUND - .update(deps.storage, |round_id: u32| StdResult::Ok(round_id + 1))?; + .update(deps.storage, |_: u32| StdResult::Ok(round.round_id))?; // store data based on passed in round_id ROUNDS.save(deps.storage, round_id.into(), &round)?; Ok(Response::default()) } @@ -74,6 +78,9 @@ mod mock { let round = ROUNDS.load(deps.storage, latest_round.into())?; to_binary(&round) } + QueryMsg::Decimals => to_binary(&DECIMALS), + QueryMsg::Version => to_binary(&VERSION), + QueryMsg::Description => to_binary(&NAME.to_string()), _ => unimplemented!(), } } @@ -178,6 +185,30 @@ fn it_works() { assert_eq!(parse_round_id(latest_round.round_id), (1, 2)); + // query decimals + let decimal: u8 = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::Decimals) + .unwrap(); + assert_eq!(decimal, mock::DECIMALS); + + // query version + let version: String = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::Version) + .unwrap(); + assert_eq!(version, mock::VERSION.to_string()); + + // query description + let desc: String = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::Description) + .unwrap(); + assert_eq!(desc, mock::NAME.to_string()); + // query by round id, it should match latest round let round: Round = env .router @@ -252,7 +283,43 @@ fn it_works() { assert_eq!(proposed_latest_round.round_id, 3); - // confirm it + // (proposed) query by round id, it should match latest round + let proposed_round: Round = env + .router + .wrap() + .query_wasm_smart( + &env.proxy_addr, + &QueryMsg::ProposedRoundData { + round_id: proposed_latest_round.round_id as u32, + }, + ) + .unwrap(); + assert_eq!(proposed_round, proposed_latest_round); + + // store old aggregator address + let old_aggregator: String = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::Aggregator) + .unwrap(); + assert_eq!(env.ocr2_addr.to_string(), old_aggregator); + + // store old aggregator address + let proposed_aggregator: String = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::ProposedAggregator) + .unwrap(); + assert_eq!(ocr2_addr2.to_string(), proposed_aggregator); + + // save original phase + let old_phase: u16 = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::PhaseId) + .unwrap(); + + // confirm aggregator swap env.router .execute_contract( env.owner.clone(), @@ -264,7 +331,46 @@ fn it_works() { ) .unwrap(); - // query latest round, it should now point to the new aggregator + // fetch new aggregator address + let new_aggregator: String = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::Aggregator) + .unwrap(); + assert_ne!(old_aggregator, new_aggregator); + assert_eq!(ocr2_addr2.to_string(), new_aggregator); + assert_eq!(proposed_aggregator, new_aggregator); + + // check phase details after switching + let new_phase: u16 = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::PhaseId) + .unwrap(); + assert_ne!(old_phase, new_phase); + let old_phase_agg: String = env + .router + .wrap() + .query_wasm_smart( + &env.proxy_addr, + &QueryMsg::PhaseAggregators { + phase_id: old_phase, + }, + ) + .unwrap(); + let new_phase_agg: String = env + .router + .wrap() + .query_wasm_smart( + &env.proxy_addr, + &QueryMsg::PhaseAggregators { + phase_id: new_phase, + }, + ) + .unwrap(); + assert_eq!(old_aggregator, old_phase_agg); + assert_eq!(new_aggregator, new_phase_agg); + let latest_round: Round = env .router .wrap() @@ -285,4 +391,75 @@ fn it_works() { .unwrap(); assert_eq!(round, historic_round); + + // test ownership transfer + let old_owner: String = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::Owner) + .unwrap(); + let owner2 = Addr::unchecked("new_owner"); + // cannot transfer if not owner + assert!(env + .router + .execute_contract( + owner2.clone(), + env.proxy_addr.clone(), + &ExecuteMsg::TransferOwnership { + to: owner2.to_string(), + }, + &[], + ) + .is_err()); + // owner can transfer ownership + assert!(env + .router + .execute_contract( + env.owner.clone(), + env.proxy_addr.clone(), + &ExecuteMsg::TransferOwnership { + to: env.owner.to_string(), + }, + &[], + ) + .is_ok()); + // owner can transfer ownership again (overwrite pending) + assert!(env + .router + .execute_contract( + env.owner.clone(), + env.proxy_addr.clone(), + &ExecuteMsg::TransferOwnership { + to: owner2.to_string(), + }, + &[], + ) + .is_ok()); + // current owner cannot accept ownership of new owner + assert!(env + .router + .execute_contract( + env.owner.clone(), + env.proxy_addr.clone(), + &ExecuteMsg::AcceptOwnership, + &[], + ) + .is_err()); + // new owner can accept ownership + assert!(env + .router + .execute_contract( + owner2.clone(), + env.proxy_addr.clone(), + &ExecuteMsg::AcceptOwnership, + &[], + ) + .is_ok()); + let new_owner: String = env + .router + .wrap() + .query_wasm_smart(&env.proxy_addr, &QueryMsg::Owner) + .unwrap(); + assert_ne!(old_owner, new_owner); + assert_eq!(owner2.to_string(), new_owner); } diff --git a/contracts/proxy-ocr2/src/lib.rs b/contracts/proxy-ocr2/src/lib.rs index bdd9c7efd..3c10c69fc 100644 --- a/contracts/proxy-ocr2/src/lib.rs +++ b/contracts/proxy-ocr2/src/lib.rs @@ -1,13 +1,44 @@ mod integration_tests; use cosmwasm_std::{ - entry_point, to_binary, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response, StdResult, + entry_point, to_binary, Addr, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response, + StdError, StdResult, }; +use cw_storage_plus::{Item, Map, U16Key}; + +use thiserror::Error; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -pub use query_proxy::{ContractError, Phase, CURRENT_PHASE, OWNER, PHASES, PROPOSED_CONTRACT}; +use owned::Auth; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + Owned(#[from] owned::Error), + + #[error("Unauthorized")] + Unauthorized, + + #[error("Invalid")] + Invalid, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Phase { + pub id: u16, + pub contract_address: Addr, +} + +pub const OWNER: Auth = Auth::new("owner"); +pub const CURRENT_PHASE: Item = Item::new("current_phase"); +pub const PROPOSED_CONTRACT: Item = Item::new("proposed_contract"); +pub const PHASES: Map = Map::new("phases"); pub mod state { use super::*; @@ -25,7 +56,30 @@ pub mod state { pub mod msg { use super::*; - pub use query_proxy::{ExecuteMsg, InstantiateMsg}; + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] + pub struct InstantiateMsg { + pub contract_address: String, + } + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] + #[serde(rename_all = "snake_case")] + pub enum ExecuteMsg { + ProposeContract { + address: String, + }, + ConfirmContract { + address: String, + }, + /// Initiate contract ownership transfer to another address. + /// Can be used only by owner + TransferOwnership { + /// Address to transfer ownership to + to: String, + }, + /// Finish contract ownership transfer. Can be used only by pending owner + AcceptOwnership, + } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -133,24 +187,24 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result { let contract_address = CURRENT_PHASE.load(deps.storage)?.contract_address; - Ok(to_binary(&deps.querier.query_wasm_smart( - contract_address, - &ocr2::msg::QueryMsg::Decimals, - )?)?) + let decimals: u8 = deps + .querier + .query_wasm_smart(&contract_address, &ocr2::msg::QueryMsg::Decimals)?; + Ok(to_binary(&decimals)?) } QueryMsg::Version => { let contract_address = CURRENT_PHASE.load(deps.storage)?.contract_address; - Ok(to_binary(&deps.querier.query_wasm_smart( - contract_address, - &ocr2::msg::QueryMsg::Version, - )?)?) + let version: String = deps + .querier + .query_wasm_smart(contract_address, &ocr2::msg::QueryMsg::Version)?; + Ok(to_binary(&version)?) } QueryMsg::Description => { let contract_address = CURRENT_PHASE.load(deps.storage)?.contract_address; - Ok(to_binary(&deps.querier.query_wasm_smart( - contract_address, - &ocr2::msg::QueryMsg::Description, - )?)?) + let description: String = deps + .querier + .query_wasm_smart(contract_address, &ocr2::msg::QueryMsg::Description)?; + Ok(to_binary(&description)?) } QueryMsg::RoundData { round_id } => { let (phase_id, round_id) = parse_round_id(round_id); diff --git a/crates/owned/src/lib.rs b/crates/owned/src/lib.rs index 7d1fb76e7..8d68d6239 100644 --- a/crates/owned/src/lib.rs +++ b/crates/owned/src/lib.rs @@ -79,7 +79,7 @@ impl<'a> Auth<'a> { self.0.save(deps.storage, &state)?; Ok(Response::default().add_event( - Event::new("ownership_transfer_requested") + Event::new("transfer_ownership") .add_attribute("from", state.owner) .add_attribute("to", to), )) @@ -101,7 +101,7 @@ impl<'a> Auth<'a> { self.0.save(deps.storage, &state)?; Ok(Response::default().add_event( - Event::new("ownership_transfer_requested") + Event::new("accept_ownership") .add_attribute("from", old_owner) .add_attribute("to", state.owner), )) diff --git a/crates/query-proxy/src/integration_tests.rs b/crates/query-proxy/src/integration_tests.rs deleted file mode 100644 index 2fc7c46bf..000000000 --- a/crates/query-proxy/src/integration_tests.rs +++ /dev/null @@ -1,125 +0,0 @@ -#![cfg(test)] -#![cfg(not(tarpaulin_include))] -use cosmwasm_std::{ - to_binary, Addr, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult, -}; -use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper, Executor}; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum FooQueryMsg { - Foo, -} - -mod foo { - pub use super::FooQueryMsg; - crate::contract!(super::FooQueryMsg); -} - -use foo::msg::{ExecuteMsg, InstantiateMsg}; -use foo::{execute, instantiate, query}; - -fn mock_app() -> App { - AppBuilder::new().build() -} - -pub fn contract_proxy() -> Box> { - let contract = ContractWrapper::new(execute, instantiate, query); - Box::new(contract) -} - -pub fn contract_foo() -> Box> { - pub fn execute( - _deps: DepsMut, - _env: Env, - _info: MessageInfo, - _msg: ExecuteMsg, - ) -> StdResult { - unreachable!(); - } - pub fn instantiate( - _deps: DepsMut, - _env: Env, - _info: MessageInfo, - _msg: InstantiateMsg, - ) -> StdResult { - Ok(Response::default()) - } - pub fn query(_deps: Deps, _env: Env, msg: FooQueryMsg) -> StdResult { - match msg { - FooQueryMsg::Foo => to_binary("foo"), - } - } - let contract = ContractWrapper::new(execute, instantiate, query); - Box::new(contract) -} - -struct TestingEnv { - router: App, - owner: Addr, - proxy_addr: Addr, - foo_addr: Addr, -} - -fn setup() -> TestingEnv { - let mut router = mock_app(); - - let owner = Addr::unchecked("owner"); - - let proxy_id = router.store_code(contract_proxy()); - let foo_id = router.store_code(contract_foo()); - - let foo_addr = router - .instantiate_contract( - foo_id, - owner.clone(), - &InstantiateMsg { - contract_address: "".to_string(), - }, - &[], - "foo", - None, - ) - .unwrap(); - - let proxy_addr = router - .instantiate_contract( - proxy_id, - owner.clone(), - &InstantiateMsg { - contract_address: foo_addr.to_string(), - }, - &[], - "proxy", - None, - ) - .unwrap(); - - TestingEnv { - router, - owner, - proxy_addr, - foo_addr, - } -} - -#[test] -fn proper_initialization() { - setup(); -} - -#[test] -fn proxy() { - let env = setup(); - - let foo: String = env - .router - .wrap() - .query_wasm_smart(&env.proxy_addr, &FooQueryMsg::Foo) - .unwrap(); - - assert_eq!(foo, "foo"); -} diff --git a/crates/query-proxy/src/lib.rs b/crates/query-proxy/src/lib.rs deleted file mode 100644 index 6c2de228a..000000000 --- a/crates/query-proxy/src/lib.rs +++ /dev/null @@ -1,200 +0,0 @@ -mod integration_tests; - -use cosmwasm_std::{Addr, StdError}; -use cw_storage_plus::{Item, Map, U16Key}; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use owned::Auth; - -#[derive(Error, Debug)] -pub enum ContractError { - #[error("{0}")] - Std(#[from] StdError), - - #[error("{0}")] - Owned(#[from] owned::Error), - - #[error("Unauthorized")] - Unauthorized, - - #[error("Invalid")] - Invalid, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] -pub struct Phase { - pub id: u16, - pub contract_address: Addr, -} - -pub const OWNER: Auth = Auth::new("owner"); -pub const CURRENT_PHASE: Item = Item::new("current_phase"); -pub const PROPOSED_CONTRACT: Item = Item::new("proposed_contract"); -pub const PHASES: Map = Map::new("phases"); - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] -pub struct InstantiateMsg { - pub contract_address: String, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ExecuteMsg { - ProposeContract { - address: String, - }, - ConfirmContract { - address: String, - }, - /// Initiate contract ownership transfer to another address. - /// Can be used only by owner - TransferOwnership { - /// Address to transfer ownership to - to: String, - }, - /// Finish contract ownership transfer. Can be used only by pending owner - AcceptOwnership, -} - -#[macro_export] -macro_rules! contract { - ($query_msg:path) => { - use cosmwasm_std::{ - entry_point, to_binary, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response, - StdResult, - }; - - pub use $crate::{ContractError, Phase, CURRENT_PHASE, OWNER, PHASES, PROPOSED_CONTRACT}; - - pub mod msg { - pub type QueryMsg = $query_msg; - pub use $crate::{ExecuteMsg, InstantiateMsg}; - // Wraps a query. Passed through transparently. - } - - use msg::*; - - // version info for migration info - const CONTRACT_NAME: &str = "crates.io:query-proxy"; - const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); - - #[entry_point] - pub fn instantiate( - deps: DepsMut, - _env: Env, - info: MessageInfo, - msg: InstantiateMsg, - ) -> Result { - let contract_address = deps.api.addr_validate(&msg.contract_address)?; - - cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - - OWNER.initialize(deps.storage, info.sender)?; - - PHASES.save(deps.storage, 0.into(), &contract_address)?; - CURRENT_PHASE.save( - deps.storage, - &Phase { - id: 0, - contract_address, - }, - )?; - - Ok(Response::default()) - } - - #[entry_point] - pub fn execute( - deps: DepsMut, - env: Env, - info: MessageInfo, - msg: ExecuteMsg, - ) -> Result { - let api = deps.api; - match msg { - ExecuteMsg::ProposeContract { address } => { - let address = deps.api.addr_validate(&address)?; - validate_ownership(deps.as_ref(), &env, info)?; - PROPOSED_CONTRACT.save(deps.storage, &address)?; - Ok(Response::default()) - } - ExecuteMsg::ConfirmContract { address } => { - let address = deps.api.addr_validate(&address)?; - validate_ownership(deps.as_ref(), &env, info)?; - - // Validate the address was actually proposed previously - let proposed = PROPOSED_CONTRACT.load(deps.storage)?; - if proposed != address { - return Err(ContractError::Invalid); - } - - // Update state - PROPOSED_CONTRACT.remove(deps.storage); - let current_phase = - CURRENT_PHASE.update(deps.storage, |mut phase| -> StdResult { - phase.id += 1; - phase.contract_address = address; - Ok(phase) - })?; - PHASES.save( - deps.storage, - current_phase.id.into(), - ¤t_phase.contract_address, - )?; - - Ok(Response::default()) - } - ExecuteMsg::TransferOwnership { to } => { - Ok(OWNER.execute_transfer_ownership(deps, info, api.addr_validate(&to)?)?) - } - ExecuteMsg::AcceptOwnership => Ok(OWNER.execute_accept_ownership(deps, info)?), - } - } - - /// Delegate queries to the current contract. This is slightly less efficient than it could be if we used a raw entrypoint because - /// we deserialize the msg, then serialize it again, but that required us to copy a lot of private modules from cosmwasm_std. - #[entry_point] - pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result { - let contract_address = CURRENT_PHASE.load(deps.storage)?.contract_address; - - // This recreates QuerierWrapper::query/custom_query without to_binary on the result - use cosmwasm_std::{ - to_vec, ContractResult, Empty, QueryRequest, StdError, SystemResult, WasmQuery, - }; - let request: QueryRequest = WasmQuery::Smart { - contract_addr: contract_address.into(), - msg: to_binary(&msg)?, - } - .into(); - let raw = to_vec(&request).map_err(|serialize_err| { - StdError::generic_err(format!("Serializing QueryRequest: {}", serialize_err)) - })?; - match deps.querier.raw_query(&raw) { - SystemResult::Err(system_err) => Err(StdError::generic_err(format!( - "Querier system error: {}", - system_err - )) - .into()), - SystemResult::Ok(ContractResult::Err(contract_err)) => Err(StdError::generic_err( - format!("Querier contract error: {}", contract_err), - ) - .into()), - SystemResult::Ok(ContractResult::Ok(value)) => Ok(value), - } - } - - fn validate_ownership( - deps: Deps, - _env: &Env, - info: MessageInfo, - ) -> Result<(), ContractError> { - if !OWNER.is_owner(deps, &info.sender)? { - return Err(ContractError::Unauthorized); - } - Ok(()) - } - }; -} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..177c17759 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +# Chainlink Terra Integration + +This repository is a monorepo of the various components required for Chainlink on Terra. + +- [Terra Contracts](../contracts) +- [Terra CL Relay](../pkg/terra) +- [Terra Gauntlet](../packages-ts) +- [Terra On-chain Monitoring](../pkg/monitoring) +- [Ops](../ops) +- [Integration/E2E Tests](../tests/e2e) +- [Demos & Examples](../examples) + +# Local asdf initial setup + + asdf plugin-add golang https://github.com/kennyp/asdf-golang.git + # for other golang requirements for your os go to https://github.com/kennyp/asdf-golang + asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git + + # Then run + asdf install diff --git a/docs/RunningE2eTests.md b/docs/RunningE2eTests.md new file mode 100644 index 000000000..7ca064051 --- /dev/null +++ b/docs/RunningE2eTests.md @@ -0,0 +1,19 @@ +# Running e2e tests + +The e2e tests run inside of a k8s cluster. They will run against whatever cluster your current kubectl context is set to. This can be an external k8s cluster or a local one (using something like minikube or k3d). + +Note: If running against a local k8s cluster, make sure you have plenty of ram allocated for docker, 12 gb if running individual tests and a lot more if you run parallel test like the ones in `make test_smoke` since it runs multiple tests in parallel + +Steps to run the e2e tests: + +1. Build using the `make build` command. If you have previously built and made changes to artficats and want to make sure you have a clean starting point you can run `make artifacts_clean` before building. +2. Make sure your kubectl context is pointing to the cluster you want to run tests against. +3. Run a test, you have several options + - `make test_smoke` will run the ocr2, ocr2 proxy, and gauntlet e2e tests (all three run in parallel) + - `make test_ocr` will run the ocr2 e2e tests + - `make test_ocr_proxy` will run the ocr2 tests using a proxy + - `make test_gauntlet` will run the gauntlet e2e tests + - `make test_migration` will run the migration e2e tests + - `make test_chaos` will run the chaos tests + +You can always look at the [Makefile](../Makefile) in this repo to see other commands or tests that have been added since this readme was last updated. diff --git a/crates/query-proxy/Cargo.toml b/examples/hello-world/Cargo.toml similarity index 80% rename from crates/query-proxy/Cargo.toml rename to examples/hello-world/Cargo.toml index 29b2b2c53..bef66f2c7 100644 --- a/crates/query-proxy/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "query-proxy" +name = "hello-world" version = "0.1.0" authors = ["Blaž Hrastnik "] edition = "2018" @@ -22,7 +22,8 @@ cw2 = "0.9.0" schemars = "0.8.3" serde = { version = "1.0.103", default-features = false, features = ["derive"] } thiserror = { version = "1.0.24" } -owned = { path = "../../crates/owned", default-features = false, features = ["library"] } + +chainlink-terra = { version = "0.1.0", package = "proxy-ocr2", path = "../../contracts/proxy-ocr2", default-features = false, features = ["library"] } [dev-dependencies] cosmwasm-schema = { version = "0.16.0" } diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md new file mode 100644 index 000000000..49ce6e37f --- /dev/null +++ b/examples/hello-world/README.md @@ -0,0 +1 @@ +# Chainlink Hello World Example diff --git a/examples/hello-world/examples/schema.rs b/examples/hello-world/examples/schema.rs new file mode 100644 index 000000000..31709957c --- /dev/null +++ b/examples/hello-world/examples/schema.rs @@ -0,0 +1,17 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use hello_world::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); +} diff --git a/examples/hello-world/src/contract.rs b/examples/hello-world/src/contract.rs new file mode 100644 index 000000000..561fa09e7 --- /dev/null +++ b/examples/hello-world/src/contract.rs @@ -0,0 +1,111 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{attr, to_binary, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response}; + +use crate::error::ContractError; +use crate::msg::*; +use crate::state::*; + +use chainlink_terra::msg::QueryMsg as ChainlinkQueryMsg; +use chainlink_terra::state::Round; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:hello-world"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Used to format the raw answer value as a human readable string. +struct Decimal { + pub value: i128, + pub decimals: u32, +} + +impl Decimal { + pub fn new(value: i128, decimals: u32) -> Self { + Decimal { value, decimals } + } +} + +impl std::fmt::Display for Decimal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut scaled_val = self.value.to_string(); + if scaled_val.len() <= self.decimals as usize { + scaled_val.insert_str( + 0, + &vec!["0"; self.decimals as usize - scaled_val.len()].join(""), + ); + scaled_val.insert_str(0, "0."); + } else { + scaled_val.insert(scaled_val.len() - self.decimals as usize, '.'); + } + f.write_str(&scaled_val) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let feed = deps.api.addr_validate(&msg.feed)?; + + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let decimals = deps + .querier + .query_wasm_smart(&feed, &ChainlinkQueryMsg::Decimals)?; + + CONFIG.save(deps.storage, &Config { feed, decimals })?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Run {} => execute_run(deps, env, info), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::Decimals {} => Ok(to_binary(&query_decimals(deps)?)?), + QueryMsg::Round {} => Ok(to_binary(&query_round(deps)?)?), + } +} + +fn execute_run(deps: DepsMut, _env: Env, _info: MessageInfo) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Query the oracle network + let round = deps + .querier + .query_wasm_smart(config.feed, &ChainlinkQueryMsg::LatestRoundData)?; + + PRICE.save(deps.storage, &round)?; + + let decimal = Decimal::new(round.answer, u32::from(config.decimals)); + + Ok(Response::new().add_attributes(vec![ + attr("price", decimal.to_string()), + attr("observations_timestamp", round.answer.to_string()), + attr("transmissions_timestamp", round.answer.to_string()), + ])) +} + +fn query_round(deps: Deps) -> Result { + let round = PRICE.load(deps.storage)?; + Ok(round) +} + +fn query_decimals(deps: Deps) -> Result { + let config = CONFIG.load(deps.storage)?; + Ok(config.decimals) +} diff --git a/examples/hello-world/src/error.rs b/examples/hello-world/src/error.rs new file mode 100644 index 000000000..40dab9ff3 --- /dev/null +++ b/examples/hello-world/src/error.rs @@ -0,0 +1,8 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), +} diff --git a/examples/hello-world/src/integration_tests.rs b/examples/hello-world/src/integration_tests.rs new file mode 100644 index 000000000..dfda60206 --- /dev/null +++ b/examples/hello-world/src/integration_tests.rs @@ -0,0 +1,139 @@ +#![cfg(test)] +#![cfg(not(tarpaulin_include))] +use crate::contract::{execute, instantiate, query}; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use cosmwasm_std::{Addr, Empty}; +use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper, Executor}; + +fn mock_app() -> App { + AppBuilder::new().build() +} + +pub fn contract_hello_world() -> Box> { + let contract = ContractWrapper::new(execute, instantiate, query); + Box::new(contract) +} + +struct Env { + router: App, + owner: Addr, + hello_world_addr: Addr, +} + +mod mock { + use cosmwasm_std::{ + to_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult, + }; + use cw_multi_test::{Contract, ContractWrapper}; + + use chainlink_terra::msg::QueryMsg as ChainlinkQueryMsg; + use chainlink_terra::state::Round; + + pub type InstantiateMsg = (); + pub type ExecuteMsg = (); + + pub const DECIMALS: u8 = 8; + pub const ROUND: Round = Round { + round_id: 1, + answer: 1, + observations_timestamp: 1, + transmission_timestamp: 1, + }; + + pub fn contract() -> Box> { + pub fn instantiate( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: InstantiateMsg, + ) -> StdResult { + Ok(Response::default()) + } + pub fn execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: ExecuteMsg, + ) -> StdResult { + unimplemented!() + } + pub fn query(_deps: Deps, _env: Env, msg: ChainlinkQueryMsg) -> StdResult { + match msg { + ChainlinkQueryMsg::Decimals => to_binary(&DECIMALS), + ChainlinkQueryMsg::LatestRoundData => to_binary(&ROUND), + _ => unimplemented!(), + } + } + let contract = ContractWrapper::new(execute, instantiate, query); + Box::new(contract) + } +} + +fn setup() -> Env { + let mut router = mock_app(); + + let owner = Addr::unchecked("owner"); + + let hello_world_id = router.store_code(contract_hello_world()); + let proxy_id = router.store_code(mock::contract()); + + let proxy_addr = router + .instantiate_contract(proxy_id, owner.clone(), &(), &[], "hello_world", None) + .unwrap(); + + let hello_world_addr = router + .instantiate_contract( + hello_world_id, + owner.clone(), + &InstantiateMsg { + feed: proxy_addr.to_string(), + }, + &[], + "hello_world", + None, + ) + .unwrap(); + + Env { + router, + owner, + hello_world_addr, + } +} + +#[test] +fn proper_initialization() { + setup(); +} + +#[test] +fn it_works() { + let mut env = setup(); + + // execute run() + assert!(env + .router + .execute_contract( + env.owner.clone(), + env.hello_world_addr.clone(), + &ExecuteMsg::Run {}, + &[], + ) + .is_ok()); + + // query round + let round: chainlink_terra::state::Round = env + .router + .wrap() + .query_wasm_smart(&env.hello_world_addr, &QueryMsg::Round {}) + .unwrap(); + assert_eq!(mock::ROUND, round); + + // query decimals + let decimals: u8 = env + .router + .wrap() + .query_wasm_smart(&env.hello_world_addr, &QueryMsg::Decimals {}) + .unwrap(); + assert_eq!(mock::DECIMALS, decimals); +} diff --git a/examples/hello-world/src/lib.rs b/examples/hello-world/src/lib.rs new file mode 100644 index 000000000..275338655 --- /dev/null +++ b/examples/hello-world/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +pub mod error; +mod integration_tests; +pub mod msg; +pub mod state; diff --git a/examples/hello-world/src/msg.rs b/examples/hello-world/src/msg.rs new file mode 100644 index 000000000..f01012d95 --- /dev/null +++ b/examples/hello-world/src/msg.rs @@ -0,0 +1,20 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct InstantiateMsg { + pub feed: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + Run {}, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + Decimals {}, + Round {}, +} diff --git a/examples/hello-world/src/state.rs b/examples/hello-world/src/state.rs new file mode 100644 index 000000000..45fccb481 --- /dev/null +++ b/examples/hello-world/src/state.rs @@ -0,0 +1,17 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::Addr; +use cw_storage_plus::Item; + +use chainlink_terra::state::Round; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + pub feed: Addr, + pub decimals: u8, +} + +pub const CONFIG: Item = Item::new("config"); + +pub const PRICE: Item = Item::new("price"); diff --git a/examples/spec/ocr2-bootstrap.spec.toml b/examples/spec/ocr2-bootstrap.spec.toml index 24dc4a829..5b98f45e6 100644 --- a/examples/spec/ocr2-bootstrap.spec.toml +++ b/examples/spec/ocr2-bootstrap.spec.toml @@ -1,12 +1,9 @@ -type = "offchainreporting2" +type = "bootstrap" schemaVersion = 1 relay = "terra" name = "" contractID = "" -isBootstrapPeer = true p2pPeerID = "" # optional, overrides P2P_PEER_ID -ocrKeyBundleID = "" # optional, overrides OCR2_KEY_BUNDLE_ID -transmitterID = "" [relayConfig] chainID = "bombay-12" diff --git a/examples/spec/ocr2-oracle-simple.spec.toml b/examples/spec/ocr2-oracle-simple.spec.toml index af18a34cf..b6b4db8e1 100644 --- a/examples/spec/ocr2-oracle-simple.spec.toml +++ b/examples/spec/ocr2-oracle-simple.spec.toml @@ -1,9 +1,9 @@ type = "offchainreporting2" +pluginType = "median" schemaVersion = 1 relay = "terra" name = "" contractID = "" -isBootstrapPeer = false p2pBootstrapPeers = ["somep2pkey@localhost-tcp:port"] # optional, overrides P2PV2_BOOTSTRAPPERS p2pPeerID = "" # optional, overrides P2P_PEER_ID ocrKeyBundleID = "" # optional, overrides OCR2_KEY_BUNDLE_ID @@ -15,6 +15,8 @@ observationSource = """ ds1_multiply [type="multiply" times=100000000] ds1 -> ds1_parse -> ds1_multiply """ + +[pluginConfig] juelsPerFeeCoinSource = """ // Fetch the LINK price from a data source // data source 1 diff --git a/examples/spec/ocr2-oracle.spec.toml b/examples/spec/ocr2-oracle.spec.toml index 9f772054e..355852a2f 100644 --- a/examples/spec/ocr2-oracle.spec.toml +++ b/examples/spec/ocr2-oracle.spec.toml @@ -1,9 +1,9 @@ type = "offchainreporting2" +pluginType = "median" schemaVersion = 1 relay = "terra" name = "" contractID = "" -isBootstrapPeer = false p2pBootstrapPeers = ["somep2pkey@localhost-tcp:port"] # optional, overrides P2PV2_BOOTSTRAPPERS p2pPeerID = "" # optional, overrides P2P_PEER_ID ocrKeyBundleID = "" # optional, overrides OCR2_KEY_BUNDLE_ID @@ -26,6 +26,8 @@ observationSource = """ ds3 -> ds3_parse -> ds3_multiply -> answer answer [type="median" index=0] """ + +[pluginConfig] juelsPerFeeCoinSource = """ // Fetch the LINK price from three data sources // data source 1 diff --git a/go.mod b/go.mod index 6a518790c..644ba7ce3 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,17 @@ go 1.17 require ( github.com/cosmos/cosmos-sdk v0.44.5 - github.com/onsi/ginkgo/v2 v2.0.0 - github.com/onsi/gomega v1.17.0 + github.com/onsi/ginkgo/v2 v2.1.2 + github.com/onsi/gomega v1.18.1 github.com/pelletier/go-toml v1.9.4 github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.12.1 github.com/rs/zerolog v1.26.1 github.com/satori/go.uuid v1.2.0 github.com/smartcontractkit/chainlink v1.1.1-0.20220215214847-93630cf8c733 - github.com/smartcontractkit/chainlink-relay v0.0.0-20220216143252-cf2fe41e5b92 + github.com/smartcontractkit/chainlink-relay v0.0.0-20220307003623-2fd8786a6e1f github.com/smartcontractkit/helmenv v1.0.36 - github.com/smartcontractkit/integrations-framework v1.0.48 + github.com/smartcontractkit/integrations-framework v1.0.50 github.com/smartcontractkit/libocr v0.0.0-20220125200954-5b957c834276 github.com/smartcontractkit/terra.go v1.0.3-0.20220108002221-62b39252ee16 github.com/stretchr/testify v1.7.0 @@ -244,7 +245,6 @@ require ( github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.12.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/procfs v0.7.3 // indirect diff --git a/go.sum b/go.sum index 2813ad14d..76179a254 100644 --- a/go.sum +++ b/go.sum @@ -1897,8 +1897,9 @@ github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvw github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.0.0 h1:CcuG/HvWNkkaqCUpJifQY8z7qEMBJya6aLPx6ftGyjQ= github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.1.2 h1:QUvZA5LiZ5EMDS0dVTQbjOvYLFs3wzcztqFU/mfR70c= +github.com/onsi/ginkgo/v2 v2.1.2/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= @@ -1912,8 +1913,9 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= -github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/openconfig/gnmi v0.0.0-20190823184014-89b2bf29312c/go.mod h1:t+O9It+LKzfOAhKTT5O0ehDix+MTqbtT0T9t+7zzOvc= github.com/openconfig/reference v0.0.0-20190727015836-8dfd928c9696/go.mod h1:ym2A+zigScwkSEb/cVQB0/ZMpU3rqiH6X7WRRsxgOGw= @@ -2019,8 +2021,8 @@ github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3O github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.8.0/go.mod h1:O9VU6huf47PktckDQfMTX0Y8tY0/7TSWwj+ITvv0TnM= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg= -github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= +github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= @@ -2179,15 +2181,15 @@ github.com/smartcontractkit/chainlink v0.8.10-0.20200825114219-81dd2fc95bac/go.m github.com/smartcontractkit/chainlink v0.9.5-0.20201207211610-6c7fee37d5b7/go.mod h1:kmdLJbVZRCnBLiL6gG+U+1+0ofT3bB48DOF8tjQvcoI= github.com/smartcontractkit/chainlink v1.1.1-0.20220215214847-93630cf8c733 h1:98E+/UxVe5dqmke+XsPK9fWiE9nJ35KYFS4JqmEzUV4= github.com/smartcontractkit/chainlink v1.1.1-0.20220215214847-93630cf8c733/go.mod h1:ExXVFKbuu8KgU1TVftSy5NuLc1UCxkqeXn2T+IPEG7Q= -github.com/smartcontractkit/chainlink-relay v0.0.0-20220216143252-cf2fe41e5b92 h1:Q3vUQ/eLcsRM7c1FelekSHXRyuokM+XeWqrh9jwpvCU= -github.com/smartcontractkit/chainlink-relay v0.0.0-20220216143252-cf2fe41e5b92/go.mod h1:/6gP9XHTQEBeBnXtC9oF1VelYhPMtGDu+6LxLtFP01g= +github.com/smartcontractkit/chainlink-relay v0.0.0-20220307003623-2fd8786a6e1f h1:Lctf/SspqDaoOgyTUhu/xVyt1clD+msko1OZDtrxt1Q= +github.com/smartcontractkit/chainlink-relay v0.0.0-20220307003623-2fd8786a6e1f/go.mod h1:/6gP9XHTQEBeBnXtC9oF1VelYhPMtGDu+6LxLtFP01g= github.com/smartcontractkit/chainlink-solana v0.2.10-0.20220208192802-307c6fba76f0 h1:A9Bw3yZu9bOwGt5krstlPrpUK+j4SRCEfWm+az225Yw= github.com/smartcontractkit/ed25519consensus v0.0.1 h1:Ta23Y6YJTACLCpKWSWAwgHCtkmWrGGfrUAV8Ns5r4z0= github.com/smartcontractkit/ed25519consensus v0.0.1/go.mod h1:8Wf0F4mu3B5DdEOFKMMLgSoZ8EyaA2Cmf4YMUsNHXzE= github.com/smartcontractkit/helmenv v1.0.36 h1:RSgEyvWstAuH5BCssPeSeDrxywvAFHP8sqw2Ft55VaQ= github.com/smartcontractkit/helmenv v1.0.36/go.mod h1:pkScfFRZM9UhMAqeG+Bfxyy7YjLWh8pY6vmNGdTpKJs= -github.com/smartcontractkit/integrations-framework v1.0.48 h1:yeGBbu+AXlkR4kT4tKound3dItFMDMTczIirdX48lJ4= -github.com/smartcontractkit/integrations-framework v1.0.48/go.mod h1:tWnoNol2k1yDW8ybOqK7QXFp6Xl2wnI6ASn398JPMJA= +github.com/smartcontractkit/integrations-framework v1.0.50 h1:r9f0pRWGOT4CcPUx6XAf+LRON8SDbda3zbf2kR1O8WQ= +github.com/smartcontractkit/integrations-framework v1.0.50/go.mod h1:IZyYezzgpwa1Ir3iZMjAnJfwg+pJB5kyExBiwZqQe9c= github.com/smartcontractkit/libocr v0.0.0-20201203233047-5d9b24f0cbb5/go.mod h1:bfdSuLnBWCkafDvPGsQ1V6nrXhg046gh227MKi4zkpc= github.com/smartcontractkit/libocr v0.0.0-20220125200954-5b957c834276 h1:sE+0R3qyCnZRiDoEoWT8St+Pmb1ZdRDhyq7xZPrwoZ0= github.com/smartcontractkit/libocr v0.0.0-20220125200954-5b957c834276/go.mod h1:nq3crM3wVqnyMlM/4ZydTuJ/WyCapAsOt7P94oRgSPg= diff --git a/ops/monitoring/Dockerfile b/ops/monitoring/Dockerfile index d9c49508b..43e08dee8 100644 --- a/ops/monitoring/Dockerfile +++ b/ops/monitoring/Dockerfile @@ -35,7 +35,7 @@ RUN apt-get update && apt-get install -y ca-certificates COPY --from=build /terra-monitoring/monitoring /monitoring # dependency of terra-money/core -COPY --from=build /root/go/pkg/mod/github.com/\!cosm\!wasm/wasmvm@v*/api/libwasmvm.so /usr/lib/libwasmvm.so +COPY --from=build /root/go/pkg/mod/github.com/\!cosm\!wasm/wasmvm@v*/api/libwasmvm.so /usr/lib/ RUN chmod 755 /usr/lib/libwasmvm.so # Expose prometheus default port diff --git a/packages-ts/gauntlet-terra-contracts/codeIds/bombay-testnet.json b/packages-ts/gauntlet-terra-contracts/codeIds/bombay-testnet.json deleted file mode 100644 index 08f1fecd7..000000000 --- a/packages-ts/gauntlet-terra-contracts/codeIds/bombay-testnet.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "flags": 39112, - "ocr2": 39113, - "access_controller": 39116, - "deviation_flagging_validator": 39115, - "cw3_flex_multisig": 36059, - "cw4_group": 36895, - "cw20_base": 41738 -} \ No newline at end of file diff --git a/packages-ts/gauntlet-terra-contracts/codeIds/local.json b/packages-ts/gauntlet-terra-contracts/codeIds/local.json index 5c2a01ca0..8906d6c66 100644 --- a/packages-ts/gauntlet-terra-contracts/codeIds/local.json +++ b/packages-ts/gauntlet-terra-contracts/codeIds/local.json @@ -5,4 +5,4 @@ "access_controller": 43, "cw3_flex_multisig": 44, "cw4_group": 45 -} \ No newline at end of file +} diff --git a/packages-ts/gauntlet-terra-contracts/codeIds/mainnet.json b/packages-ts/gauntlet-terra-contracts/codeIds/mainnet.json new file mode 100644 index 000000000..1b90d3a23 --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/codeIds/mainnet.json @@ -0,0 +1,8 @@ +{ + "access_controller": 2690, + "ocr2": 3024, + "proxy_ocr2": 3202, + "cw3_flex_multisig": 3192, + "cw4_group": 3193, + "cw20_base": 3 +} diff --git a/packages-ts/gauntlet-terra-contracts/codeIds/testnet-bombay-internal.json b/packages-ts/gauntlet-terra-contracts/codeIds/testnet-bombay-internal.json new file mode 100644 index 000000000..95824d596 --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/codeIds/testnet-bombay-internal.json @@ -0,0 +1,10 @@ +{ + "flags": 51109, + "ocr2": 51111, + "access_controller": 51113, + "deviation_flagging_validator": 51110, + "cw3_flex_multisig": 36059, + "cw4_group": 36895, + "cw20_base": 50820, + "proxy_ocr2": 51112 +} diff --git a/packages-ts/gauntlet-terra-contracts/codeIds/testnet-bombay.json b/packages-ts/gauntlet-terra-contracts/codeIds/testnet-bombay.json new file mode 100644 index 000000000..95824d596 --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/codeIds/testnet-bombay.json @@ -0,0 +1,10 @@ +{ + "flags": 51109, + "ocr2": 51111, + "access_controller": 51113, + "deviation_flagging_validator": 51110, + "cw3_flex_multisig": 36059, + "cw4_group": 36895, + "cw20_base": 50820, + "proxy_ocr2": 51112 +} diff --git a/packages-ts/gauntlet-terra-contracts/networks/.env.testnet-bombay b/packages-ts/gauntlet-terra-contracts/networks/.env.testnet-bombay new file mode 100644 index 000000000..b69450877 --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/networks/.env.testnet-bombay @@ -0,0 +1,6 @@ +NODE_URL=https://bombay-lcd.terra.dev +CHAIN_ID=bombay-12 +DEFAULT_GAS_PRICE=0.5 +LINK=terra167ccv2h0z7k0p8j6qpuzwsgu5au5qvfwgmkjsl +BILLING_ACCESS_CONTROLLER=terra1anfm045fgautt6mm5wc7uk6njx30n3cwlgvv8p +REQUESTER_ACCESS_CONTROLLER=terra1urknnpgcmgvptdmhdvex53hl72prwgcnn49t47 diff --git a/packages-ts/gauntlet-terra-contracts/networks/.env.bombay-testnet b/packages-ts/gauntlet-terra-contracts/networks/.env.testnet-bombay-internal similarity index 69% rename from packages-ts/gauntlet-terra-contracts/networks/.env.bombay-testnet rename to packages-ts/gauntlet-terra-contracts/networks/.env.testnet-bombay-internal index 15fc2f61a..f659c2de2 100644 --- a/packages-ts/gauntlet-terra-contracts/networks/.env.bombay-testnet +++ b/packages-ts/gauntlet-terra-contracts/networks/.env.testnet-bombay-internal @@ -4,5 +4,6 @@ DEFAULT_GAS_PRICE=0.5 LINK=terra1fcksmfjncl6m7apvpalvhwv5jxd9djv5lwyu82 BILLING_ACCESS_CONTROLLER=terra1trcufj64y53hxk7g8cra33xw3jkyvlr9lu99eu REQUESTER_ACCESS_CONTROLLER=terra1s38kfu4qp0ttwxkka9zupaysefl5qruhv5rc0z -MULTISIG_GROUP=terra168lv95kfm49y9zu0409jmplj756ukxdrew7uta -MULTISIG_WALLET=terra1u89pduw4enewduy9qydj925738cyn9juszgj54 + +CW4_GROUP=terra1edk45cc6rckjszfmr87qfx50pfn2mnhg5mn3vd +CW3_FLEX_MULTISIG=terra1cql0r4csmce0ntf68dmkvu3negs7m662uyg90g diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts index ca24ed2c8..5a0c3f1f2 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts @@ -1,8 +1,28 @@ import AbstractCommand, { makeAbstractCommand } from '.' import { Result } from '@chainlink/gauntlet-core' import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' +import { AccAddress, LCDClient } from '@terra-money/terra.js' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' + +export type ExecutionContext = { + input: Input + contractInput: ContractInput + id: string + contract: string + provider: LCDClient + flags: any +} + +export type BeforeExecute = ( + context: ExecutionContext, +) => (signer: AccAddress) => Promise + +export type AfterExecute = ( + context: ExecutionContext, +) => (response: Result) => Promise export interface AbstractInstruction { + examples?: string[] instruction: { category: string contract: string @@ -11,35 +31,72 @@ export interface AbstractInstruction { makeInput: (flags: any, args: string[]) => Promise validateInput: (input: Input) => boolean makeContractInput: (input: Input) => Promise - afterExecute?: (response: Result) => any + beforeExecute?: BeforeExecute + afterExecute?: AfterExecute +} + +const defaultBeforeExecute = (context: ExecutionContext) => async () => { + logger.loading(`Executing ${context.id} from contract ${context.contract}`) + logger.log('Input Params:', context.contractInput) + await prompt(`Continue?`) } -export const instructionToCommand = (instruction: AbstractInstruction) => { +export const instructionToCommand = (instruction: AbstractInstruction) => { const id = `${instruction.instruction.contract}:${instruction.instruction.function}` const category = `${instruction.instruction.category}` + const examples = instruction.examples || [] + return class Command extends TerraCommand { static id = id static category = category + static examples = examples + command: AbstractCommand constructor(flags, args) { super(flags, args) } - execute = async (): Promise> => { - const commandInput = await instruction.makeInput(this.flags, this.args) - if (!instruction.validateInput(commandInput)) { - throw new Error(`Invalid input params: ${JSON.stringify(commandInput)}`) + buildCommand = async (flags, args): Promise => { + const input = await instruction.makeInput(flags, args) + if (!instruction.validateInput(input)) { + throw new Error(`Invalid input params: ${JSON.stringify(input)}`) } - const input = await instruction.makeContractInput(commandInput) - const abstractCommand = await makeAbstractCommand(id, this.flags, this.args, input) - await abstractCommand.invokeMiddlewares(abstractCommand, abstractCommand.middlewares) - let response = await abstractCommand.execute() - if (instruction.afterExecute) { - const data = instruction.afterExecute(response) - response = { ...response, data: { ...data } } + const contractInput = await instruction.makeContractInput(input) + const executionContext: ExecutionContext = { + input, + contractInput, + id, + contract: this.args[0], + provider: this.provider, + flags, } - return response + this.beforeExecute = instruction.beforeExecute + ? instruction.beforeExecute(executionContext) + : defaultBeforeExecute(executionContext) + + this.afterExecute = instruction.afterExecute ? instruction.afterExecute(executionContext) : this.afterExecute + + const abstractCommand = await makeAbstractCommand(id, this.flags, this.args, contractInput) + await abstractCommand.invokeMiddlewares(abstractCommand, abstractCommand.middlewares) + this.command = abstractCommand + + return this + } + + makeRawTransaction = async (signer: AccAddress) => { + return this.command.makeRawTransaction(signer) + } + + execute = async (): Promise> => { + // TODO: Command should be built from gauntet-core + await this.buildCommand(this.flags, this.args) + await this.command.simulateExecute() + await this.beforeExecute(this.wallet.key.accAddress) + + let response = await this.command.execute() + const data = await this.afterExecute(response) + return !!data ? { ...response, data: { ...data } } : response } } } diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts index df11a0ca2..fba9c4363 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts @@ -1,4 +1,5 @@ import { Result } from '@chainlink/gauntlet-core' +import { AccAddress, MsgExecuteContract } from '@terra-money/terra.js' import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' import { TransactionResponse, TerraCommand } from '@chainlink/gauntlet-terra' import { Contract, CONTRACT_LIST, getContract, TerraABI, TERRA_OPERATIONS } from '../../lib/contracts' @@ -20,7 +21,7 @@ export const makeAbstractCommand = async ( flags: any, args: string[], input?: any, -): Promise => { +): Promise => { const commandOpts = await parseInstruction(instruction, flags.version) const params = parseParams(commandOpts, input || flags) return new AbstractCommand(flags, args, commandOpts, params) @@ -34,16 +35,21 @@ export const parseInstruction = async (instruction: string, inputVersion: string const isValidFunction = (abi: TerraABI, functionName: string): boolean => { // Check against ABI if method exists - const availableFunctions = [...(abi.query.oneOf || []), ...(abi.execute.oneOf || [])].reduce((agg, prop) => { + const availableFunctions = [ + ...(abi.query.oneOf || abi.query.anyOf || []), + ...(abi.execute.oneOf || abi.execute.anyOf || []), + ].reduce((agg, prop) => { if (prop?.required && prop.required.length > 0) return [...agg, ...prop.required] if (prop?.enum && prop.enum.length > 0) return [...agg, ...prop.enum] return [...agg] }, []) + logger.debug(`Available functions on this contract: ${availableFunctions}`) return availableFunctions.includes(functionName) } const isQueryFunction = (abi: TerraABI, functionName: string) => { - return abi.query.oneOf.find((queryAbi: any) => { + const functionList = abi.query.oneOf || abi.query.anyOf + return functionList.find((queryAbi: any) => { if (queryAbi.enum) return queryAbi.enum.includes(functionName) if (queryAbi.required) return queryAbi.required.includes(functionName) return false @@ -125,6 +131,11 @@ export default class AbstractCommand extends TerraCommand { this.contracts = [this.opts.contract.id] } + makeRawTransaction = async (signer: AccAddress): Promise => { + const address = this.args[0] + return new MsgExecuteContract(signer, address, this.params) + } + abstractDeploy: AbstractExecute = async (params: any) => { logger.loading(`Deploying contract ${this.opts.contract.id}`) const codeId = this.codeIds[this.opts.contract.id] @@ -142,11 +153,9 @@ export default class AbstractCommand extends TerraCommand { } abstractExecute: AbstractExecute = async (params: any, address: string) => { - logger.loading(`Executing ${this.opts.function} from contract ${this.opts.contract.id} at ${address}`) - logger.log('Input Params:', params) - await prompt(`Continue?`) + logger.debug(`Executing ${this.opts.function} from contract ${this.opts.contract.id} at ${address}`) const tx = await this.call(address, params) - logger.success(`Execution finished at tx ${tx.hash}`) + logger.debug(`Execution finished at tx ${tx.hash}`) return { responses: [ { @@ -158,7 +167,7 @@ export default class AbstractCommand extends TerraCommand { } abstractQuery: AbstractExecute = async (params: any, address: string) => { - logger.loading(`Calling ${this.opts.function} from contract ${this.opts.contract.id} at ${address}`) + logger.debug(`Calling ${this.opts.function} from contract ${this.opts.contract.id} at ${address}`) const result = await this.query(address, params) logger.debug(`Query finished with result: ${JSON.stringify(result)}`) return { @@ -181,6 +190,23 @@ export default class AbstractCommand extends TerraCommand { } } + simulateExecute = async () => { + if (this.opts.action !== TERRA_OPERATIONS.EXECUTE) { + logger.info('Skipping tx simulation for non-execute operation') + return + } + + const signer = this.wallet.key.accAddress // signer is the default loaded wallet + const contractAddress = this.args[0] + const input = this.params + const msg = new MsgExecuteContract(signer, contractAddress, input) + logger.loading(`Executing tx simulation for ${this.opts.contract.id}:${this.opts.function} at ${contractAddress}`) + + const estimatedGas = await this.simulate(signer, [msg]) + logger.info(`Tx simulation successful: estimated gas usage is ${estimatedGas}`) + return estimatedGas + } + execute = async () => { const operations = { [TERRA_OPERATIONS.DEPLOY]: this.abstractDeploy, diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts index af08a1954..5848d4336 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts @@ -1,14 +1,10 @@ -import AbstractCommand, { makeAbstractCommand } from '.' +import { makeAbstractCommand } from '.' import { Result } from '@chainlink/gauntlet-core' import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' import { logger } from '@chainlink/gauntlet-core/dist/utils' import { CATEGORIES } from '../../lib/constants' import { CONTRACT_LIST } from '../../lib/contracts' - -export type InspectionInput = { - commandInput?: CommandInput - expected: Expected -} +import { LCDClient } from '@terra-money/terra.js' /** * Inspection commands need to match this interface @@ -17,11 +13,12 @@ export type InspectionInput = { * id: Name of the command the user will execute * } * instructions: instruction[] Set of abstract query commands the inspection command will run - * makeInput: Receives flags and args. Should return the input the underneath commands, and the expected result we want + * makeInput: Receives flags and args. Should return the input the underneath commands + * makeInspectionInput: Transforms input into a comparable format * makeOnchainData: Parses every instruction command result to match the same interface the Inspection command expects * inspect: Compares both expected and onchain data. */ -export interface InspectInstruction { +export interface InspectInstruction { command: { category: CATEGORIES contract: CONTRACT_LIST @@ -31,9 +28,12 @@ export interface InspectInstruction { contract: string function: string }[] - makeInput: (flags: any, args: string[]) => Promise> - makeOnchainData: (instructionsData: any[]) => Expected - inspect: (expected: Expected, data: Expected) => boolean + makeInput: (flags: any, args: string[]) => Promise + makeInspectionData: (provider: LCDClient) => (input: CommandInput) => Promise + makeOnchainData: ( + provider: LCDClient, + ) => (instructionsData: any[], input: CommandInput, contractAddress: string) => Promise + inspect: (expected: ContractExpectedInfo, data: ContractExpectedInfo) => boolean } export const instructionToInspectCommand = ( @@ -47,16 +47,15 @@ export const instructionToInspectCommand = ( super(flags, args) } + makeRawTransaction = () => { + throw new Error('Inspection command does not involve any transaction') + } + execute = async (): Promise> => { const input = await inspectInstruction.makeInput(this.flags, this.args) const commands = await Promise.all( inspectInstruction.instructions.map((instruction) => - makeAbstractCommand( - `${instruction.contract}:${instruction.function}`, - this.flags, - this.args, - input.commandInput, - ), + makeAbstractCommand(`${instruction.contract}:${instruction.function}`, this.flags, this.args, input), ), ) @@ -69,8 +68,9 @@ export const instructionToInspectCommand = ( }), ) - const onchainData = inspectInstruction.makeOnchainData(data) - const inspection = inspectInstruction.inspect(input.expected, onchainData) + const onchainData = await inspectInstruction.makeOnchainData(this.provider)(data, input, this.args[0]) + const inspectData = await inspectInstruction.makeInspectionData(this.provider)(input) + const inspection = inspectInstruction.inspect(inspectData, onchainData) return { data: inspection, responses: [ diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/group.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/deploy.ts similarity index 87% rename from packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/group.ts rename to packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/deploy.ts index d204a41fe..cc4c0cb7f 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/group.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/deploy.ts @@ -4,7 +4,7 @@ import { AbstractInstruction, instructionToCommand } from '../../abstract/execut type CommandInput = { owners: string[] - admin?: string + admin: string } type ContractInput = { @@ -12,7 +12,7 @@ type ContractInput = { addr: string weight: number }[] - admin?: string + admin: string } const makeCommandInput = async (flags: any, args: any[]): Promise => { @@ -28,7 +28,7 @@ const validateInput = (input: CommandInput): boolean => { } const areValidOwners = input.owners.filter((owner) => !isValidAddress(owner)).length === 0 if (!areValidOwners) throw new Error('Owners are not valid') - if (input.admin && !isValidAddress(input.admin)) throw new Error('Admin is not valid') + if (!isValidAddress(input.admin)) throw new Error('Admin is not valid') return true } @@ -43,8 +43,8 @@ const makeContractInput = async (input: CommandInput): Promise => } } -// yarn gauntlet cw4_group:deploy --network=bombay-testnet const createGroupInstruction: AbstractInstruction = { + examples: ['yarn gauntlet cw4_group:deploy --network=bombay-testnet --admin= '], instruction: { category: CATEGORIES.MULTISIG, contract: 'cw4_group', diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/index.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/index.ts new file mode 100644 index 000000000..4b785f3aa --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/index.ts @@ -0,0 +1,5 @@ +import { CreateGroup } from './deploy' +import { UpdateMembers } from './updateMembers' +import { UpdateAdmin } from './updateAdmin' + +export default [CreateGroup, UpdateMembers, UpdateAdmin] diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/updateAdmin.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/updateAdmin.ts new file mode 100644 index 000000000..fdb434bad --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/updateAdmin.ts @@ -0,0 +1,45 @@ +import { CATEGORIES } from '../../../lib/constants' +import { isValidAddress } from '../../../lib/utils' +import { AbstractInstruction, instructionToCommand } from '../../abstract/executionWrapper' + +type CommandInput = { + admin: string +} + +type ContractInput = { + admin: string +} + +const makeCommandInput = async (flags: any, args: any[]): Promise => { + return { + admin: flags.admin, + } as CommandInput +} + +const validateInput = (input: CommandInput): boolean => { + if (!isValidAddress(input.admin)) { + throw new Error('Admin address is not valid!') + } + + return true +} + +const makeContractInput = async (input: CommandInput): Promise => { + return { + admin: input.admin, + } as ContractInput +} + +const createUpdateAdminInstruction: AbstractInstruction = { + examples: ['yarn gauntlet cw4_group:update_admin --admin= '], + instruction: { + category: CATEGORIES.MULTISIG, + contract: 'cw4_group', + function: 'update_admin', + }, + makeInput: makeCommandInput, + validateInput, + makeContractInput, +} + +export const UpdateAdmin = instructionToCommand(createUpdateAdminInstruction) diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/updateMembers.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/updateMembers.ts new file mode 100644 index 000000000..9c3917f83 --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/cw4_group/updateMembers.ts @@ -0,0 +1,73 @@ +import { CATEGORIES } from '../../../lib/constants' +import { isValidAddress } from '../../../lib/utils' +import { AbstractInstruction, instructionToCommand } from '../../abstract/executionWrapper' + +type CW4_GROUP_Member = { + addr: string + weight: number +} + +type CommandInput = { + add: string[] + remove: string[] +} + +type ContractInput = { + add: CW4_GROUP_Member[] + remove: string[] +} + +const makeCommandInput = async (flags: any, args: any[]): Promise => { + return { + add: flags.add?.split(',') || [], + remove: flags.remove?.split(',') || [], + } as CommandInput +} + +const validateInput = (input: CommandInput): boolean => { + if (!input.add.every((addr) => isValidAddress(addr))) { + throw new Error("One of provided 'add' addresses is not valid!") + } + + if (!input.remove.every((addr) => isValidAddress(addr))) { + throw new Error("One of provided 'remove' addresses of not valid!") + } + + if (input.add.length === 0 && input.remove.length === 0) { + throw new Error("You must specify 'add' or 'remove' addresses!") + } + + return true +} + +const makeContractInput = async (input: CommandInput): Promise => { + const membersToAdd = input.add.map((addr: string) => { + return { + addr, + weight: 1, + } as CW4_GROUP_Member + }) + + return { + add: membersToAdd, + remove: input.remove, + } as ContractInput +} + +const createUpdateMembersInstruction: AbstractInstruction = { + examples: [ + 'yarn gauntlet cw4_group:update_members --add=, --remove= ', + 'yarn gauntlet cw4_group:update_members --add= ', + 'yarn gauntlet cw4_group:update_members --remove= ', + ], + instruction: { + category: CATEGORIES.MULTISIG, + contract: 'cw4_group', + function: 'update_members', + }, + makeInput: makeCommandInput, + validateInput, + makeContractInput, +} + +export const UpdateMembers = instructionToCommand(createUpdateMembersInstruction) diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts index c644a4e9c..f835771e2 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts @@ -1,7 +1,7 @@ import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' import { Result } from '@chainlink/gauntlet-core' import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' -import { CATEGORIES, CW20_BASE_CODE_IDs } from '../../../lib/constants' +import { CATEGORIES, CW20_BASE_CODE_IDs, TOKEN_DECIMALS } from '../../../lib/constants' export default class DeployLink extends TerraCommand { static description = 'Deploys LINK token contract' @@ -19,12 +19,16 @@ export default class DeployLink extends TerraCommand { super(flags, args) } + makeRawTransaction = async () => { + throw new Error('Deploy LINK command: makeRawTransaction method not implemented') + } + execute = async () => { await prompt(`Begin deploying LINK Token?`) const deploy = await this.deploy(CW20_BASE_CODE_IDs[this.flags.network], { name: 'ChainLink Token', symbol: 'LINK', - decimals: 18, + decimals: TOKEN_DECIMALS, initial_balances: [{ address: this.wallet.key.accAddress, amount: '1000000000000000000000000000' }], marketing: { project: 'Chainlink', diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts index 367a1ea29..526dbb2dc 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts @@ -1,42 +1,58 @@ -import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' -import { Result } from '@chainlink/gauntlet-core' import { BN, logger, prompt } from '@chainlink/gauntlet-core/dist/utils' -import { CATEGORIES } from '../../../lib/constants' +import { CATEGORIES, TOKEN_DECIMALS } from '../../../lib/constants' +import { AbstractInstruction, ExecutionContext, instructionToCommand } from '../../abstract/executionWrapper' +import { AccAddress } from '@terra-money/terra.js' -export default class TransferLink extends TerraCommand { - static description = 'Transfer LINK' - static examples = [ - `yarn gauntlet token:transfer --network=bombay-testnet --to=[RECEIVER] --amount=[AMOUNT_IN_TOKEN_UNITS]`, - `yarn gauntlet token:transfer --network=bombay-testnet --to=[RECEIVER] --amount=[AMOUNT_IN_TOKEN_UNITS] --link=[TOKEN_ADDRESS] --decimals=[TOKEN_DECIMALS]`, - ] +type CommandInput = { + to: string + // Units in LINK + amount: string +} - static id = 'token:transfer' - static category = CATEGORIES.LINK +type ContractInput = { + recipient: string + amount: string +} - constructor(flags, args: string[]) { - super(flags, args) +const makeCommandInput = async (flags: any): Promise => { + if (flags.input) return flags.input as CommandInput + return { + to: flags.to, + amount: flags.amount, } +} + +const validateInput = (input: CommandInput): boolean => { + if (!AccAddress.validate(input.to)) throw new Error(`Invalid destination address`) + if (isNaN(Number(input.amount))) throw new Error(`Amount ${input.amount} is not a number`) + return true +} - execute = async () => { - const decimals = this.flags.decimals || 18 - const link = this.flags.link || process.env.LINK - const amount = new BN(this.flags.amount).mul(new BN(10).pow(new BN(decimals))) - - await prompt(`Sending ${this.flags.amount} LINK (${amount.toString()}) to ${this.flags.to}. Continue?`) - const tx = await this.call(link, { - transfer: { - recipient: this.flags.to, - amount: amount.toString(), - }, - }) - logger.success(`LINK transferred successfully to ${this.flags.to} (txhash: ${tx.hash})`) - return { - responses: [ - { - tx, - contract: link, - }, - ], - } as Result +const makeContractInput = async (input: CommandInput): Promise => { + const amount = new BN(input.amount).mul(new BN(10).pow(new BN(TOKEN_DECIMALS))) + return { + recipient: input.to, + amount: amount.toString(), } } + +const beforeExecute = (context: ExecutionContext) => async (): Promise => { + logger.info( + `Transferring ${context.contractInput.amount} (${context.input.amount}) Tokens to ${context.contractInput.recipient}`, + ) + await prompt('Continue?') +} + +const transferToken: AbstractInstruction = { + instruction: { + category: CATEGORIES.LINK, + contract: 'cw20_base', + function: 'transfer', + }, + makeInput: makeCommandInput, + validateInput: validateInput, + makeContractInput: makeContractInput, + beforeExecute, +} + +export default instructionToCommand(transferToken) diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/index.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/index.ts index ba1d89079..b75ff9c7f 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/index.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/index.ts @@ -1,4 +1,3 @@ -import { CreateGroup } from './group' import { CreateWallet } from './wallet' -export default [CreateGroup, CreateWallet] +export default [CreateWallet] diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/wallet.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/wallet.ts index ae4d56800..0ebc120f7 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/wallet.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/multisig/wallet.ts @@ -1,18 +1,20 @@ +import { logger } from '@chainlink/gauntlet-core/dist/utils' import { CATEGORIES } from '../../../lib/constants' import { isValidAddress } from '../../../lib/utils' import { AbstractInstruction, instructionToCommand } from '../../abstract/executionWrapper' +// 24 hours +const DEFAULT_MULTISIG_EXPIRATION_TIME_IN_SECS = 24 * 60 * 60 + type Duration = { - height?: number // block height - time?: number // length of time in seconds + time: number // length of time in seconds } type CommandInput = { group: string threshold: number - votingPeriod?: { - height?: number - time?: number + votingPeriod: { + time: number } } @@ -41,15 +43,18 @@ const makeCommandInput = async (flags: any): Promise => { group: flags.group, threshold: Number(flags.threshold), votingPeriod: { - height: flags.height, - time: flags.time, + time: Number(flags.time) || DEFAULT_MULTISIG_EXPIRATION_TIME_IN_SECS, }, } } const validateInput = (input: CommandInput): boolean => { // TODO: Add time validation - const isValidTime = (a: any) => true + const isValidTime = (a: any) => { + if (!a) return false + if (Number(a) <= 0) return false + return true + } if (!isValidAddress(input.group)) { throw new Error(`group ${input.group} is not a valid terra address`) } @@ -58,12 +63,8 @@ const validateInput = (input: CommandInput): boolean => { throw new Error(`Threshold ${input.threshold} is invalid. Should be higher than zero`) } - if (input.votingPeriod?.height && isNaN(input.votingPeriod?.height)) { - throw new Error(`Voting period height ${input.votingPeriod.height} is not a valid Block`) - } - - if (input.votingPeriod?.time && !isValidTime(input.votingPeriod?.time)) { - throw new Error(`Voting period time ${input.votingPeriod?.time} is not a valid time`) + if (!isValidTime(input.votingPeriod.time)) { + throw new Error(`Voting period time ${input.votingPeriod.time} is not a valid time`) } return true @@ -73,8 +74,7 @@ const makeContractInput = async (input: CommandInput): Promise => return { group_addr: input.group, max_voting_period: { - ...(input.votingPeriod?.height && { height: Number(input.votingPeriod?.height) }), - ...(input.votingPeriod?.time && { time: Number(input.votingPeriod?.time) }), + time: input.votingPeriod.time, }, threshold: { absolute_count: { @@ -84,9 +84,10 @@ const makeContractInput = async (input: CommandInput): Promise => } } -// Creates a multisig wallet backed by a previously created cw4_group -// yarn gauntlet cw3_flex_multisig:deploy --network=bombay-testnet --group= --threshold= --height=10000100 const createWalletInstruction: AbstractInstruction = { + examples: [ + 'yarn gauntlet cw3_flex_multisig:deploy --network=bombay-testnet --group= --threshold= (--time=)', + ], instruction: { category: CATEGORIES.MULTISIG, contract: 'cw3_flex_multisig', diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/deploy.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/deploy.ts index 3d6a00bab..9bccc741b 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/deploy.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/deploy.ts @@ -1,7 +1,6 @@ -import { getRDD } from '../../../lib/rdd' +import { RDD } from '@chainlink/gauntlet-terra' import { instructionToCommand, AbstractInstruction } from '../../abstract/executionWrapper' import { CATEGORIES } from '../../../lib/constants' -import { CONTRACT_LIST } from '../../../lib/contracts' type CommandInput = { billingAccessController: string @@ -23,10 +22,11 @@ type ContractInput = { min_answer: string } -const makeCommandInput = async (flags: any): Promise => { +const makeCommandInput = async (flags: any, args: string[]): Promise => { if (flags.input) return flags.input as CommandInput - const rdd = getRDD(flags.rdd) - const aggregator = rdd.contracts[flags.id] + const rdd = RDD.getRDD(flags.rdd) + const contract = args[0] + const aggregator = rdd.contracts[contract] return { maxAnswer: aggregator.maxSubmissionValue, minAnswer: aggregator.minSubmissionValue, diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/initialize.flow.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/initialize.flow.ts index 3e0c990d2..80644adc2 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/initialize.flow.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/initialize.flow.ts @@ -51,7 +51,7 @@ export default class OCR2InitializeFlow extends FlowCommand name: 'Propose Config', command: ProposeConfig, flags: { - proposalId: this.getReportStepDataById(FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId')), + proposalId: FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId'), }, args: [this.getReportStepDataById(FlowCommand.ID.contract(this.stepIds.OCR_2))], }, @@ -59,7 +59,7 @@ export default class OCR2InitializeFlow extends FlowCommand name: 'Propose Offchain Config', command: ProposeOffchainConfig, flags: { - proposalId: this.getReportStepDataById(FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId')), + proposalId: FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId'), }, args: [this.getReportStepDataById(FlowCommand.ID.contract(this.stepIds.OCR_2))], }, @@ -68,7 +68,7 @@ export default class OCR2InitializeFlow extends FlowCommand name: 'Finalize Proposal', command: FinalizeProposal, flags: { - proposalId: this.getReportStepDataById(FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId')), + proposalId: FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId'), }, args: [this.getReportStepDataById(FlowCommand.ID.contract(this.stepIds.OCR_2))], }, @@ -76,8 +76,8 @@ export default class OCR2InitializeFlow extends FlowCommand name: 'Accept Proposal', command: AcceptProposal, flags: { - proposalId: this.getReportStepDataById(FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId')), - digest: this.getReportStepDataById(FlowCommand.ID.data(this.stepIds.FINALIZE_PROPOSAL, 'digest')), + proposalId: FlowCommand.ID.data(this.stepIds.BEGIN_PROPOSAL, 'proposalId'), + digest: FlowCommand.ID.data(this.stepIds.FINALIZE_PROPOSAL, 'digest'), }, args: [this.getReportStepDataById(FlowCommand.ID.contract(this.stepIds.OCR_2))], }, diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/inspection/inspect.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/inspection/inspect.ts index 42d1e18aa..dbbed1c68 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/inspection/inspect.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/inspection/inspect.ts @@ -1,66 +1,67 @@ -import { inspection } from '@chainlink/gauntlet-core/dist/utils' +import { BN, inspection, logger } from '@chainlink/gauntlet-core/dist/utils' +import { providerUtils, RDD } from '@chainlink/gauntlet-terra' import { CONTRACT_LIST } from '../../../../lib/contracts' -import { CATEGORIES } from '../../../../lib/constants' -import { getRDD } from '../../../../lib/rdd' -import { InspectInstruction, InspectionInput, instructionToInspectCommand } from '../../../abstract/inspectionWrapper' +import { CATEGORIES, TOKEN_UNIT } from '../../../../lib/constants' +import { InspectInstruction, instructionToInspectCommand } from '../../../abstract/inspectionWrapper' +import { deserializeConfig } from '../../../../lib/encoding' import { getOffchainConfigInput, OffchainConfig } from '../proposeOffchainConfig' +import { toComparableNumber, getLatestOCRConfigEvent } from '../../../../lib/inspection' +import { LCDClient } from '@terra-money/terra.js' -const MIN_LINK_AVAILABLE = '100' - -type Expected = { +// Command input and expected info is the same here +type ContractExpectedInfo = { description: string decimals: string | number - minAnswer: string | number - maxAnswer: string | number transmitters: string[] billingAccessController: string requesterAccessController: string link: string - linkAvailable: string billing: { observationPaymentGjuels: string recommendedGasPriceMicro: string transmissionPaymentGjuels: string } offchainConfig: OffchainConfig + totalOwed?: string + linkAvailable?: string + owner?: string } -const makeInput = async (flags: any, args: string[]): Promise> => { - if (flags.input) return flags.input as InspectionInput - const rdd = getRDD(flags.rdd) +const makeInput = async (flags: any, args: string[]): Promise => { + if (flags.input) return flags.input as ContractExpectedInfo + const rdd = RDD.getRDD(flags.rdd) const contract = args[0] const info = rdd.contracts[contract] const aggregatorOperators: string[] = info.oracles.map((o) => o.operator) - const transmitters = aggregatorOperators.map((operator) => rdd.operators[operator].ocrNodeAddress[0]) + const transmitters = aggregatorOperators.map((o) => rdd.operators[o].ocrNodeAddress[0]) const billingAccessController = flags.billingAccessController || process.env.BILLING_ACCESS_CONTROLLER const requesterAccessController = flags.requesterAccessController || process.env.REQUESTER_ACCESS_CONTROLLER const link = flags.link || process.env.LINK - const offchainConfig = getOffchainConfigInput(rdd, contract) + return { - expected: { - description: info.name, - decimals: info.decimals, - minAnswer: info.minSubmissionValue, - maxAnswer: info.maxSubmissionValue, - transmitters, - billingAccessController, - requesterAccessController, - link, - linkAvailable: MIN_LINK_AVAILABLE, - offchainConfig, - billing: { - observationPaymentGjuels: info.billing.observationPaymentGjuels, - recommendedGasPriceMicro: info.billing.recommendedGasPriceMicro, - transmissionPaymentGjuels: info.billing.transmissionPaymentGjuels, - }, + description: info.name, + decimals: info.decimals, + transmitters, + billingAccessController, + requesterAccessController, + link, + billing: { + observationPaymentGjuels: info.billing.observationPaymentGjuels, + recommendedGasPriceMicro: info.billing.recommendedGasPriceMicro, + transmissionPaymentGjuels: info.billing.transmissionPaymentGjuels, }, + offchainConfig: getOffchainConfigInput(rdd, contract), } } -const makeOnchainData = (instructionsData: any[]): Expected => { +const makeInspectionData = () => async (input: ContractExpectedInfo): Promise => input + +const makeOnchainData = (provider: LCDClient) => async ( + instructionsData: any[], + input: ContractExpectedInfo, + aggregator: string, +): Promise => { const latestConfigDetails = instructionsData[0] - // TODO: Offchain config is not stored onchain, only the digested config. Gauntlet could calculate the digested with RDD values and compare it - // const offchainConfig = deserializeConfig(latestConfigDetails.config_digest) const description = instructionsData[1] const transmitters = instructionsData[2] const decimals = instructionsData[3] @@ -69,28 +70,45 @@ const makeOnchainData = (instructionsData: any[]): Expected => { const requesterAC = instructionsData[6] const link = instructionsData[7] const linkAvailable = instructionsData[8] + const owner = instructionsData[9] + const owedPerTransmitter: string[] = await Promise.all( + transmitters.addresses.map((t) => { + return provider.wasm.contractQuery(aggregator, { + owed_payment: { + transmitter: t, + }, + }) + }), + ) + + const event = await getLatestOCRConfigEvent(provider, aggregator) + const offchainConfig = event?.offchain_config + ? await deserializeConfig(Buffer.from(event.offchain_config[0], 'base64')) + : ({} as OffchainConfig) + + const totalOwed = owedPerTransmitter.reduce((agg: BN, v) => agg.add(new BN(v)), new BN(0)).toString() return { description, decimals, - minAnswer: 'INFO NOT AVAILABLE IN CONTRACT', - maxAnswer: 'INFO NOT AVAILABLE IN CONTRACT', transmitters: transmitters.addresses, billingAccessController: billingAC, requesterAccessController: requesterAC, link, linkAvailable: linkAvailable.amount, - offchainConfig: {} as OffchainConfig, billing: { observationPaymentGjuels: billing.observation_payment_gjuels, transmissionPaymentGjuels: billing.transmission_payment_gjuels, recommendedGasPriceMicro: billing.recommended_gas_price_micro, }, + totalOwed, + owner, + offchainConfig, } } -const inspect = (expected: Expected, onchainData: Expected): boolean => { - const inspections: inspection.Inspection[] = [ +const inspect = (expected: ContractExpectedInfo, onchainData: ContractExpectedInfo): boolean => { + let inspections: inspection.Inspection[] = [ inspection.makeInspection(onchainData.description, expected.description, 'Description'), inspection.makeInspection(onchainData.decimals, expected.decimals, 'Decimals'), inspection.makeInspection(onchainData.transmitters, expected.transmitters, 'Transmitters'), @@ -105,9 +123,6 @@ const inspect = (expected: Expected, onchainData: Expected): boolean => { 'Requester Access Controller', ), inspection.makeInspection(onchainData.link, expected.link, 'LINK'), - inspection.makeInspection(onchainData.linkAvailable, expected.linkAvailable, 'LINK Available'), - inspection.makeInspection(onchainData.minAnswer, expected.minAnswer, 'Min Answer'), - inspection.makeInspection(onchainData.maxAnswer, expected.maxAnswer, 'Max Answer'), inspection.makeInspection( onchainData.billing.observationPaymentGjuels, expected.billing.observationPaymentGjuels, @@ -124,10 +139,100 @@ const inspect = (expected: Expected, onchainData: Expected): boolean => { 'Transmission Payment', ), ] - return inspection.inspect(inspections) + + if (!!onchainData.offchainConfig.s) { + const offchainConfigInspections: inspection.Inspection[] = [ + inspection.makeInspection(onchainData.offchainConfig.s, expected.offchainConfig.s, 'Offchain Config "s"'), + inspection.makeInspection( + onchainData.offchainConfig.peerIds, + expected.offchainConfig.peerIds, + 'Offchain Config "peerIds"', + ), + inspection.makeInspection( + toComparableNumber(onchainData.offchainConfig.rMax), + toComparableNumber(expected.offchainConfig.rMax), + 'Offchain Config "rMax"', + ), + inspection.makeInspection( + onchainData.offchainConfig.offchainPublicKeys.map((k) => Buffer.from(k).toString('hex')), + expected.offchainConfig.offchainPublicKeys, + `Offchain Config "offchainPublicKeys"`, + ), + inspection.makeInspection( + onchainData.offchainConfig.reportingPluginConfig.alphaReportInfinite, + expected.offchainConfig.reportingPluginConfig.alphaReportInfinite, + 'Offchain Config "reportingPluginConfig.alphaReportInfinite"', + ), + inspection.makeInspection( + onchainData.offchainConfig.reportingPluginConfig.alphaAcceptInfinite, + expected.offchainConfig.reportingPluginConfig.alphaAcceptInfinite, + 'Offchain Config "reportingPluginConfig.alphaAcceptInfinite"', + ), + inspection.makeInspection( + toComparableNumber(onchainData.offchainConfig.reportingPluginConfig.alphaReportPpb), + toComparableNumber(expected.offchainConfig.reportingPluginConfig.alphaReportPpb), + `Offchain Config "reportingPluginConfig.alphaReportPpb"`, + ), + inspection.makeInspection( + toComparableNumber(onchainData.offchainConfig.reportingPluginConfig.alphaAcceptPpb), + toComparableNumber(expected.offchainConfig.reportingPluginConfig.alphaAcceptPpb), + `Offchain Config "reportingPluginConfig.alphaAcceptPpb"`, + ), + inspection.makeInspection( + toComparableNumber(onchainData.offchainConfig.reportingPluginConfig.deltaCNanoseconds), + toComparableNumber(expected.offchainConfig.reportingPluginConfig.deltaCNanoseconds), + `Offchain Config "reportingPluginConfig.deltaCNanoseconds"`, + ), + ] + + const longNumberInspections = [ + 'deltaProgressNanoseconds', + 'deltaResendNanoseconds', + 'deltaRoundNanoseconds', + 'deltaGraceNanoseconds', + 'deltaStageNanoseconds', + 'maxDurationQueryNanoseconds', + 'maxDurationObservationNanoseconds', + 'maxDurationReportNanoseconds', + 'maxDurationShouldAcceptFinalizedReportNanoseconds', + 'maxDurationShouldTransmitAcceptedReportNanoseconds', + ].map((prop) => + inspection.makeInspection( + toComparableNumber(onchainData.offchainConfig[prop]), + toComparableNumber(expected.offchainConfig[prop]), + `Offchain Config "${prop}"`, + ), + ) + + inspections = inspections.concat(offchainConfigInspections).concat(longNumberInspections) + } else { + logger.error('Could not get offchain config information from the contract. Skipping offchain config inspection') + } + + logger.line() + logger.info('Inspection results:') + logger.info(`Ownership: + - Owner: ${onchainData.owner} + `) + logger.info(`Funding: + - LINK Available: ${onchainData.linkAvailable} + - Total LINK Owed: ${onchainData.totalOwed} + `) + + const owedDiff = new BN(onchainData.linkAvailable).sub(new BN(onchainData.totalOwed)).div(new BN(TOKEN_UNIT)) + if (owedDiff.lt(new BN(0))) { + logger.warn(`Total LINK Owed is higher than balance. Amount to fund: ${owedDiff.mul(new BN(-1)).toString()}`) + } else { + logger.success(`LINK Balance can cover debt. LINK after payment: ${owedDiff.toString()}`) + } + + const result = inspection.inspect(inspections) + logger.line() + + return result } -const instruction: InspectInstruction = { +const instruction: InspectInstruction = { command: { category: CATEGORIES.OCR, contract: CONTRACT_LIST.OCR_2, @@ -170,10 +275,15 @@ const instruction: InspectInstruction = { contract: 'ocr2', function: 'link_available_for_payment', }, + { + contract: 'ocr2', + function: 'owner', + }, ], makeInput, + makeInspectionData, makeOnchainData, inspect, } -export default instructionToInspectCommand(instruction) +export default instructionToInspectCommand(instruction) diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/acceptProposal.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/acceptProposal.ts index b904468f8..11b420005 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/acceptProposal.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/acceptProposal.ts @@ -1,11 +1,18 @@ import { Result } from '@chainlink/gauntlet-core' -import { TransactionResponse } from '@chainlink/gauntlet-terra' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' +import { TransactionResponse, RDD } from '@chainlink/gauntlet-terra' import { CATEGORIES } from '../../../../lib/constants' -import { AbstractInstruction, instructionToCommand } from '../../../abstract/executionWrapper' +import { AbstractInstruction, instructionToCommand, BeforeExecute } from '../../../abstract/executionWrapper' +import { serializeOffchainConfig, deserializeConfig } from '../../../../lib/encoding' +import { getOffchainConfigInput, OffchainConfig } from '../proposeOffchainConfig' +import { getLatestOCRConfigEvent, longsInObjToNumbers, printDiff } from '../../../../lib/inspection' +import assert from 'assert' type CommandInput = { proposalId: string digest: string + offchainConfig: OffchainConfig + randomSecret: string } type ContractInput = { @@ -15,12 +22,64 @@ type ContractInput = { const makeCommandInput = async (flags: any, args: string[]): Promise => { if (flags.input) return flags.input as CommandInput + const { rdd: rddPath, secret } = flags + + if (!rddPath) throw new Error('RDD flag is required. Provide it with --rdd flag') + + const rdd = RDD.getRDD(rddPath) + const contract = args[0] + return { proposalId: flags.proposalId, digest: flags.digest, + offchainConfig: getOffchainConfigInput(rdd, contract), + randomSecret: secret, } } +const beforeExecute: BeforeExecute = (context) => async () => { + const { proposalId, randomSecret, offchainConfig: offchainLocalConfig } = context.input + + const { offchainConfig } = await serializeOffchainConfig(offchainLocalConfig, process.env.SECRET!, randomSecret) + const localConfig = offchainConfig.toString('base64') + + const proposal: any = await context.provider.wasm.contractQuery(context.contract, { + proposal: { + id: proposalId, + }, + }) + + try { + assert.equal(localConfig, proposal.offchain_config) + } catch (err) { + throw new Error(`RDD configuration does not correspond the proposal configuration. Error: ${err.message}`) + } + logger.success('RDD Generated configuration matches with onchain proposal configuration') + + // Config in Proposal + const offchainConfigInProposal = await deserializeConfig(Buffer.from(proposal.offchain_config, 'base64')) + const configInProposal = longsInObjToNumbers({ + ...offchainConfigInProposal, + offchainPublicKeys: offchainConfigInProposal.offchainPublicKeys?.map((key) => Buffer.from(key).toString('hex')), + f: proposal.f, + }) + + // Config in contract + const event = await getLatestOCRConfigEvent(context.provider, context.contract) + const offchainConfigInContract = event?.offchain_config + ? await deserializeConfig(Buffer.from(event.offchain_config[0], 'base64')) + : ({} as OffchainConfig) + const configInContract = longsInObjToNumbers({ + ...offchainConfigInContract, + offchainPublicKeys: offchainConfigInContract.offchainPublicKeys?.map((key) => Buffer.from(key).toString('hex')), + f: event?.f[0], + }) + + logger.info('Review the configuration difference from contract and proposal: green - added, red - deleted.') + printDiff(configInContract, configInProposal) + await prompt('Continue?') +} + const makeContractInput = async (input: CommandInput): Promise => { return { id: input.proposalId, @@ -30,12 +89,22 @@ const makeContractInput = async (input: CommandInput): Promise => const validateInput = (input: CommandInput): boolean => { if (!input.proposalId) throw new Error('A proposal ID is required. Provide it with --proposalId flag') + if (!input.randomSecret) + throw new Error('Secret generated at proposing offchain config is required. Provide it with --secret flag') return true } -const afterExecute = async (response: Result) => { - console.log(response.data) - return +const afterExecute = () => async (response: Result) => { + logger.success(`Proposal accepted on tx ${response.responses[0].tx.hash}`) + const events = response.responses[0].tx.events + if (!events) { + logger.error('Could not retrieve events from tx') + return + } + const digest = events[0]['wasm-set_config'].latest_config_digest[0] + return { + digest, + } } // yarn gauntlet ocr2:accept_proposal --network=bombay-testnet --id=4 --digest=71e6969c14c3e0cd47d75da229dbd2f76fd0f3c17e05635f78ac755a99897a2f terra14nrtuhrrhl2ldad7gln5uafgl8s2m25du98hlx @@ -48,6 +117,7 @@ const instruction: AbstractInstruction = { makeInput: makeCommandInput, validateInput: validateInput, makeContractInput: makeContractInput, + beforeExecute, afterExecute, } diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/beginProposal.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/beginProposal.ts index 526cbbc9e..100967d2f 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/beginProposal.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/beginProposal.ts @@ -20,7 +20,9 @@ const validateInput = (input: CommandInput): boolean => { return true } -const afterExecute = (response: Result): { proposalId: string } | undefined => { +const afterExecute = () => async ( + response: Result, +): Promise<{ proposalId: string } | undefined> => { const events = response.responses[0].tx.events if (!events) { logger.error('No events found. Proposal ID could not be retrieved') diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/clearProposal.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/clearProposal.ts index 28500d6e0..a8279be3a 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/clearProposal.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/clearProposal.ts @@ -1,5 +1,3 @@ -import { Result } from '@chainlink/gauntlet-core' -import { TransactionResponse } from '@chainlink/gauntlet-terra' import { CATEGORIES } from '../../../../lib/constants' import { instructionToCommand, AbstractInstruction } from '../../../abstract/executionWrapper' @@ -29,11 +27,6 @@ const validateInput = (input: CommandInput): boolean => { return true } -const afterExecute = async (response: Result) => { - console.log(response.data) - return -} - // yarn gauntlet ocr2:clear_proposal --network=bombay-testnet --id=7 terra14nrtuhrrhl2ldad7gln5uafgl8s2m25du98hlx const instruction: AbstractInstruction = { instruction: { @@ -44,7 +37,6 @@ const instruction: AbstractInstruction = { makeInput: makeCommandInput, validateInput: validateInput, makeContractInput: makeContractInput, - afterExecute, } export default instructionToCommand(instruction) diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/finalizeProposal.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/finalizeProposal.ts index 6c482262a..5ae140855 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/finalizeProposal.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposal/finalizeProposal.ts @@ -30,7 +30,9 @@ const validateInput = (input: CommandInput): boolean => { return true } -const afterExecute = (response: Result): { proposalId: string; digest: string } | undefined => { +const afterExecute = () => async ( + response: Result, +): Promise<{ proposalId: string; digest: string } | undefined> => { const events = response.responses[0].tx.events if (!events) { logger.error('Could not retrieve events from tx') diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeConfig.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeConfig.ts index 7bac78187..620513a3c 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeConfig.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeConfig.ts @@ -1,6 +1,8 @@ +import { providerUtils, RDD } from '@chainlink/gauntlet-terra' import { CATEGORIES } from '../../../lib/constants' -import { getRDD } from '../../../lib/rdd' -import { AbstractInstruction, instructionToCommand } from '../../abstract/executionWrapper' +import { getLatestOCRConfigEvent, printDiff } from '../../../lib/inspection' +import { AbstractInstruction, BeforeExecute, instructionToCommand } from '../../abstract/executionWrapper' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' type OnchainConfig = any type CommandInput = { @@ -23,7 +25,14 @@ type ContractInput = { const makeCommandInput = async (flags: any, args: string[]): Promise => { if (flags.input) return flags.input as CommandInput - const rdd = getRDD(flags.rdd) + + const { rdd: rddPath } = flags + + if (!rddPath) { + throw new Error('No RDD flag provided!') + } + + const rdd = RDD.getRDD(rddPath) const contract = args[0] const aggregator = rdd.contracts[contract] const aggregatorOperators: any[] = aggregator.oracles.map((o) => rdd.operators[o.operator]) @@ -66,6 +75,30 @@ const validateInput = (input: CommandInput): boolean => { return true } +const beforeExecute: BeforeExecute = (context) => async () => { + const event = await getLatestOCRConfigEvent(context.provider, context.contract) + + const contractConfig = { + f: event?.f[0], + transmitters: event?.transmitters, + signers: event?.signers.map((s) => Buffer.from(s, 'hex').toString('base64')), + onchain_config: event?.onchain_config[0], + // todo: add payees to set_config event (https://github.com/smartcontractkit/chainlink-terra/issues/180) + } + + const proposedConfig = { + f: context.contractInput.f, + transmitters: context.contractInput.transmitters, + signers: context.contractInput.signers, + payees: context.contractInput.payees, + onchain_config: context.contractInput.onchain_config, + } + + logger.info('Review the proposed changes below: green - added, red - deleted.') + printDiff(contractConfig, proposedConfig) + await prompt('Continue?') +} + // yarn gauntlet ocr2:propose_config --network=bombay-testnet --proposalId=4 --rdd=../reference-data-directory/directory-terra-mainnet.json terra14nrtuhrrhl2ldad7gln5uafgl8s2m25du98hlx const instruction: AbstractInstruction = { instruction: { @@ -76,6 +109,7 @@ const instruction: AbstractInstruction = { makeInput: makeCommandInput, validateInput: validateInput, makeContractInput: makeContractInput, + beforeExecute, } export default instructionToCommand(instruction) diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeOffchainConfig.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeOffchainConfig.ts index 528294879..54bafdb42 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeOffchainConfig.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/proposeOffchainConfig.ts @@ -1,15 +1,17 @@ -import { getRDD } from '../../../lib/rdd' -import { AbstractInstruction, instructionToCommand } from '../../abstract/executionWrapper' +import { providerUtils, RDD } from '@chainlink/gauntlet-terra' +import { AbstractInstruction, instructionToCommand, BeforeExecute, AfterExecute } from '../../abstract/executionWrapper' import { time, BN } from '@chainlink/gauntlet-core/dist/utils' -import { serializeOffchainConfig } from '../../../lib/encoding' import { ORACLES_MAX_LENGTH } from '../../../lib/constants' import { CATEGORIES } from '../../../lib/constants' -import { CONTRACT_LIST } from '../../../lib/contracts' +import { getLatestOCRConfigEvent, printDiff } from '../../../lib/inspection' +import { serializeOffchainConfig, deserializeConfig, generateSecretWords } from '../../../lib/encoding' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' type CommandInput = { proposalId: string offchainConfig: OffchainConfig offchainConfigVersion: number + randomSecret?: string } type ContractInput = { @@ -89,22 +91,73 @@ export const getOffchainConfigInput = (rdd: any, contract: string): OffchainConf const makeCommandInput = async (flags: any, args: string[]): Promise => { if (flags.input) return flags.input as CommandInput - const rdd = getRDD(flags.rdd) + + const { rdd: rddPath, randomSecret } = flags + + if (!rddPath) { + throw new Error('No RDD flag provided!') + } + + const rdd = RDD.getRDD(rddPath) const contract = args[0] return { proposalId: flags.proposalId, offchainConfig: getOffchainConfigInput(rdd, contract), offchainConfigVersion: 2, + randomSecret: randomSecret || (await generateSecretWords()), + } +} + +const beforeExecute: BeforeExecute = (context) => async () => { + const event = await getLatestOCRConfigEvent(context.provider, context.contract) + const offchainConfig = event?.offchain_config + ? await deserializeConfig(Buffer.from(event.offchain_config[0], 'base64')) + : ({} as OffchainConfig) + + const contractOffchainConfig = { + ...offchainConfig, + offchainPublicKeys: offchainConfig.offchainPublicKeys?.map((key) => Buffer.from(key).toString('hex')), + f: event?.f, + } + + const proposedOffchainConfig = { + ...context.input.offchainConfig, + configPublicKeys: undefined, } + + logger.info('Review the proposed changes below: green - added, red - deleted.') + printDiff(contractOffchainConfig, proposedOffchainConfig) + + logger.info( + `Important: The following secret was used to encode offchain config. You will need to provide it to approve the config proposal: + SECRET: ${context.input.randomSecret}`, + ) + + await prompt('Continue?') } const makeContractInput = async (input: CommandInput): Promise => { - const offchainConfig = await serializeOffchainConfig(input.offchainConfig) + const { offchainConfig } = await serializeOffchainConfig( + input.offchainConfig, + process.env.SECRET!, + input.randomSecret, + ) return { id: input.proposalId, offchain_config_version: 2, - offchain_config: offchainConfig, + offchain_config: offchainConfig.toString('base64'), + } +} + +const afterExecute: AfterExecute = (context) => async (result): Promise => { + logger.success(`Tx succeded at ${result.responses[0].tx.hash}`) + logger.info( + `Important: The following secret was used to encode offchain config. You will need to provide it to approve the config proposal: + SECRET: ${context.input.randomSecret}`, + ) + return { + randomSecret: context.input.randomSecret, } } @@ -174,6 +227,8 @@ const instruction: AbstractInstruction = { makeInput: makeCommandInput, validateInput: validateInput, makeContractInput: makeContractInput, + beforeExecute, + afterExecute, } export default instructionToCommand(instruction) diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/setBilling.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/setBilling.ts index 0d436ca14..22b289162 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/setBilling.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ocr2/setBilling.ts @@ -1,5 +1,5 @@ import { BN } from '@chainlink/gauntlet-core/dist/utils' -import { getRDD } from '../../../lib/rdd' +import { RDD } from '@chainlink/gauntlet-terra' import { AbstractInstruction, instructionToCommand } from '../../abstract/executionWrapper' import { CATEGORIES } from '../../../lib/constants' import { CONTRACT_LIST } from '../../../lib/contracts' @@ -20,7 +20,7 @@ type ContractInput = { const makeCommandInput = async (flags: any, args: string[]): Promise => { if (flags.input) return flags.input as CommandInput - const rdd = getRDD(flags.rdd) + const rdd = RDD.getRDD(flags.rdd) const contract = args[0] const billingInfo = rdd.contracts[contract]?.billing return { diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/acceptOwnership.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/acceptOwnership.ts index 68946d998..d091eab99 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/acceptOwnership.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/acceptOwnership.ts @@ -1,21 +1,29 @@ -import { AbstractInstruction } from '../../abstract/executionWrapper' +import { AbstractInstruction, BeforeExecute } from '../../abstract/executionWrapper' +import { RDD } from '@chainlink/gauntlet-terra' import { CATEGORIES } from '../../../lib/constants' import { CONTRACT_LIST } from '../../../lib/contracts' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' type CommandInput = {} type ContractInput = {} -const makeCommandInput = async (flags: any, args: string[]): Promise => { - return {} -} - -const makeContractInput = async (input: CommandInput): Promise => { - return {} -} +const makeCommandInput = async (flags: any, args: string[]): Promise => ({}) +const makeContractInput = async (input: CommandInput): Promise => ({}) +const validateInput = (input: CommandInput): boolean => true -const validateInput = (input: CommandInput): boolean => { - return true +const beforeExecute: BeforeExecute = (context) => async (signer) => { + const currentOwner = await context.provider.wasm.contractQuery(context.contract, 'owner' as any) + if (!context.flags.rdd) { + throw new Error(`No RDD flag provided!`) + } + const contract = RDD.getContractFromRDD(RDD.getRDD(context.flags.rdd), context.contract) + logger.info(`Accepting Ownership Transfer of contract of type "${contract.type}": + - Contract: ${contract.address} ${contract.description ? '- ' + contract.description : ''} + - Current Owner: ${currentOwner} + - Next Owner (Current signer): ${signer} + `) + await prompt('Continue?') } export const makeAcceptOwnershipInstruction = (contractId: CONTRACT_LIST) => { @@ -28,6 +36,7 @@ export const makeAcceptOwnershipInstruction = (contractId: CONTRACT_LIST) => { makeInput: makeCommandInput, validateInput: validateInput, makeContractInput: makeContractInput, + beforeExecute, } return acceptOwnershipInstruction diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/transferOwnership.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/transferOwnership.ts index 30559fe5e..8cae56c3a 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/transferOwnership.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/ownership/transferOwnership.ts @@ -1,7 +1,9 @@ import { AccAddress } from '@terra-money/terra.js' -import { AbstractInstruction } from '../../abstract/executionWrapper' +import { RDD } from '@chainlink/gauntlet-terra' +import { AbstractInstruction, BeforeExecute } from '../../abstract/executionWrapper' import { CATEGORIES } from '../../../lib/constants' import { CONTRACT_LIST } from '../../../lib/contracts' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' type CommandInput = { to: string @@ -31,6 +33,20 @@ const validateInput = (input: CommandInput): boolean => { return true } +const beforeExecute: BeforeExecute = (context) => async () => { + const currentOwner = await context.provider.wasm.contractQuery(context.contract, 'owner' as any) + if (!context.flags.rdd) { + throw new Error(`No RDD flag provided!`) + } + const contract = RDD.getContractFromRDD(RDD.getRDD(context.flags.rdd), context.contract) + logger.info(`Proposing Ownership Transfer of contract of type "${contract.type}": + - Contract: ${contract.address} ${contract.description ? '- ' + contract.description : ''} + - Current Owner: ${currentOwner} + - Next Owner: ${context.contractInput.to} + `) + await prompt('Continue?') +} + export const makeTransferOwnershipInstruction = (contractId: CONTRACT_LIST) => { const transferOwnershipInstruction: AbstractInstruction = { instruction: { @@ -41,6 +57,7 @@ export const makeTransferOwnershipInstruction = (contractId: CONTRACT_LIST) => { makeInput: makeCommandInput, validateInput: validateInput, makeContractInput: makeContractInput, + beforeExecute, } return transferOwnershipInstruction diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/confirmContract.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/confirmContract.ts index 521d51049..41cd09f3d 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/confirmContract.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/confirmContract.ts @@ -13,8 +13,10 @@ type ContractInput = { } const makeCommandInput = async (flags: any, args: string[]): Promise => { + const contract = args[0] + return { - address: flags.address, + address: contract, } } diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/deploy.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/deploy.ts index 06b5f7db7..d74319053 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/deploy.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/deploy.ts @@ -13,8 +13,10 @@ type ContractInput = { } const makeCommandInput = async (flags: any, args: string[]): Promise => { + const contract = args[0] + return { - address: flags.contractAddress, + address: contract, } } diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/proposeContract.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/proposeContract.ts index 6574262c9..2f5f27e8c 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/proposeContract.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/proxy_ocr2/proposeContract.ts @@ -13,8 +13,10 @@ type ContractInput = { } const makeCommandInput = async (flags: any, args: string[]): Promise => { + const contract = args[0] + return { - address: flags.address, + address: contract, } } diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/index.ts b/packages-ts/gauntlet-terra-contracts/src/commands/index.ts index fd0240964..30f7e77fa 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/index.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/index.ts @@ -7,6 +7,8 @@ import Flags from './contracts/flags' import Proxy_OCR2 from './contracts/proxy_ocr2' import DeviationFlaggingValidator from './contracts/deviation_flagging_validator' import Multisig from './contracts/multisig' +import CW4_GROUP from './contracts/cw4_group' +import Wallet from './wallet' export default [ Upload, @@ -18,4 +20,6 @@ export default [ ...DeviationFlaggingValidator, ...Proxy_OCR2, ...Multisig, + ...CW4_GROUP, + ...Wallet, ] diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts b/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts index 3862513a8..af775ea8f 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts @@ -23,6 +23,10 @@ export default class UploadContractCode extends TerraCommand { super(flags, args) } + makeRawTransaction = async () => { + throw new Error('Upload command: makeRawTransaction method not implemented') + } + getCodeId(response): number | undefined { return Number(this.parseResponseValue(response, 'store_code', 'code_id')) } @@ -65,6 +69,8 @@ export default class UploadContractCode extends TerraCommand { if (maxRetry === retry + 1) { throw new Error(message) } + // sleep one second before trying again since it can flake if not given some time + await new Promise((resolve) => setTimeout(resolve, 1000)) continue } break diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/wallet/index.ts b/packages-ts/gauntlet-terra-contracts/src/commands/wallet/index.ts new file mode 100644 index 000000000..7400c704a --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/src/commands/wallet/index.ts @@ -0,0 +1,3 @@ +import Send from './send' + +export default [Send] diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/wallet/send.ts b/packages-ts/gauntlet-terra-contracts/src/commands/wallet/send.ts new file mode 100644 index 000000000..02210a7a4 --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/src/commands/wallet/send.ts @@ -0,0 +1,62 @@ +import { BN, prompt } from '@chainlink/gauntlet-core/dist/utils' +import { AccAddress, MsgSend } from '@terra-money/terra.js' +import { CATEGORIES, ULUNA_DECIMALS } from '../../lib/constants' +import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' +import { Result } from '@chainlink/gauntlet-core' + +type CommandInput = { + destination: string + // Units in LUNA + amount: string +} + +export default class TransferLuna extends TerraCommand { + static description = 'Transfer Luna' + static examples = [`yarn gauntlet wallet:transfer --network=bombay-testnet`] + + static id = 'wallet:transfer' + static category = CATEGORIES.WALLET + + input: CommandInput + + constructor(flags, args: string[]) { + super(flags, args) + } + + buildCommand = async (flags, args): Promise => { + this.input = this.makeInput(flags, args) + return this + } + + beforeExecute = async () => { + await prompt(`Continue sending ${this.input.amount} uLUNA to ${this.input.destination}?`) + } + + makeInput = (flags, _) => { + return { + destination: flags.to, + amount: new BN(flags.amount).mul(new BN(10).pow(new BN(ULUNA_DECIMALS))), + } + } + + makeRawTransaction = async (signer: AccAddress) => { + if (!AccAddress.validate(this.input.destination)) throw new Error('Invalid destination address') + return new MsgSend(signer, this.input.destination, `${this.input.amount.toString()}uluna`) + } + + execute = async () => { + const message = await this.makeRawTransaction(this.wallet.key.accAddress) + await this.beforeExecute() + const tx = await this.signAndSend([message]) + const result = { + responses: [ + { + tx, + contract: '', + }, + ], + } as Result + await this.afterExecute(result) + return result + } +} diff --git a/packages-ts/gauntlet-terra-contracts/src/index.ts b/packages-ts/gauntlet-terra-contracts/src/index.ts index b1e589e08..74ef30d96 100644 --- a/packages-ts/gauntlet-terra-contracts/src/index.ts +++ b/packages-ts/gauntlet-terra-contracts/src/index.ts @@ -1,12 +1,14 @@ import { executeCLI } from '@chainlink/gauntlet-core' +import { multisigWrapCommand, commands as CWPlusCommands } from '@chainlink/gauntlet-terra-cw-plus' import { existsSync } from 'fs' import path from 'path' +import { io } from '@chainlink/gauntlet-core/dist/utils' import Terra from './commands' import { makeAbstractCommand } from './commands/abstract' import { defaultFlags } from './lib/args' const commands = { - custom: [...Terra], + custom: [...Terra, ...Terra.map(multisigWrapCommand), ...CWPlusCommands], loadDefaultFlags: () => defaultFlags, abstract: { findPolymorphic: () => undefined, @@ -20,9 +22,10 @@ const commands = { const networkPath = networkPossiblePaths.filter((networkPath) => existsSync(path.join(process.cwd(), networkPath)), )[0] - /* const result = */ await executeCLI(commands, networkPath) - // TODO: save report just as on Solana - // if (result) io.saveJSON(result, 'report') + const result = await executeCLI(commands, networkPath) + if (result) { + io.saveJSON(result, process.env['REPORT_NAME'] ? process.env['REPORT_NAME'] : 'report') + } } catch (e) { console.log(e) console.log('Terra Command execution error', e.message) diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/args.ts b/packages-ts/gauntlet-terra-contracts/src/lib/args.ts index b98efe353..9d89fd51d 100644 --- a/packages-ts/gauntlet-terra-contracts/src/lib/args.ts +++ b/packages-ts/gauntlet-terra-contracts/src/lib/args.ts @@ -2,4 +2,7 @@ export const defaultFlags = { delta: 'delta.json', codeIdsPath: './codeIds', artifactsPath: './artifacts', + // TODO: when enabled it will overwrite --rdd flag always, not just when -rdd flag missing + // Default path is set as next to chainlink-terra repo folder + // rdd: '../../../../../reference-data-directory/directory-${network}.json', } diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts b/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts index 20cb82fe0..8956a6207 100644 --- a/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts +++ b/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts @@ -1,3 +1,5 @@ +import { BN } from '@chainlink/gauntlet-core/dist/utils' + export const enum CATEGORIES { OWNERSHIP = 'Ownership', PROXIES = 'Proxies', @@ -9,9 +11,10 @@ export const enum CATEGORIES { ACCESS_CONTROLLER = 'Access Controller', MULTISIG = 'Multisig', DEVIATION_FLAGGING_VALIDATOR = 'Devaiation Flagging Validator', + WALLET = 'Wallet', } -export const DEFAULT_RELEASE_VERSION = 'v0.0.4' +export const DEFAULT_RELEASE_VERSION = 'local' export const DEFAULT_CWPLUS_VERSION = 'v0.9.1' export const ORACLES_MAX_LENGTH = 31 @@ -19,17 +22,22 @@ export const ORACLES_MAX_LENGTH = 31 export const CW20_BASE_CODE_IDs = { mainnet: 3, local: 32, - 'bombay-testnet': 148, + 'testnet-bombay': 148, } export const CW4_GROUP_CODE_IDs = { mainnet: -1, local: -1, - 'bombay-testnet': 36895, + 'testnet-bombay': 36895, } export const CW3_FLEX_MULTISIG_CODE_IDs = { mainnet: -1, local: -1, - 'bombay-testnet': 36059, + 'testnet-bombay': 36059, } + +export const TOKEN_DECIMALS = 18 +export const TOKEN_UNIT = new BN(10).pow(new BN(TOKEN_DECIMALS)) + +export const ULUNA_DECIMALS = 6 diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/contracts.ts b/packages-ts/gauntlet-terra-contracts/src/lib/contracts.ts index b1caadb10..ad43922c3 100644 --- a/packages-ts/gauntlet-terra-contracts/src/lib/contracts.ts +++ b/packages-ts/gauntlet-terra-contracts/src/lib/contracts.ts @@ -64,7 +64,7 @@ export const getContractCode = async (contractId: CONTRACT_LIST, version): Promi default: url = `https://github.com/smartcontractkit/chainlink-terra/releases/download/${version}/${contractId}.wasm` } - console.log(`Fetching ${url}...`) + logger.loading(`Fetching ${url}...`) const response = await fetch(url) const body = await response.arrayBuffer() return Buffer.from(body).toString('base64') diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/encoding.ts b/packages-ts/gauntlet-terra-contracts/src/lib/encoding.ts index 957024d0e..382e629ce 100644 --- a/packages-ts/gauntlet-terra-contracts/src/lib/encoding.ts +++ b/packages-ts/gauntlet-terra-contracts/src/lib/encoding.ts @@ -2,24 +2,58 @@ import { OffchainConfig } from '../commands/contracts/ocr2/proposeOffchainConfig import { Proto, sharedSecretEncryptions } from '@chainlink/gauntlet-core/dist/crypto' import { join } from 'path' -export const serializeOffchainConfig = async (input: OffchainConfig): Promise => { - const { configPublicKeys, f, ...validInput } = input +export const serializeOffchainConfig = async ( + input: OffchainConfig, + gauntletSecret: string, + secret?: string, +): Promise<{ offchainConfig: Buffer; randomSecret: string }> => { + const { configPublicKeys, ...validInput } = input const proto = new Proto.Protobuf({ descriptor }) const reportingPluginConfigProto = proto.encode( 'offchainreporting2_config.ReportingPluginConfig', validInput.reportingPluginConfig, ) - const sharedSecretEncryptions = await generateSecretEncryptions(configPublicKeys) + const { sharedSecretEncryptions, randomSecret } = await generateSecretEncryptions( + configPublicKeys, + gauntletSecret, + secret, + ) const offchainConfig = { ...validInput, offchainPublicKeys: validInput.offchainPublicKeys.map((key) => Buffer.from(key, 'hex')), reportingPluginConfig: reportingPluginConfigProto, sharedSecretEncryptions, } - return Buffer.from(proto.encode('offchainreporting2_config.OffchainConfigProto', offchainConfig)).toString('base64') + return { + offchainConfig: Buffer.from(proto.encode('offchainreporting2_config.OffchainConfigProto', offchainConfig)), + randomSecret, + } +} + +// constructs a SharedSecretEncryptions from +// a set of SharedSecretEncryptionPublicKeys, the sharedSecret, and a cryptographic randomness source +const generateSecretEncryptions = async ( + operatorsPublicKeys: string[], + gauntletSecret: string, + secret?: string, +): Promise<{ sharedSecretEncryptions: sharedSecretEncryptions.SharedSecretEncryptions; randomSecret: string }> => { + const randomSecret = secret || (await generateSecretWords()) + return { + sharedSecretEncryptions: sharedSecretEncryptions.makeSharedSecretEncryptions( + gauntletSecret, + operatorsPublicKeys, + randomSecret, + ), + randomSecret, + } } -export const deserializeConfig = (buffer: Buffer): any => { +export const generateSecretWords = async (): Promise => { + const path = join(process.cwd(), 'packages-ts/gauntlet-terra-contracts/artifacts/bip-0039', 'english.txt') + return await sharedSecretEncryptions.generateSecretWords(path) +} + +export const deserializeConfig = (buffer: Buffer): OffchainConfig => { const proto = new Proto.Protobuf({ descriptor: descriptor }) const offchain = proto.decode('offchainreporting2_config.OffchainConfigProto', buffer) const reportingPluginConfig = proto.decode( @@ -29,17 +63,6 @@ export const deserializeConfig = (buffer: Buffer): any => { return { ...offchain, reportingPluginConfig } } -// constructs a SharedSecretEncryptions from -// a set of SharedSecretEncryptionPublicKeys, the sharedSecret, and a cryptographic randomness source -const generateSecretEncryptions = async ( - operatorsPublicKeys: string[], -): Promise => { - const gauntletSecret = process.env.SECRET - const path = join(process.cwd(), 'packages-ts/gauntlet-terra-contracts/artifacts/bip-0039', 'english.txt') - const randomSecret = await sharedSecretEncryptions.generateSecretWords(path) - return await sharedSecretEncryptions.makeSharedSecretEncryptions(gauntletSecret!, operatorsPublicKeys, randomSecret) -} - // Autogenerated from ocr2Proto.proto with Protobuf.toJSON() export const descriptor = { nested: { diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/inspection.ts b/packages-ts/gauntlet-terra-contracts/src/lib/inspection.ts new file mode 100644 index 000000000..1db8963da --- /dev/null +++ b/packages-ts/gauntlet-terra-contracts/src/lib/inspection.ts @@ -0,0 +1,125 @@ +import { Proto } from '@chainlink/gauntlet-core/dist/crypto' +import { BN } from '@chainlink/gauntlet-core/dist/utils' +import { AccAddress, LCDClient } from '@terra-money/terra.js' +import { providerUtils } from '@chainlink/gauntlet-terra' +import { logger } from '@chainlink/gauntlet-core/dist/utils' +import { deepCopy } from './utils' +import Long from 'long' + +// TODO: find the right place for this function +export const getLatestOCRConfigEvent = async (provider: LCDClient, contract: AccAddress) => { + // The contract only stores the block where the config was accepted. The tx log contains the config + const latestConfigDetails: any = await provider.wasm.contractQuery(contract, 'latest_config_details' as any) + const setConfigTx = providerUtils.filterTxsByEvent( + await providerUtils.getBlockTxs(provider, latestConfigDetails.block_number), + 'wasm-set_config', + ) + + return setConfigTx?.logs?.[0].eventsByType['wasm-set_config'] +} + +enum DIFF_PROPERTY_COLOR { + ADDED = 'green', + REMOVED = 'red', + NO_CHANGE = 'reset', +} + +type DIFF_OPTIONS = { + initialIndent?: string + propertyName?: string +} + +// TODO: find a better place for this function (likely gauntlet-core to expose it for other project) +// https://github.com/smartcontractkit/chainlink-terra/issues/181 +export function printDiff(existing: Object, incoming: Object, options?: DIFF_OPTIONS) { + const { initialIndent = '', propertyName = 'Object' } = options || {} + logger.log(initialIndent, propertyName, '{') + const indent = initialIndent + ' ' + + for (const prop of Object.keys(incoming)) { + const existingProperty = existing?.[prop] + const incomingProperty = incoming[prop] + + if (Array.isArray(incomingProperty)) { + logger.log(indent, prop, ': [') + const itemsIndent = indent + ' ' + + for (const item of incomingProperty) { + const itemStr = Buffer.isBuffer(item) ? item.toString('hex') : item + if (existingProperty?.includes(item)) { + logger.log(itemsIndent, logger.style(itemStr, DIFF_PROPERTY_COLOR.NO_CHANGE)) + } else { + logger.log(itemsIndent, logger.style(itemStr, DIFF_PROPERTY_COLOR.ADDED)) + } + } + + for (const item of existingProperty || []) { + const itemStr = Buffer.isBuffer(item) ? item.toString('hex') : item + if (!incomingProperty.includes(item)) { + logger.log(itemsIndent, logger.style(itemStr, DIFF_PROPERTY_COLOR.REMOVED)) + } + } + logger.log(indent, `]`) + continue + } + + if (Buffer.isBuffer(incomingProperty)) { + if (Buffer.compare(incomingProperty, existingProperty || Buffer.from('')) === 0) { + logger.log(indent, `${prop}:`, logger.style(incomingProperty.toString('hex'), DIFF_PROPERTY_COLOR.NO_CHANGE)) + } else { + logger.log(indent, `${prop}:`, logger.style(existingProperty?.toString('hex'), DIFF_PROPERTY_COLOR.REMOVED)) + logger.log(indent, `${prop}:`, logger.style(incomingProperty.toString('hex'), DIFF_PROPERTY_COLOR.ADDED)) + } + continue + } + + if (typeof incomingProperty === 'object') { + printDiff(existingProperty, incomingProperty, { + initialIndent: indent, + propertyName: `${prop}:`, + }) + continue + } + + // plain property + if (existingProperty == incomingProperty) { + logger.log(indent, `${prop}:`, logger.style(incomingProperty, DIFF_PROPERTY_COLOR.NO_CHANGE)) + } else { + logger.log(indent, `${prop}:`, logger.style(existingProperty, DIFF_PROPERTY_COLOR.REMOVED)) + logger.log(indent, `${prop}:`, logger.style(incomingProperty, DIFF_PROPERTY_COLOR.ADDED)) + } + } + + logger.log(initialIndent, '}') +} + +export const longsInObjToNumbers = (obj) => { + const copy = deepCopy(obj) + for (const [key, value] of Object.entries(obj)) { + if (Array.isArray(value) || Buffer.isBuffer(value) || value instanceof Date) { + // skip non-convertable arrays and buffers + continue + } + + if (Long.isLong(value)) { + // transform long struct into readable and comparable number + copy[key] = toComparableLongNumber(value) + continue + } + + if (typeof value === 'object') { + // for all nested objects repeat recursively + copy[key] = longsInObjToNumbers(value) + } + } + return copy +} + +export const toComparableLongNumber = (v: Long) => new BN(Proto.Protobuf.longToString(v)).toString() + +export const toComparableNumber = (v: string | number | Long) => { + // Proto encoding will ignore falsy values + if (!v) return '0' + if (typeof v === 'string' || typeof v === 'number') return new BN(v).toString() + return toComparableLongNumber(v) +} diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/rdd.ts b/packages-ts/gauntlet-terra-contracts/src/lib/rdd.ts deleted file mode 100644 index 9406e2c0a..000000000 --- a/packages-ts/gauntlet-terra-contracts/src/lib/rdd.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readFileSync } from 'fs' -import { join } from 'path' - -export const getRDD = (path: string) => { - const buffer = readFileSync(join(process.cwd(), path), 'utf8') - try { - const rdd = JSON.parse(buffer.toString()) - return rdd - } catch (e) { - throw new Error('An error ocurred while parsing the RDD. Make sure you provided a valid RDD path') - } -} diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/utils.ts b/packages-ts/gauntlet-terra-contracts/src/lib/utils.ts index ed2b66e34..e56f14243 100644 --- a/packages-ts/gauntlet-terra-contracts/src/lib/utils.ts +++ b/packages-ts/gauntlet-terra-contracts/src/lib/utils.ts @@ -11,3 +11,19 @@ export function isValidAddress(address) { return false } } + +export function deepCopy(source: T): T { + return Buffer.isBuffer(source) + ? Buffer.from(source) + : Array.isArray(source) + ? source.map((item) => this.deepCopy(item)) + : source instanceof Date + ? new Date(source.getTime()) + : source && typeof source === 'object' + ? Object.getOwnPropertyNames(source).reduce((o, prop) => { + Object.defineProperty(o, prop, Object.getOwnPropertyDescriptor(source, prop)!) + o[prop] = this.deepCopy((source as { [key: string]: any })[prop]) + return o + }, Object.create(Object.getPrototypeOf(source))) + : (source as T) +} diff --git a/packages-ts/gauntlet-terra-cw-plus/README.md b/packages-ts/gauntlet-terra-cw-plus/README.md new file mode 100644 index 000000000..3c95c93f9 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/README.md @@ -0,0 +1 @@ +# Gauntlet Terra CW Plus \ No newline at end of file diff --git a/packages-ts/gauntlet-terra-cw-plus/package.json b/packages-ts/gauntlet-terra-cw-plus/package.json new file mode 100644 index 000000000..c7f364368 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/package.json @@ -0,0 +1,32 @@ +{ + "name": "@chainlink/gauntlet-terra-cw-plus", + "version": "0.0.1", + "description": "Gauntlet Terra CW Plus contracts", + "keywords": [ + "typescript", + "cli" + ], + "main": "./dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*", + "!dist/**/*.test.js" + ], + "scripts": { + "preinstall": "node ../../scripts/require-yarn.js", + "gauntlet": "ts-node ./src/index.ts", + "lint": "tsc", + "test": "SKIP_PROMPTS=true jest --runInBand", + "test:coverage": "yarn test --collectCoverage", + "test:ci": "yarn test --ci", + "lint:format": "yarn prettier --check ./src", + "format": "yarn prettier --write ./src", + "clean": "rm -rf ./dist/ ./bin/", + "build": "yarn clean && tsc", + "bundle": "yarn build && pkg ." + }, + "dependencies": { + "@chainlink/gauntlet-core": "0.0.7", + "@chainlink/gauntlet-terra": "*" + } +} diff --git a/packages-ts/gauntlet-terra-cw-plus/src/commands/inspect.ts b/packages-ts/gauntlet-terra-cw-plus/src/commands/inspect.ts new file mode 100644 index 000000000..38fd29402 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/commands/inspect.ts @@ -0,0 +1,121 @@ +import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' +import { Result } from '@chainlink/gauntlet-core' +import { logger } from '@chainlink/gauntlet-core/dist/utils' +import { Action, State, Vote } from '../lib/types' + +export default class Inspect extends TerraCommand { + static id = 'cw3_flex_multisig:inspect' + + constructor(flags, args: string[]) { + super(flags, args) + } + + makeRawTransaction = async () => { + throw new Error('Query method does not have any tx') + } + + fetchState = async (multisig: string, proposalId?: number): Promise => { + const query = this.provider.wasm.contractQuery.bind(this.provider.wasm) + return fetchProposalState(query)(multisig, proposalId) + } + + execute = async () => { + const msig = this.args[0] || process.env.CW3_FLEX_MULTISIG + const proposalId = Number(this.flags.proposal) + const state = await this.fetchState(msig, proposalId) + + logger.info(makeInspectionMessage(state)) + return {} as Result + } +} + +export const fetchProposalState = (query: (contractAddress: string, query: any) => Promise) => async ( + multisig: string, + proposalId?: number, +): Promise => { + const _queryMultisig = (params) => () => query(multisig, params) + const multisigQueries = [ + _queryMultisig({ + list_voters: {}, + }), + _queryMultisig({ + threshold: {}, + }), + ] + const proposalQueries = [ + _queryMultisig({ + proposal: { + proposal_id: proposalId, + }, + }), + _queryMultisig({ + list_votes: { + proposal_id: proposalId, + }, + }), + ] + const queries = !!proposalId ? multisigQueries.concat(proposalQueries) : multisigQueries + + const [groupState, thresholdState, proposalState, votes] = await Promise.all(queries.map((q) => q())) + + const multisigState = { + threshold: thresholdState.absolute_count.weight, + owners: groupState.voters.map((m) => m.addr), + } + if (!proposalId) { + return { + multisig: multisigState, + proposal: { + nextAction: Action.CREATE, + approvers: [], + }, + } + } + const toNextAction = { + passed: Action.EXECUTE, + open: Action.APPROVE, + pending: Action.APPROVE, + rejected: Action.NONE, + executed: Action.NONE, + } + return { + multisig: multisigState, + proposal: { + id: proposalId, + nextAction: toNextAction[proposalState.status], + currentStatus: proposalState.status, + data: proposalState.msgs, + approvers: votes.votes.filter((v) => v.vote === Vote.YES).map((v) => v.voter), + expiresAt: proposalState.expires.at_time ? new Date(proposalState.expires.at_time / 1e6) : null, + }, + } +} + +export const makeInspectionMessage = (state: State): string => { + const newline = `\n` + const indent = ' '.repeat(2) + const ownersList = state.multisig.owners.map((o) => `\n${indent.repeat(2)} - ${o}`).join('') + const multisigMessage = `Multisig State: + - Threshold: ${state.multisig.threshold} + - Total Owners: ${state.multisig.owners.length} + - Owners List: ${ownersList}` + + let proposalMessage = `Proposal State: + - Next Action: ${state.proposal.nextAction.toUpperCase()}` + + if (!state.proposal.id) return multisigMessage.concat(newline) + + const approversList = state.proposal.approvers.map((a) => `\n${indent.repeat(2)} - ${a}`).join('') + proposalMessage = proposalMessage.concat(` + - Proposal ID: ${state.proposal.id} + - Total Approvers: ${state.proposal.approvers.length} + - Approvers List: ${approversList} + `) + + if (state.proposal.expiresAt) { + const expiration = `- Approvals expire at ${state.proposal.expiresAt}` + proposalMessage = proposalMessage.concat(expiration) + } + + return multisigMessage.concat(newline).concat(proposalMessage).concat(newline) +} diff --git a/packages-ts/gauntlet-terra-cw-plus/src/commands/multisig.ts b/packages-ts/gauntlet-terra-cw-plus/src/commands/multisig.ts new file mode 100644 index 000000000..9cdea7cce --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/commands/multisig.ts @@ -0,0 +1,229 @@ +import { Result } from '@chainlink/gauntlet-core' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' +import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' +import { AccAddress, MsgExecuteContract, MsgSend } from '@terra-money/terra.js' +import { isDeepEqual } from '../lib/utils' +import { fetchProposalState, makeInspectionMessage } from './inspect' +import { Vote, Cw3WasmMsg, Action, State, Cw3BankMsg } from '../lib/types' + +type ProposalAction = ( + signer: AccAddress, + proposalId: number, + message: MsgExecuteContract | MsgSend, +) => Promise + +export const wrapCommand = (command) => { + return class Multisig extends TerraCommand { + command: TerraCommand + multisig: AccAddress + + static id = `${command.id}:multisig` + + constructor(flags, args) { + super(flags, args) + } + + buildCommand = async (flags, args): Promise => { + if (!AccAddress.validate(process.env.CW3_FLEX_MULTISIG)) throw new Error(`Invalid Multisig wallet address`) + if (!AccAddress.validate(process.env.CW4_GROUP)) throw new Error(`Invalid Multisig group address`) + this.multisig = process.env.CW3_FLEX_MULTISIG as AccAddress + + const c = new command(flags, args) as TerraCommand + await c.invokeMiddlewares(c, c.middlewares) + this.command = c.buildCommand ? await c.buildCommand(flags, args) : c + return this.command + } + + makeRawTransaction = async (signer: AccAddress, state?: State) => { + const message = await this.command.makeRawTransaction(this.multisig) + await this.command.simulate(this.multisig, [message]) + logger.info(`Command simulation successful.`) + + const operations = { + [Action.CREATE]: this.makeProposeTransaction, + [Action.APPROVE]: this.makeAcceptTransaction, + [Action.EXECUTE]: this.makeExecuteTransaction, + [Action.NONE]: () => { + throw new Error('No action needed') + }, + } + + if (state.proposal.nextAction !== Action.CREATE) { + this.require( + await this.isSameProposal(state.proposal.data, [this.toMsg(message)]), + 'The transaction generated is different from the proposal provided', + ) + } + + return operations[state.proposal.nextAction](signer, Number(this.flags.proposal), message) + } + + isSameProposal = (proposalMsgs: (Cw3WasmMsg | Cw3BankMsg)[], generatedMsgs: (Cw3WasmMsg | Cw3BankMsg)[]) => { + return isDeepEqual(proposalMsgs, generatedMsgs) + } + + toMsg = (message: MsgSend | MsgExecuteContract): Cw3BankMsg | Cw3WasmMsg => { + if (message instanceof MsgSend) return this.toBankMsg(message as MsgSend) + if (message instanceof MsgExecuteContract) return this.toWasmMsg(message as MsgExecuteContract) + } + + toBankMsg = (message: MsgSend): Cw3BankMsg => { + return { + bank: { + send: { + amount: message.amount.toArray().map((c) => c.toData()), + to_address: message.to_address, + }, + }, + } + } + + toWasmMsg = (message: MsgExecuteContract): Cw3WasmMsg => { + return { + wasm: { + execute: { + contract_addr: message.contract, + funds: message.coins.toArray().map((c) => c.toData()), + msg: Buffer.from(JSON.stringify(message.execute_msg)).toString('base64'), + }, + }, + } + } + + makeProposeTransaction: ProposalAction = async (signer, _, message) => { + logger.info('Generating data for creating new proposal') + const proposeInput = { + propose: { + description: command.id, + msgs: [this.toMsg(message)], + title: command.id, + // TODO: Set expiration time + // latest: { at_height: 7970238 }, + }, + } + return new MsgExecuteContract(signer, this.multisig, proposeInput) + } + + makeAcceptTransaction: ProposalAction = async (signer, proposalId) => { + logger.info(`Generating data for approving proposal ${proposalId}`) + const approvalInput = { + vote: { + vote: Vote.YES, + proposal_id: proposalId, + }, + } + return new MsgExecuteContract(signer, this.multisig, approvalInput) + } + + makeExecuteTransaction: ProposalAction = async (signer, proposalId) => { + logger.info(`Generating data for executing proposal ${proposalId}`) + const executeInput = { + execute: { + proposal_id: proposalId, + }, + } + return new MsgExecuteContract(signer, this.multisig, executeInput) + } + + fetchState = async (proposalId?: number): Promise => { + const query = this.provider.wasm.contractQuery.bind(this.provider.wasm) + return fetchProposalState(query)(this.multisig, proposalId) + } + + printPostInstructions = async (proposalId: number) => { + const state = await this.fetchState(proposalId) + if (!state.proposal.id) { + logger.error(`Proposal ${proposalId} not found`) + return + } + const approvalsLeft = state.multisig.threshold - state.proposal.approvers.length + const messages = { + passed: `The proposal reached the threshold and can be executed. Run the same command with the flag --proposal=${proposalId}`, + open: `The proposal needs ${approvalsLeft} more approvals. Run the same command with the flag --proposal=${proposalId}`, + pending: `The proposal needs ${approvalsLeft} more approvals. Run the same command with the flag --proposal=${proposalId}`, + rejected: `The proposal has been rejected. No actions available`, + executed: `The proposal has been executed. No more actions needed`, + } + logger.line() + logger.info(`${messages[state.proposal.currentStatus]}`) + logger.line() + } + + execute = async () => { + // TODO: Gauntlet core should initialize commands using `buildCommand` instead of new Command + await this.buildCommand(this.flags, this.args) + + let proposalId = !!this.flags.proposal && Number(this.flags.proposal) + const state = await this.fetchState(proposalId) + logger.info(makeInspectionMessage(state)) + + if (state.proposal.nextAction === Action.NONE) { + await this.printPostInstructions(proposalId) + return + } + const rawTx = await this.makeRawTransaction(this.wallet.key.accAddress, state) + + const actionMessage = { + [Action.CREATE]: 'CREATING', + [Action.APPROVE]: 'APPROVING', + [Action.EXECUTE]: 'EXECUTING', + } + + if (this.flags.execute) { + await this.command.beforeExecute(this.multisig) + + await prompt(`Continue ${actionMessage[state.proposal.nextAction]} proposal?`) + const tx = await this.signAndSend([rawTx]) + let response: Result = { + responses: [ + { + tx, + contract: this.multisig, + }, + ], + data: { + proposalId, + }, + } + + if (state.proposal.nextAction === Action.CREATE) { + const proposalFromEvent = tx.events[0].wasm.proposal_id[0] + logger.success(`New proposal created with ID: ${proposalFromEvent}`) + proposalId = Number(proposalFromEvent) + } + + if (state.proposal.nextAction === Action.EXECUTE && this.command.afterExecute) { + const data = this.command.afterExecute(response) + response = { ...response, data: { ...data } } + } + + logger.success(`TX finished at ${tx.hash}`) + await this.printPostInstructions(proposalId) + + return response + } + + // TODO: Test raw message + const msgData = Buffer.from(JSON.stringify(rawTx.execute_msg)).toString('base64') + logger.line() + logger.success(`Message generated succesfully for ${actionMessage[state.proposal.nextAction]} proposal`) + logger.log() + logger.log(msgData) + logger.log() + logger.line() + + return { + responses: [ + { + tx: {}, + contract: this.multisig, + }, + ], + data: { + proposalId, + message: msgData, + }, + } as Result + } + } +} diff --git a/packages-ts/gauntlet-terra-cw-plus/src/index.ts b/packages-ts/gauntlet-terra-cw-plus/src/index.ts new file mode 100644 index 000000000..5339e8e01 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/index.ts @@ -0,0 +1,6 @@ +import { wrapCommand as multisigWrapCommand } from './commands/multisig' +import Inspect from './commands/inspect' + +const commands = [Inspect] + +export { multisigWrapCommand, commands } diff --git a/packages-ts/gauntlet-terra-cw-plus/src/lib/types.ts b/packages-ts/gauntlet-terra-cw-plus/src/lib/types.ts new file mode 100644 index 000000000..ff67fd69c --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/lib/types.ts @@ -0,0 +1,54 @@ +import { AccAddress } from '@terra-money/terra.js' + +export enum Vote { + YES = 'yes', + NO = 'no', + ABS = 'abstain', + VETO = 'veto', +} + +export enum Action { + CREATE = 'create', + APPROVE = 'approve', + EXECUTE = 'execute', + NONE = 'none', +} + +type Coin = { + denom: string + amount: string +} + +export type Cw3WasmMsg = { + wasm: { + execute: { + contract_addr: string + funds: Coin[] + msg: string + } + } +} + +export type Cw3BankMsg = { + bank: { + send: { + amount: Coin[] + to_address: string + } + } +} + +export type State = { + multisig: { + threshold: number + owners: AccAddress[] + } + proposal: { + id?: number + nextAction: Action + currentStatus?: 'pending' | 'open' | 'rejected' | 'passed' | 'executed' + data?: Cw3WasmMsg[] + expiresAt?: Date + approvers: string[] + } +} diff --git a/packages-ts/gauntlet-terra-cw-plus/src/lib/utils.ts b/packages-ts/gauntlet-terra-cw-plus/src/lib/utils.ts new file mode 100644 index 000000000..074010855 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/lib/utils.ts @@ -0,0 +1,13 @@ +import assert from 'assert' + +export const isDeepEqual = (a: any, b: any) => { + try { + assert.deepStrictEqual(a, b) + } catch (error) { + if (error.name === 'AssertionError') { + return false + } + throw error + } + return true +} diff --git a/packages-ts/gauntlet-terra-cw-plus/tsconfig.json b/packages-ts/gauntlet-terra-cw-plus/tsconfig.json new file mode 100644 index 000000000..2c84c1fcb --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages-ts/gauntlet-terra/src/commands/internal/terra.ts b/packages-ts/gauntlet-terra/src/commands/internal/terra.ts index 72a140bf4..bdc5c8fda 100644 --- a/packages-ts/gauntlet-terra/src/commands/internal/terra.ts +++ b/packages-ts/gauntlet-terra/src/commands/internal/terra.ts @@ -1,16 +1,21 @@ import { Result, WriteCommand } from '@chainlink/gauntlet-core' import { logger } from '@chainlink/gauntlet-core/dist/utils' -import { EventsByType, MsgStoreCode, TxLog } from '@terra-money/terra.js' import { SignMode } from '@terra-money/terra.proto/cosmos/tx/signing/v1beta1/signing' - import { withProvider, withWallet, withCodeIds, withNetwork } from '../middlewares' import { + EventsByType, + MsgStoreCode, + AccAddress, + TxLog, + MsgSend, BlockTxBroadcastResult, LCDClient, MsgExecuteContract, MsgInstantiateContract, TxError, Wallet, + Msg, + SignerData, } from '@terra-money/terra.js' import { TransactionResponse } from '../types' import { LedgerKey } from '../ledgerKey' @@ -22,7 +27,16 @@ export default abstract class TerraCommand extends WriteCommand Promise> + abstract makeRawTransaction: (signer: AccAddress) => Promise + // Preferable option to initialize the command instead of new TerraCommand. This should be an static option to construct the command + buildCommand?: (flags, args) => Promise + beforeExecute: (context?: any) => Promise + + afterExecute = async (response: Result): Promise => { + logger.success(`Execution finished at transaction: ${response.responses[0].tx.hash}`) + } constructor(flags, args) { super(flags, args) @@ -63,6 +77,25 @@ export default abstract class TerraCommand extends WriteCommand => { + try { + logger.loading('Signing transaction...') + const tx = await this.wallet.createAndSignTx({ + msgs: messages, + ...(this.wallet.key instanceof LedgerKey && { + signMode: SignMode.SIGN_MODE_LEGACY_AMINO_JSON, + }), + }) + + logger.loading('Sending transaction...') + const res = await this.provider.tx.broadcast(tx) + return this.wrapResponse(res) + } catch (e) { + const message = e?.response?.data?.message || e.message + throw new Error(message) + } + } + async call(address, input) { const msg = new MsgExecuteContract(this.wallet.key.accAddress, address, input) @@ -74,15 +107,13 @@ export default abstract class TerraCommand extends WriteCommand { + const account = await this.provider.auth.accountInfo(signer) + + const signerData: SignerData = { + sequenceNumber: account.getSequenceNumber(), + publicKey: account.getPublicKey(), + } + + const tx = await this.provider.tx.create([{ ...signerData, address: signer }], { msgs }) + + // gas estimation successful => tx is valid (simulation is run under the hood) + return await this.provider.tx.estimateGas(tx, { + signers: [signerData], + }) + } } diff --git a/packages-ts/gauntlet-terra/src/commands/middlewares.ts b/packages-ts/gauntlet-terra/src/commands/middlewares.ts index 9d16e54b5..6a1956006 100644 --- a/packages-ts/gauntlet-terra/src/commands/middlewares.ts +++ b/packages-ts/gauntlet-terra/src/commands/middlewares.ts @@ -37,7 +37,7 @@ export const withWallet: Middleware = async (c: TerraCommand, next: Next) => { } c.wallet = c.provider.wallet(key) - logger.info(`Operator address is ${c.wallet.key.accAddress}`) + logger.debug(`Operator address is ${c.wallet.key.accAddress}`) return next() } diff --git a/packages-ts/gauntlet-terra/src/index.ts b/packages-ts/gauntlet-terra/src/index.ts index 5d73d8b88..b4b2a6a84 100644 --- a/packages-ts/gauntlet-terra/src/index.ts +++ b/packages-ts/gauntlet-terra/src/index.ts @@ -2,5 +2,7 @@ import TerraCommand from './commands/internal/terra' import { waitExecute } from './lib/execute' import { TransactionResponse } from './commands/types' import * as constants from './lib/constants' +import * as providerUtils from './lib/provider' +import * as RDD from './lib/rdd' -export { TerraCommand, waitExecute, TransactionResponse, constants } +export { TerraCommand, waitExecute, TransactionResponse, constants, providerUtils, RDD } diff --git a/packages-ts/gauntlet-terra/src/lib/provider.ts b/packages-ts/gauntlet-terra/src/lib/provider.ts new file mode 100644 index 000000000..218137a2a --- /dev/null +++ b/packages-ts/gauntlet-terra/src/lib/provider.ts @@ -0,0 +1,32 @@ +import { logger } from '@chainlink/gauntlet-core/dist/utils' +import { LCDClient, TxInfo } from '@terra-money/terra.js' + +export const filterTxsByEvent = (txs: TxInfo[], event: string): TxInfo | undefined => { + const filteredTx = txs.filter((tx) => tx.logs?.some((log) => log.eventsByType[event]))?.[0] + return filteredTx +} + +export const getBlockTxs = async (provider: LCDClient, block: number, offset = 0): Promise => { + // recursive call to get every tx in the block. API has a 100 tx limit. Increasing the offset 100 every time + try { + const txs = await provider.tx.search({ + events: [ + { + key: 'tx.height', + value: String(block), + }, + ], + 'pagination.offset': String(offset), + }) + return txs.txs.concat(await getBlockTxs(provider, block, offset + 100)) + } catch (e) { + const expectedError = 'page should be within' + if (!((e.response?.data?.message as string) || '').includes(expectedError)) { + logger.error(`Error fetching block ${block} and offset ${offset}: ${e.response?.data?.message || e.message}`) + return [] + } + logger.debug(`No more txs in block ${block}. Last offset ${offset}`) + } + + return [] +} diff --git a/packages-ts/gauntlet-terra/src/lib/rdd.ts b/packages-ts/gauntlet-terra/src/lib/rdd.ts new file mode 100644 index 000000000..a735a5b2b --- /dev/null +++ b/packages-ts/gauntlet-terra/src/lib/rdd.ts @@ -0,0 +1,53 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +export const getRDD = (path: string, fileDescription: string = 'RDD'): any => { + let pathToUse + // test whether the file exists as a relative path or an absolute path + if (existsSync(path)) { + pathToUse = path + } else if (existsSync(join(process.cwd(), path))) { + pathToUse = join(process.cwd(), path) + } else { + throw new Error(`Could not find the ${fileDescription}. Make sure you provided a valid ${fileDescription} path`) + } + + try { + const buffer = readFileSync(pathToUse, 'utf8') + return JSON.parse(buffer.toString()) + } catch (e) { + throw new Error( + `An error ocurred while parsing the ${fileDescription}. Make sure you provided a valid ${fileDescription} path`, + ) + } +} + +export enum CONTRACT_TYPES { + PROXY = 'proxies', + FLAG = 'flags', + ACCESS_CONTROLLER = 'accessControllers', + CONTRACT = 'contracts', + VALIDATOR = 'validators', +} + +export type RDDContract = { + type: CONTRACT_TYPES + contract: any + address: string + description?: string +} + +export const getContractFromRDD = (rdd: any, address: string): RDDContract => { + return Object.values(CONTRACT_TYPES).reduce((agg, type) => { + const content = rdd[type]?.[address] + if (content) { + return { + type, + contract: content, + address, + ...((type === CONTRACT_TYPES.CONTRACT || type === CONTRACT_TYPES.PROXY) && { description: content.name }), + } + } + return agg + }, {} as RDDContract) +} diff --git a/pkg/monitoring/chain_reader.go b/pkg/monitoring/chain_reader.go new file mode 100644 index 000000000..1972d1098 --- /dev/null +++ b/pkg/monitoring/chain_reader.go @@ -0,0 +1,87 @@ +package monitoring + +import ( + "context" + "sync" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/query" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + pkgClient "github.com/smartcontractkit/chainlink-terra/pkg/terra/client" +) + +// ChainReader is a subset of the pkg/terra/client.Reader interface enhanced with context support. +type ChainReader interface { + TxsEvents(ctx context.Context, events []string, paginationParams *query.PageRequest) (*txtypes.GetTxsEventResponse, error) + ContractStore(ctx context.Context, contractAddress sdk.AccAddress, queryMsg []byte) ([]byte, error) +} + +// NewChainReader produces a ChainReader that issues requests to the Terra RPC +// in sequence, even if it's called by multiple sources in parallel. +// That's because the Terra endpoint is aggresively rate limitting the monitor. +func NewChainReader(client *pkgClient.Client) ChainReader { + return &chainReader{ + client, + sync.Mutex{}, + sync.Mutex{}, + } +} + +type chainReader struct { + client *pkgClient.Client + + txEventsSequencer sync.Mutex + contractStoreSequencer sync.Mutex +} + +func (c *chainReader) TxsEvents(ctx context.Context, events []string, paginationParams *query.PageRequest) (*txtypes.GetTxsEventResponse, error) { + c.txEventsSequencer.Lock() + defer c.txEventsSequencer.Unlock() + raw, err := withContext(ctx, func() (interface{}, error) { + return c.client.TxsEvents(events, paginationParams) + }) + if err != nil { + return nil, err + } + return raw.(*txtypes.GetTxsEventResponse), nil +} + +func (c *chainReader) ContractStore(ctx context.Context, contractAddress sdk.AccAddress, queryMsg []byte) ([]byte, error) { + c.contractStoreSequencer.Lock() + defer c.contractStoreSequencer.Unlock() + raw, err := withContext(ctx, func() (interface{}, error) { + return c.client.ContractStore(contractAddress, queryMsg) + }) + if err != nil { + return nil, err + } + return raw.([]byte), nil +} + +type callResult struct { + data interface{} + err error +} + +// Helpers + +// withContext makes a function that does not take in a context exit when the context cancels or expires. +// In reality withContext will abandon a call that continues after the context expires and simply return an error. +// This helper is needed, because the cosmos/tendermint clients don't respect context. +// Note! This method may leak goroutines. +func withContext(ctx context.Context, call func() (interface{}, error)) (interface{}, error) { + callResults := make(chan callResult) + go func() { + data, err := call() + select { + case callResults <- callResult{data, err}: + case <-ctx.Done(): + } + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-callResults: + return result.data, result.err + } +} diff --git a/pkg/monitoring/chain_reader_test.go b/pkg/monitoring/chain_reader_test.go new file mode 100644 index 000000000..f6ca966b5 --- /dev/null +++ b/pkg/monitoring/chain_reader_test.go @@ -0,0 +1,48 @@ +package monitoring + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestChainReader(t *testing.T) { + t.Run("withContext()", func(t *testing.T) { + t.Run("should return an error when context expires", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + data, err := withContext(ctx, func() (interface{}, error) { + <-time.After(150 * time.Millisecond) + return "some fake value", nil + }) + require.Error(t, context.DeadlineExceeded, err) + require.Equal(t, nil, data) + }) + t.Run("should return an error when context is cancelled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + <-time.After(50 * time.Millisecond) + cancel() + }() + data, err := withContext(ctx, func() (interface{}, error) { + <-time.After(150 * time.Millisecond) + return "some fake value", nil + }) + require.Error(t, context.DeadlineExceeded, err) + require.Equal(t, nil, data) + }) + t.Run("should return whatever the call returned and not leak any goroutines", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + data, err := withContext(ctx, func() (interface{}, error) { + <-time.After(50 * time.Millisecond) + return "some fake value", nil + }) + require.NoError(t, err) + require.Equal(t, "some fake value", data) + }) + }) +} diff --git a/pkg/monitoring/exporter_prometheus.go b/pkg/monitoring/exporter_prometheus.go new file mode 100644 index 000000000..72a169bc1 --- /dev/null +++ b/pkg/monitoring/exporter_prometheus.go @@ -0,0 +1,122 @@ +package monitoring + +import ( + "context" + "fmt" + "math/big" + "sync" + + relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" +) + +// NewPrometheusExporterFactory builds an implementation of the Exporter for prometheus. +func NewPrometheusExporterFactory( + log relayMonitoring.Logger, + metrics Metrics, +) relayMonitoring.ExporterFactory { + return &prometheusExporterFactory{ + log, + metrics, + } +} + +type prometheusExporterFactory struct { + log relayMonitoring.Logger + metrics Metrics +} + +func (p *prometheusExporterFactory) NewExporter( + chainConfig relayMonitoring.ChainConfig, + feedConfig relayMonitoring.FeedConfig, +) (relayMonitoring.Exporter, error) { + terraFeedConfig, ok := feedConfig.(TerraFeedConfig) + if !ok { + return nil, fmt.Errorf("expected feedConfig to be of type TerraFeedConfig not %T", feedConfig) + } + return &prometheusExporter{ + chainConfig, + terraFeedConfig, + p.log, + p.metrics, + sync.Mutex{}, + map[string]struct{}{}, + }, nil +} + +type prometheusExporter struct { + chainConfig relayMonitoring.ChainConfig + feedConfig TerraFeedConfig + log relayMonitoring.Logger + metrics Metrics + addressesMu sync.Mutex + addressesSet map[string]struct{} +} + +func (p *prometheusExporter) Export(ctx context.Context, data interface{}) { + proxyData, isProxyData := data.(ProxyData) + if !isProxyData { + return + } + answer := float64(proxyData.Answer.Uint64()) + linkAvailableForPayment, _ := new(big.Float).SetInt(proxyData.LinkAvailableForPayment).Float64() + multiply := float64(p.feedConfig.Multiply.Uint64()) + if multiply == 0 { + multiply = 1.0 + } + p.metrics.SetProxyAnswersRaw( + answer, + p.feedConfig.ProxyAddressBech32, + p.feedConfig.GetID(), + p.chainConfig.GetChainID(), + p.feedConfig.GetContractStatus(), + p.feedConfig.GetContractType(), + p.feedConfig.GetName(), + p.feedConfig.GetPath(), + p.chainConfig.GetNetworkID(), + p.chainConfig.GetNetworkName(), + ) + p.metrics.SetProxyAnswers( + answer/multiply, + p.feedConfig.ProxyAddressBech32, + p.feedConfig.GetID(), + p.chainConfig.GetChainID(), + p.feedConfig.GetContractStatus(), + p.feedConfig.GetContractType(), + p.feedConfig.GetName(), + p.feedConfig.GetPath(), + p.chainConfig.GetNetworkID(), + p.chainConfig.GetNetworkName(), + ) + p.metrics.SetLinkAvailableForPayment( + linkAvailableForPayment, + p.feedConfig.GetID(), + p.chainConfig.GetChainID(), + p.feedConfig.GetContractStatus(), + p.feedConfig.GetContractType(), + p.feedConfig.GetName(), + p.feedConfig.GetPath(), + p.chainConfig.GetNetworkID(), + p.chainConfig.GetNetworkName(), + ) + p.addressesMu.Lock() + defer p.addressesMu.Unlock() + p.addressesSet[p.feedConfig.ProxyAddressBech32] = struct{}{} +} + +func (p *prometheusExporter) Cleanup(_ context.Context) { + p.addressesMu.Lock() + defer p.addressesMu.Unlock() + for address := range p.addressesSet { + p.metrics.Cleanup( + address, + p.feedConfig.GetContractAddress(), + p.chainConfig.GetChainID(), + p.feedConfig.GetContractStatus(), + p.feedConfig.GetContractType(), + p.feedConfig.GetName(), + p.feedConfig.GetPath(), + p.chainConfig.GetNetworkID(), + p.chainConfig.GetNetworkName(), + ) + } +} diff --git a/pkg/monitoring/feed.go b/pkg/monitoring/feed.go index 69a569c7d..bf24d2590 100644 --- a/pkg/monitoring/feed.go +++ b/pkg/monitoring/feed.go @@ -23,6 +23,10 @@ type TerraFeedConfig struct { ContractAddressBech32 string `json:"contract_address_bech32,omitempty"` ContractAddress sdk.AccAddress `json:"-"` + + // Optional fields! Internal feeds are not proxied. Check ProxyAddressBech32 == ""! + ProxyAddressBech32 string `json:"proxy_address_bech32,omitempty"` + ProxyAddress sdk.AccAddress `json:"-"` } var _ relayMonitoring.FeedConfig = TerraFeedConfig{} @@ -108,6 +112,14 @@ func TerraFeedParser(buf io.ReadCloser) ([]relayMonitoring.FeedConfig, error) { if err != nil { return nil, fmt.Errorf("failed to parse contract address '%s' from JSON at index i=%d: %w", rawFeed.ContractAddressBech32, i, err) } + var proxyAddress sdk.AccAddress + if rawFeed.ProxyAddressBech32 != "" { + address, err := sdk.AccAddressFromBech32(rawFeed.ProxyAddressBech32) + if err != nil { + return nil, fmt.Errorf("failed to parse proxy contract address '%s' from JSON at index i=%d: %w", rawFeed.ProxyAddressBech32, i, err) + } + proxyAddress = address + } multiply, ok := new(big.Int).SetString(rawFeed.MultiplyRaw, 10) if !ok { return nil, fmt.Errorf("failed to parse multiply '%s' into a big.Int", rawFeed.MultiplyRaw) @@ -124,6 +136,8 @@ func TerraFeedParser(buf io.ReadCloser) ([]relayMonitoring.FeedConfig, error) { multiply, rawFeed.ContractAddressBech32, contractAddress, + rawFeed.ProxyAddressBech32, + proxyAddress, }) } return feeds, nil diff --git a/pkg/monitoring/go_generate.go b/pkg/monitoring/go_generate.go new file mode 100644 index 000000000..f58d920c7 --- /dev/null +++ b/pkg/monitoring/go_generate.go @@ -0,0 +1,4 @@ +package monitoring + +//go:generate mockery --name ChainReader --output ./mocks/ +//go:generate mockery --name Metrics --output ./mocks/ diff --git a/pkg/monitoring/metrics.go b/pkg/monitoring/metrics.go new file mode 100644 index 000000000..21ad9aa7c --- /dev/null +++ b/pkg/monitoring/metrics.go @@ -0,0 +1,125 @@ +package monitoring + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" +) + +// Metrics is an interface for prometheus metrics. Makes testing easier. +type Metrics interface { + SetProxyAnswersRaw(answer float64, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName string) + SetProxyAnswers(answer float64, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName string) + SetLinkAvailableForPayment(amount float64, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName string) + Cleanup(proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName string) +} + +var ( + proxyAnswersRaw = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "proxy_answers_raw", + Help: "Reports the latest raw answer from the proxy contract.", + }, + []string{"proxy_contract_address", "feed_id", "chain_id", "contract_status", "contract_type", "feed_name", "feed_path", "network_id", "network_name"}, + ) + proxyAnswers = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "proxy_answers", + Help: "Reports the latest answer from the proxy contract divided by the feed's multiplier parameter.", + }, + []string{"proxy_contract_address", "feed_id", "chain_id", "contract_status", "contract_type", "feed_name", "feed_path", "network_id", "network_name"}, + ) + linkAvailableForPayment = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "link_available_for_payments", + Help: "Reports the amount of link the contract can use to make payments to node operators. This may be different from the LINK balance of the contract since that can contain debt", + }, + []string{"feed_id", "chain_id", "contract_status", "contract_type", "feed_name", "feed_path", "network_id", "network_name"}, + ) +) + +// NewMetrics does wisott +func NewMetrics(log relayMonitoring.Logger) Metrics { + return &defaultMetrics{log} +} + +type defaultMetrics struct { + log relayMonitoring.Logger +} + +func (d *defaultMetrics) SetProxyAnswersRaw(answer float64, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName string) { + proxyAnswersRaw.With(prometheus.Labels{ + "proxy_contract_address": proxyContractAddress, + "feed_id": feedID, + "chain_id": chainID, + "contract_status": contractStatus, + "contract_type": contractType, + "feed_name": feedName, + "feed_path": feedPath, + "network_id": networkID, + "network_name": networkName, + }).Set(answer) +} + +func (d *defaultMetrics) SetProxyAnswers(answer float64, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName string) { + proxyAnswers.With(prometheus.Labels{ + "proxy_contract_address": proxyContractAddress, + "feed_id": feedID, + "chain_id": chainID, + "contract_status": contractStatus, + "contract_type": contractType, + "feed_name": feedName, + "feed_path": feedPath, + "network_id": networkID, + "network_name": networkName, + }).Set(answer) +} + +func (d *defaultMetrics) SetLinkAvailableForPayment(amount float64, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName string) { + linkAvailableForPayment.With(prometheus.Labels{ + "feed_id": feedID, + "chain_id": chainID, + "contract_status": contractStatus, + "contract_type": contractType, + "feed_name": feedName, + "feed_path": feedPath, + "network_id": networkID, + "network_name": networkName, + }).Set(amount) +} + +func (d *defaultMetrics) Cleanup( + proxyContractAddress, feedID, chainID, contractStatus, contractType string, + feedName, feedPath, networkID, networkName string, +) { + labels := prometheus.Labels{ + "proxy_contract_address": proxyContractAddress, + "feed_id": feedID, + "chain_id": chainID, + "contract_status": contractStatus, + "contract_type": contractType, + "feed_name": feedName, + "feed_path": feedPath, + "network_id": networkID, + "network_name": networkName, + } + if !proxyAnswersRaw.Delete(labels) { + d.log.Errorw("failed to delete metric", "name", "proxy_answers_raw", "labels", labels) + } + if !proxyAnswers.Delete(labels) { + d.log.Errorw("failed to delete metric", "name", "proxy_answers", "labels", labels) + } + linkLeftLabels := prometheus.Labels{ + "feed_id": feedID, + "chain_id": chainID, + "contract_status": contractStatus, + "contract_type": contractType, + "feed_name": feedName, + "feed_path": feedPath, + "network_id": networkID, + "network_name": networkName, + } + if !linkAvailableForPayment.Delete(linkLeftLabels) { + d.log.Errorw("failed to delete metric", "name", "link_available_for_payment", "labels", linkLeftLabels) + } +} diff --git a/pkg/monitoring/mocks/ChainReader.go b/pkg/monitoring/mocks/ChainReader.go new file mode 100644 index 000000000..b22b9dd33 --- /dev/null +++ b/pkg/monitoring/mocks/ChainReader.go @@ -0,0 +1,66 @@ +// Code generated by mockery v2.9.4. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + query "github.com/cosmos/cosmos-sdk/types/query" + + tx "github.com/cosmos/cosmos-sdk/types/tx" + + types "github.com/cosmos/cosmos-sdk/types" +) + +// ChainReader is an autogenerated mock type for the ChainReader type +type ChainReader struct { + mock.Mock +} + +// ContractStore provides a mock function with given fields: ctx, contractAddress, queryMsg +func (_m *ChainReader) ContractStore(ctx context.Context, contractAddress types.AccAddress, queryMsg []byte) ([]byte, error) { + ret := _m.Called(ctx, contractAddress, queryMsg) + + var r0 []byte + if rf, ok := ret.Get(0).(func(context.Context, types.AccAddress, []byte) []byte); ok { + r0 = rf(ctx, contractAddress, queryMsg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, types.AccAddress, []byte) error); ok { + r1 = rf(ctx, contractAddress, queryMsg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TxsEvents provides a mock function with given fields: ctx, events, paginationParams +func (_m *ChainReader) TxsEvents(ctx context.Context, events []string, paginationParams *query.PageRequest) (*tx.GetTxsEventResponse, error) { + ret := _m.Called(ctx, events, paginationParams) + + var r0 *tx.GetTxsEventResponse + if rf, ok := ret.Get(0).(func(context.Context, []string, *query.PageRequest) *tx.GetTxsEventResponse); ok { + r0 = rf(ctx, events, paginationParams) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*tx.GetTxsEventResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []string, *query.PageRequest) error); ok { + r1 = rf(ctx, events, paginationParams) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/monitoring/mocks/Metrics.go b/pkg/monitoring/mocks/Metrics.go new file mode 100644 index 000000000..da57f80db --- /dev/null +++ b/pkg/monitoring/mocks/Metrics.go @@ -0,0 +1,30 @@ +// Code generated by mockery v2.9.4. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// Metrics is an autogenerated mock type for the Metrics type +type Metrics struct { + mock.Mock +} + +// Cleanup provides a mock function with given fields: proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName +func (_m *Metrics) Cleanup(proxyContractAddress string, feedID string, chainID string, contractStatus string, contractType string, feedName string, feedPath string, networkID string, networkName string) { + _m.Called(proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName) +} + +// SetLinkAvailableForPayment provides a mock function with given fields: amount, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName +func (_m *Metrics) SetLinkAvailableForPayment(amount float64, feedID string, chainID string, contractStatus string, contractType string, feedName string, feedPath string, networkID string, networkName string) { + _m.Called(amount, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName) +} + +// SetProxyAnswers provides a mock function with given fields: answer, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName +func (_m *Metrics) SetProxyAnswers(answer float64, proxyContractAddress string, feedID string, chainID string, contractStatus string, contractType string, feedName string, feedPath string, networkID string, networkName string) { + _m.Called(answer, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName) +} + +// SetProxyAnswersRaw provides a mock function with given fields: answer, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName +func (_m *Metrics) SetProxyAnswersRaw(answer float64, proxyContractAddress string, feedID string, chainID string, contractStatus string, contractType string, feedName string, feedPath string, networkID string, networkName string) { + _m.Called(answer, proxyContractAddress, feedID, chainID, contractStatus, contractType, feedName, feedPath, networkID, networkName) +} diff --git a/pkg/monitoring/proxy_monitoring_test.go b/pkg/monitoring/proxy_monitoring_test.go new file mode 100644 index 000000000..8421ec3b2 --- /dev/null +++ b/pkg/monitoring/proxy_monitoring_test.go @@ -0,0 +1,134 @@ +package monitoring + +import ( + "context" + "math/big" + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" + "github.com/smartcontractkit/chainlink-terra/pkg/monitoring/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestProxyMonitoring(t *testing.T) { + t.Run("the read proxied value should be reported to prometheus", func(t *testing.T) { + // This test checks both the source and the corresponding exporter. + // It does so by using a mock ChainReader to return values that the real proxy would return. + // Then it uses a mock Metrics object to record the data exported to prometheus. + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + chainConfig := generateChainConfig() + feedConfig := generateFeedConfig() + feedConfig.Multiply = big.NewInt(100) + + chainReader := new(mocks.ChainReader) + chainReader.Test(t) + metrics := new(mocks.Metrics) + metrics.Test(t) + + sourceFactory := NewProxySourceFactory(chainReader, newNullLogger()) + source, err := sourceFactory.NewSource(chainConfig, feedConfig) + require.NoError(t, err) + + exporterFactory := NewPrometheusExporterFactory(newNullLogger(), metrics) + exporter, err := exporterFactory.NewExporter(chainConfig, feedConfig) + require.NoError(t, err) + + // Setup claims. + chainReader.On("ContractStore", + mock.Anything, // context + feedConfig.ProxyAddress, + []byte(`"latest_round_data"`), + ).Return( + []byte(`{"round_id":5709,"answer":"2632212500","observations_timestamp":1645456354,"transmission_timestamp":1645456380}`), + nil, + ).Once() + chainReader.On("ContractStore", + mock.Anything, // context + feedConfig.ProxyAddress, + []byte(`"link_available_for_payment"`), + ).Return( + []byte(`{"amount":"-380431529018756503364"}`), + nil, + ).Once() + metrics.On("SetProxyAnswersRaw", + float64(2632212500), // answer + feedConfig.ProxyAddressBech32, // proxyContractAddress + feedConfig.GetID(), // feedID + chainConfig.GetChainID(), // chainID + feedConfig.GetContractStatus(), // contractStatus + feedConfig.GetContractType(), // contractType + feedConfig.GetName(), // feedName + feedConfig.GetPath(), // feedPath + chainConfig.GetNetworkID(), // networkID + chainConfig.GetNetworkName(), // networkName + ) + metrics.On("SetProxyAnswers", + float64(26322125), // answer / multiply + feedConfig.ProxyAddressBech32, // proxyContractAddress + feedConfig.GetID(), // feedID + chainConfig.GetChainID(), // chainID + feedConfig.GetContractStatus(), // contractStatus + feedConfig.GetContractType(), // contractType + feedConfig.GetName(), // feedName + feedConfig.GetPath(), // feedPath + chainConfig.GetNetworkID(), // networkID + chainConfig.GetNetworkName(), // networkName + ) + metrics.On("SetLinkAvailableForPayment", + float64(-380431529018756503364), // link balance + feedConfig.GetID(), // feedID + chainConfig.GetChainID(), // chainID + feedConfig.GetContractStatus(), // contractStatus + feedConfig.GetContractType(), // contractType + feedConfig.GetName(), // feedName + feedConfig.GetPath(), // feedPath + chainConfig.GetNetworkID(), // networkID + chainConfig.GetNetworkName(), // networkName + ) + metrics.On("Cleanup", + feedConfig.ProxyAddressBech32, // proxyContractAddress + feedConfig.GetID(), // feedID + chainConfig.GetChainID(), // chainID + feedConfig.GetContractStatus(), // contractStatus + feedConfig.GetContractType(), // contractType + feedConfig.GetName(), // feedName + feedConfig.GetPath(), // feedPath + chainConfig.GetNetworkID(), // networkID + chainConfig.GetNetworkName(), // networkName + ) + + // Run the setup + data, err := source.Fetch(ctx) + require.NoError(t, err) + exporter.Export(ctx, data) + exporter.Cleanup(ctx) + + // Assertions + mock.AssertExpectationsForObjects(t, chainReader) + mock.AssertExpectationsForObjects(t, metrics) + }) + t.Run("contract without a proxy are not monitored by the proxy source", func(t *testing.T) { + chainConfig := generateChainConfig() + feedConfig := generateFeedConfig() + feedConfig.ProxyAddressBech32 = "" + feedConfig.ProxyAddress = sdk.AccAddress{} + + chainReader := new(mocks.ChainReader) + chainReader.Test(t) + + sourceFactory := NewProxySourceFactory(chainReader, newNullLogger()) + source, err := sourceFactory.NewSource(chainConfig, feedConfig) + require.NoError(t, err) + + data, err := source.Fetch(context.Background()) + require.Nil(t, data) + require.Error(t, err, relayMonitoring.ErrNoUpdate) + + }) +} diff --git a/pkg/monitoring/source_envelope.go b/pkg/monitoring/source_envelope.go index 68d93161c..7d94a0736 100644 --- a/pkg/monitoring/source_envelope.go +++ b/pkg/monitoring/source_envelope.go @@ -14,21 +14,19 @@ import ( cosmosTx "github.com/cosmos/cosmos-sdk/types/tx" relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" pkgTerra "github.com/smartcontractkit/chainlink-terra/pkg/terra" - pkgClient "github.com/smartcontractkit/chainlink-terra/pkg/terra/client" - "github.com/smartcontractkit/chainlink/core/logger" "github.com/smartcontractkit/libocr/offchainreporting2/types" "go.uber.org/multierr" ) // NewEnvelopeSourceFactory build a new object that reads observations and // configurations from the Terra chain. -func NewEnvelopeSourceFactory(client pkgClient.Reader, log logger.Logger) relayMonitoring.SourceFactory { +func NewEnvelopeSourceFactory(client ChainReader, log relayMonitoring.Logger) relayMonitoring.SourceFactory { return &envelopeSourceFactory{client, log} } type envelopeSourceFactory struct { - client pkgClient.Reader - log logger.Logger + client ChainReader + log relayMonitoring.Logger } func (e *envelopeSourceFactory) NewSource( @@ -51,9 +49,13 @@ func (e *envelopeSourceFactory) NewSource( }, nil } +func (e *envelopeSourceFactory) GetType() string { + return "envelope" +} + type envelopeSource struct { - client pkgClient.Reader - log logger.Logger + client ChainReader + log relayMonitoring.Logger terraConfig TerraConfig terraFeedConfig TerraFeedConfig } @@ -70,7 +72,8 @@ func (e *envelopeSource) Fetch(ctx context.Context) (interface{}, error) { wg.Add(3) go func() { defer wg.Done() - configDigest, epoch, round, latestAnswer, latestTimestamp, blockNumber, transmitter, aggregatorRoundID, juelsPerFeeCoin, err := e.fetchLatestTransmission() + configDigest, epoch, round, latestAnswer, latestTimestamp, blockNumber, + transmitter, aggregatorRoundID, juelsPerFeeCoin, err := e.fetchLatestTransmission(ctx) envelopeMu.Lock() defer envelopeMu.Unlock() if err != nil { @@ -82,6 +85,7 @@ func (e *envelopeSource) Fetch(ctx context.Context) (interface{}, error) { envelope.Round = round envelope.LatestAnswer = latestAnswer envelope.LatestTimestamp = latestTimestamp + // Note: block number is read from the transmission transaction, not set_config! envelope.BlockNumber = blockNumber envelope.Transmitter = transmitter envelope.JuelsPerFeeCoin = juelsPerFeeCoin @@ -89,7 +93,7 @@ func (e *envelopeSource) Fetch(ctx context.Context) (interface{}, error) { }() go func() { defer wg.Done() - contractConfig, err := e.fetchLatestConfig() + contractConfig, err := e.fetchLatestConfig(ctx) envelopeMu.Lock() defer envelopeMu.Unlock() if err != nil { @@ -100,25 +104,11 @@ func (e *envelopeSource) Fetch(ctx context.Context) (interface{}, error) { }() go func() { defer wg.Done() - query := fmt.Sprintf(`{"balance":{"address":"%s"}}`, e.terraFeedConfig.ContractAddressBech32) - res, err := e.client.ContractStore( - e.terraConfig.LinkTokenAddress, - []byte(query), - ) + balance, err := e.fetchLinkBalance(ctx) envelopeMu.Lock() defer envelopeMu.Unlock() if err != nil { - envelopeErr = multierr.Combine(envelopeErr, fmt.Errorf("failed to fetch balance: %w", err)) - return - } - balanceRes := linkBalanceResponse{} - if err = json.Unmarshal(res, &balanceRes); err != nil { - envelopeErr = multierr.Combine(envelopeErr, fmt.Errorf("failed to unmarshal balance response: %w", err)) - return - } - balance, success := new(big.Int).SetString(balanceRes.Balance, 10) - if !success { - envelopeErr = multierr.Combine(fmt.Errorf("failed to parse link balance from '%s'", balanceRes.Balance)) + envelopeErr = multierr.Combine(envelopeErr, fmt.Errorf("failed to fetch link balance: %w", err)) return } envelope.LinkBalance = balance @@ -127,7 +117,7 @@ func (e *envelopeSource) Fetch(ctx context.Context) (interface{}, error) { return envelope, envelopeErr } -func (e *envelopeSource) fetchLatestTransmission() ( +func (e *envelopeSource) fetchLatestTransmission(ctx context.Context) ( configDigest types.ConfigDigest, epoch uint32, round uint8, @@ -140,9 +130,9 @@ func (e *envelopeSource) fetchLatestTransmission() ( err error, ) { query := []string{ - fmt.Sprintf("wasm-new_transmission.contract_address='%s'", e.terraFeedConfig.ContractAddressBech32), + fmt.Sprintf(`wasm-new_transmission.contract_address='%s'`, e.terraFeedConfig.ContractAddressBech32), } - res, err := e.client.TxsEvents(query, &cosmosQuery.PageRequest{Limit: 1}) + res, err := e.client.TxsEvents(ctx, query, &cosmosQuery.PageRequest{Limit: 1}) if err != nil { return types.ConfigDigest{}, 0, 0, nil, time.Time{}, 0, "", 0, nil, fmt.Errorf("failed to fetch latest 'new_transmission' event: %w", err) @@ -183,7 +173,7 @@ func (e *envelopeSource) fetchLatestTransmission() ( aggregatorRoundID = uint32(raw) return pasrseErr }, - "juels_per_luna": func(value string) error { + "juels_per_fee_coin": func(value string) error { var success bool juelsPerFeeCoin, success = new(big.Int).SetString(value, 10) if !success { @@ -201,11 +191,11 @@ func (e *envelopeSource) fetchLatestTransmission() ( transmitter, aggregatorRoundID, juelsPerFeeCoin, nil } -func (e *envelopeSource) fetchLatestConfig() (types.ContractConfig, error) { +func (e *envelopeSource) fetchLatestConfig(ctx context.Context) (types.ContractConfig, error) { query := []string{ - fmt.Sprintf("wasm-set_config.contract_address='%s'", e.terraFeedConfig.ContractAddressBech32), + fmt.Sprintf(`wasm-set_config.contract_address='%s'`, e.terraFeedConfig.ContractAddressBech32), } - res, err := e.client.TxsEvents(query, &cosmosQuery.PageRequest{Limit: 1}) + res, err := e.client.TxsEvents(ctx, query, &cosmosQuery.PageRequest{Limit: 1}) if err != nil { return types.ContractConfig{}, fmt.Errorf("failed to fetch latest 'set_config' event: %w", err) } @@ -262,6 +252,27 @@ func (e *envelopeSource) fetchLatestConfig() (types.ContractConfig, error) { return output, nil } +func (e *envelopeSource) fetchLinkBalance(ctx context.Context) (*big.Int, error) { + query := fmt.Sprintf(`{"balance":{"address":"%s"}}`, e.terraFeedConfig.ContractAddressBech32) + res, err := e.client.ContractStore( + ctx, + e.terraConfig.LinkTokenAddress, + []byte(query), + ) + if err != nil { + return nil, fmt.Errorf("failed to fetch balance: %w", err) + } + balanceRes := linkBalanceResponse{} + if err = json.Unmarshal(res, &balanceRes); err != nil { + return nil, fmt.Errorf("failed to unmarshal balance response: %w", err) + } + balance, success := new(big.Int).SetString(balanceRes.Balance, 10) + if !success { + return nil, fmt.Errorf("failed to parse link balance from '%s'", balanceRes.Balance) + } + return balance, nil +} + // Helpers func extractDataFromTxResponse(eventType string, res *cosmosTx.GetTxsEventResponse, extractors map[string]func(string) error) error { @@ -270,6 +281,10 @@ func extractDataFromTxResponse(eventType string, res *cosmosTx.GetTxsEventRespon len(res.TxResponses[0].Logs[0].Events) == 0 { return fmt.Errorf("no events found of event type '%s'", eventType) } + extracted := map[string]bool{} + for key := range extractors { + extracted[key] = false + } for _, event := range res.TxResponses[0].Logs[0].Events { if event.Type != eventType { continue @@ -283,6 +298,12 @@ func extractDataFromTxResponse(eventType string, res *cosmosTx.GetTxsEventRespon if err := extractor(value); err != nil { return fmt.Errorf("failed to extract '%s' from raw value '%s': %w", key, value, err) } + extracted[key] = true + } + } + for key, wasExtracted := range extracted { + if !wasExtracted { + return fmt.Errorf("failed to extract key '%s' TxEventResponse", key) } } return nil diff --git a/pkg/monitoring/source_envelope_test.go b/pkg/monitoring/source_envelope_test.go new file mode 100644 index 000000000..da87b230a --- /dev/null +++ b/pkg/monitoring/source_envelope_test.go @@ -0,0 +1,254 @@ +package monitoring + +import ( + "context" + "encoding/hex" + "fmt" + "math/big" + "testing" + "time" + + "github.com/cosmos/cosmos-sdk/types" + cosmosQuery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/cosmos/cosmos-sdk/types/tx" + relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" + "github.com/smartcontractkit/chainlink-terra/pkg/monitoring/mocks" + ocr2types "github.com/smartcontractkit/libocr/offchainreporting2/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestEnvelopeSource(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + // Setup API responses + balanceRes := []byte(`{"balance":"1234567890987654321"}`) + setConfigRes := &tx.GetTxsEventResponse{ + TxResponses: []*types.TxResponse{ + { + Height: 123456789, + Logs: types.ABCIMessageLogs{ + types.ABCIMessageLog{ + Events: types.StringEvents{ + types.StringEvent{Type: "execute_contract", Attributes: []types.Attribute{ + {Key: "sender", Value: "terra1t0mw79g7rk6aueeqyqwz4a77vrgmqwpxs47cyy"}, + {Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + }}, + types.StringEvent{Type: "from_contract", Attributes: []types.Attribute{ + {Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + {Key: "method", Value: "propose_config"}, + }}, + types.StringEvent{Type: "message", Attributes: []types.Attribute{ + {Key: "action", Value: "/terra.wasm.v1beta1.MsgExecuteContract"}, + {Key: "module", Value: "wasm"}, + {Key: "sender", Value: "terra1t0mw79g7rk6aueeqyqwz4a77vrgmqwpxs47cyy"}, + }}, + types.StringEvent{Type: "wasm", Attributes: []types.Attribute{ + {Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + {Key: "method", Value: "propose_config"}, + }}, + types.StringEvent{Type: "wasm-set_config", Attributes: []types.Attribute{ + {Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + {Key: "previous_config_block_number", Value: "0"}, + {Key: "latest_config_digest", Value: "0002c245d0b4575d38cb5bb8b7b34d4b58a8b8cf9c6d4d925c15542c4ce062ac"}, + {Key: "config_count", Value: "1"}, + {Key: "signers", Value: "0d04b01e7b81d79c5ff7c0ba8fb3f09bd12273c1c478a84d2f1547b2f754b5da"}, + {Key: "signers", Value: "74f8fec126138a69bdbe6e05b9255a91f1269447ece88c57a07e5de83cb9ab9d"}, + {Key: "signers", Value: "fdc93b531eff20d8dd7115f872f00d398ff568f24aaee732d2ce18fb62e27509"}, + {Key: "signers", Value: "d77c5d17e2b43d166a343b312d7515957b2ba20395962e853781f99745e6f822"}, + {Key: "signers", Value: "8697b65476d687349573e16bceb1a6ee748c3fe4981656dd3673808ff9af1ec6"}, + {Key: "signers", Value: "a9d56584774baca0f3f451bcce230bc1c816cc41780e39c72d308fab79f23680"}, + {Key: "signers", Value: "b15355db6d3ddba6a02f3c30da00d0516bb462b3360f80ac596308cea262f28c"}, + {Key: "signers", Value: "fbe37462a4f3a8ed7e47eb296f097c8a760160a05f28f82b0baab96b144605d4"}, + {Key: "signers", Value: "f117ac6e19a17b558b2bfc732780049184fa5924857dfff6aac07e0bcefd2b46"}, + {Key: "signers", Value: "4c86e398b4708d30e597a90296c6ec9d306d17ea8f7f9d0e1267cfa2ea8dde89"}, + {Key: "signers", Value: "7e1d60331ef87c908b64a89b75a878264fb3a2099a1a5225f214931e01ebe0a5"}, + {Key: "signers", Value: "45a8e0c13d15972fc97d50cbb2e39bd0b4c75b8b4cd2dc5ba355b2cc702d494b"}, + {Key: "signers", Value: "f9ad962c062fa5d47af67b229a368b5ef7f6a46f889117b676b9d8bf42632b96"}, + {Key: "signers", Value: "e990e3738686976c46e35b11b5552a7a453ba1c62f14b84efb36e28a943d0cd0"}, + {Key: "signers", Value: "1fc37e4833745ee9a9f2bb39c9810cd43b75431dc2fe60814a2f23d00f64e7ac"}, + {Key: "signers", Value: "efdee2a3454a0a94449ed70a4e4a2d4fd113de4d56efe198d18adfa89a960dd1"}, + {Key: "transmitters", Value: "terra1pagnmhas5q0twmkqn9kcvyzezfzjqnsukz47ha"}, + {Key: "transmitters", Value: "terra17tdm630tz3u5a47pxpysd4ktltycjlckew0g3a"}, + {Key: "transmitters", Value: "terra14szqs99f0jap3f4lwqvavnct23gq2jrj9qg6nf"}, + {Key: "transmitters", Value: "terra1x2hgypr08vf466sej4zqc5wzwdfh7mw7j8lwqc"}, + {Key: "transmitters", Value: "terra1q2l2ep768vrrjfmxacpa6kl8e8aezeru9646pw"}, + {Key: "transmitters", Value: "terra1rmt28lv8cs50wtxy7qpuvwlmphdjsm2lyvtqhh"}, + {Key: "transmitters", Value: "terra167h3sh8c4pgs8grxz24pam2x764flydv3h9pd8"}, + {Key: "transmitters", Value: "terra14va5jfwuuqs9pls379c5dc2d7lyvv2yul9nrnq"}, + {Key: "transmitters", Value: "terra1qv4w60avcm653a544apsq8r6z2dqzd89ndz3ta"}, + {Key: "transmitters", Value: "terra13cxfg5awyscnj7c7u4ycwgplf528krf8c90cp6"}, + {Key: "transmitters", Value: "terra1tfx3q08q780u9uu4qlw0drn375uktfka7kgh93"}, + {Key: "transmitters", Value: "terra16mk5h5ma7ksr2vqpxcsaqxjny2ea68jap8mf6a"}, + {Key: "transmitters", Value: "terra1h0ygh9tr8t8sc97ntq5tnams9vprchxmgl9ame"}, + {Key: "transmitters", Value: "terra17rzwyj72rxzm4g2g7zy72tqn67cl3nncvd5sj4"}, + {Key: "transmitters", Value: "terra14wxk4hy63wa5punxehvc3wg2wr64hhcdnz3qm0"}, + {Key: "transmitters", Value: "terra16vueyxmul8kczd0nxvw0ge7kzfzpmtsgqc9tup"}, + {Key: "f", Value: "5"}, + {Key: "onchain_config", Value: "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAJiWgAAAAAAAAAAAAAAAAAAAAAAAAADo1KUQAA=="}, + {Key: "offchain_config_version", Value: "2"}, + {Key: "offchain_config", Value: "CIDIr6AlEIDYjuFvGICg2eYdIICU69wDKICY3JM0MAw6EAEBAQEBAQEBAQEBAQEBAQFCIN2HsmWEim5Q9pQqEkK9FKHK1/KJU9UMOpe2deyd1KSqQiBfdWovAxVFvfWjRDGFjPislKEUVacMYU1YaCX0d0LX2kIg3OZ8g+VT48FGVjBcek1zTLLCU9JUBbQ/cAbCB6CBUUFCIER7fwSH30c8pLxu1A2C/zkMd+7DtF4IitYFJodgGJBHQiBN/bQKs4HmHnujCuntsMJ9HqzcBm6yJLCCnxCFFotdpEIg5QSTg5DfEVykUPZB5kSrMloSjIIoLK9iZnbErhbo85ZCIDeiSSnLmx2gOOaGVp6QoKx7DaANLeUX94hX+6mwEdT9QiDFtnugZWIQC2MFgqU4Gb9wiAcUta5V0a039lUkEVUCDEIg5ZFI1boYHPq9ZEb3dQHoKkfi7R5lob051FP/PLVBmclCIEurfQmn3okRCjYUyuWSjtXyTQ/lQNkTtV3cxTU7pa6GQiBuLow0XCK9EZ6c89RYMHcQQHQ4EleH1hOVv4/L+aRWAUIgCRP6rMAc5frg0Xr0qM4xEgmb/U0ktHe5XlqJQu7CwahCIHHjznyYQp7h/vliR1p0mZ/5vWDhsI1iIGgN/zQr2pvCQiDTBtKqpCh31SlyYXPT29hFh7LjbT0SZNZwW/O3ryRcn0IgUaqatA7eAwQ8o0oep9RQnn1SP70CV+JjC1r1AW6JE2dCIC9A8HTvjG87C80k/2ofBJ/90PnxERpyPJmpzggV5x4YSjQxMkQzS29vV1BuSERkQXRmaWU5eXU3ZTl4dVRhaGlSZjVTNnNqQUZjNlIzUXhkWmdNcjZkSjQxMkQzS29vV0NHRkxyWkJmODN4dDhVcE5UcUJoTnN3OHNRUUJyUnJONFY1cHoyZDhqbUFwSjQxMkQzS29vV0NMa1VnblBnRDRYN28ya283dXFoeDRyTnlvN0xIQjNZckZnV3A0VjVDanN5SjQxMkQzS29vV0pQQVRRQloyb1Zqd2VVaHlUSjNNM3FQOWpoZlFpMldvSFVuYTFocFdrZ3ZESjQxMkQzS29vV005cTlITEY0UTVOTTNFR01QVHhaVnYzUG9GaHpCN3RHRGZ6Y202TkdadWJUSjQxMkQzS29vV0dlbXhFOWpkTUF1cU1LeGRwVDIzVnVlRmVxS2dKOXRUUDFVZDhUREM2WENYSjQxMkQzS29vV1JnWWJNanRrOXJza2Qybnl4V24yY2JzbVQxUnJnSHo3SmNhV2lXdmNzYXBuSjQxMkQzS29vV0I2Y21ZRzIybW5ZdU5nRGVVQlNVekVRb2hIM1V1SGsyUHBvUlBKem1LcHVjSjQxMkQzS29vV01YejloS2NzZDNoUVZZdTltQkxFVFJVM2t2VXBFQXFKVWRyWjlzeGUxZGR4SjQxMkQzS29vV01VTkVqWGRTRUpNVlFoRG5kcTZxdGV5RTlUZTl6MkU4U3pzRUJybVZ5UE15SjQxMkQzS29vV0FvcUxiWmJwR0tZZDViNnlCQXVITkd6Vkw0Q1Q4VVJVRGgybXJ0OG4zakZ4SjQxMkQzS29vV1JIUkxBc2tTdE04RkZZUXM1RDVlRmJDN3VLb3A5REhrRHpTYVpCV0tFQjFUSjQxMkQzS29vV0taeURGYTJ1Y3BpMm1Kbkw3eXJpVkdSbUQ4Qm84OXZucmFLZ1p3MVZZanN4SjQxMkQzS29vV0w5U056QlBvTTRxbVFTSGk3eDIzdFhIc01iWlNMREN0NVZUd0pCeVk0U2hNSjQxMkQzS29vV0oyeVRaa2FEWkVRdHRzcEg2cDZMOVVoU0VOYldCTVhLRk1qYXZ3S0E3a0xBSjQxMkQzS29vV01rakhxMVVTQldhVE5SakNEWXRwdzhmSnVVOTl4RVcxUmJtWU44b0h1RTl3UhEQwI23ASDAjbcBKIDo7aG6AWCA5JfQEmiAlOvcA3CAlOvcA3iAlOvcA4IB5AIKIEzs3hW7kV+myd3MI25hUbxOFPulYq0bU/bAIACSNUZzEiCdq7UFZ1ig7eeEQ9XXxM9nmPpJTJDMSBA+CEx2ks8JrRoQkJkfwc1ylB7Lc4KMygE2GxoQsE/EL0iCAGK0U4LSlBb10xoQ4961A06e5xrY3JE6x8N/GBoQXv8COMqrT8cBdiRUlMTdZBoQvLOll8+0Mz21PZOl2OL6+hoQDqgtG0m70LwSNmN07mziTRoQlsVtD1IFP+TeHfgKvt/3hBoQX3yQoypBfW/VPNUDeZLgORoQMJqPK7R1fcoCf8v+kBMKSRoQCb2V76p0YECoN5aa3fMfABoQ06Tu8bh4UAQZm3tN2BAU0BoQdV4s6fRxCBK5nJ+DQDewExoQ1Ds4K/+v7GT9HJyoqJHeqxoQvYN7OQecJ+Vl5ffYdRcKQhoQUyKAemD6j21VwDERbxIeOxoQeh9AoZZ0YfYrSR+ahpK7fQ=="}, + }}, + }, + }, + }, + }, + }, + } + newTransmissionRes := &tx.GetTxsEventResponse{ + TxResponses: []*types.TxResponse{ + { + Height: 987654321, + Logs: types.ABCIMessageLogs{ + types.ABCIMessageLog{ + Events: types.StringEvents{ + types.StringEvent{Type: "execute_contract", Attributes: []types.Attribute{ + types.Attribute{Key: "sender", Value: "terra16vueyxmul8kczd0nxvw0ge7kzfzpmtsgqc9tup"}, + types.Attribute{Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + }}, + types.StringEvent{Type: "from_contract", Attributes: []types.Attribute{ + types.Attribute{Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + types.Attribute{Key: "method", Value: "transmit"}, + types.Attribute{Key: "method", Value: "transmit"}, + }}, + types.StringEvent{Type: "message", Attributes: []types.Attribute{ + types.Attribute{Key: "action", Value: "/terra.wasm.v1beta1.MsgExecuteContract"}, + types.Attribute{Key: "module", Value: "wasm"}, + types.Attribute{Key: "sender", Value: "terra16vueyxmul8kczd0nxvw0ge7kzfzpmtsgqc9tup"}, + }}, + types.StringEvent{Type: "wasm", Attributes: []types.Attribute{ + types.Attribute{Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + types.Attribute{Key: "method", Value: "transmit"}, + types.Attribute{Key: "method", Value: "transmit"}, + }}, + types.StringEvent{Type: "wasm-new_transmission", Attributes: []types.Attribute{ + types.Attribute{Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + types.Attribute{Key: "aggregator_round_id", Value: "452"}, + types.Attribute{Key: "answer", Value: "2727000000"}, + types.Attribute{Key: "transmitter", Value: "terra16vueyxmul8kczd0nxvw0ge7kzfzpmtsgqc9tup"}, + types.Attribute{Key: "observations_timestamp", Value: "1645206820"}, + types.Attribute{Key: "observers", Value: "0c0a080403090d070b060e0000000000000000000000000000000000000000"}, + types.Attribute{Key: "juels_per_fee_coin", Value: "302815889"}, + types.Attribute{Key: "config_digest", Value: "0002c245d0b4575d38cb5bb8b7b34d4b58a8b8cf9c6d4d925c15542c4ce062ac"}, + types.Attribute{Key: "epoch", Value: "1732"}, + types.Attribute{Key: "round", Value: "6"}, + types.Attribute{Key: "reimbursement", Value: "893407"}, + types.Attribute{Key: "observations", Value: "2724000000"}, + types.Attribute{Key: "observations", Value: "2726325800"}, + types.Attribute{Key: "observations", Value: "2726700000"}, + types.Attribute{Key: "observations", Value: "2726973503"}, + types.Attribute{Key: "observations", Value: "2726973503"}, + types.Attribute{Key: "observations", Value: "2726986751"}, + types.Attribute{Key: "observations", Value: "2727000000"}, + types.Attribute{Key: "observations", Value: "2727000000"}, + types.Attribute{Key: "observations", Value: "2727000000"}, + types.Attribute{Key: "observations", Value: "2727000000"}, + types.Attribute{Key: "observations", Value: "2727162900"}, + types.Attribute{Key: "observations", Value: "2727300000"}}, + }, + types.StringEvent{Type: "wasm-transmitted", Attributes: []types.Attribute{ + types.Attribute{Key: "contract_address", Value: "terra13vgemxvpdshmhvwcz2nnr239948dxa2kz4glg8"}, + types.Attribute{Key: "config_digest", Value: "0002c245d0b4575d38cb5bb8b7b34d4b58a8b8cf9c6d4d925c15542c4ce062ac"}, + types.Attribute{Key: "epoch", Value: "1732"}, + }}, + }, + }, + }, + }, + }, + } + // Setup mocks. + feedConfig := generateFeedConfig() + chainConfig := generateChainConfig() + chainReader := new(mocks.ChainReader) + chainReader.On("TxsEvents", + mock.Anything, // context + mock.MatchedBy(func(query []string) bool { + return query[0] == fmt.Sprintf(`wasm-set_config.contract_address='%s'`, feedConfig.ContractAddressBech32) + }), + &cosmosQuery.PageRequest{Limit: 1}, + ).Return(setConfigRes, nil).Once() + chainReader.On("TxsEvents", + mock.Anything, // context + mock.MatchedBy(func(query []string) bool { + return query[0] == fmt.Sprintf(`wasm-new_transmission.contract_address='%s'`, feedConfig.ContractAddressBech32) + }), + &cosmosQuery.PageRequest{Limit: 1}, + ).Return(newTransmissionRes, nil).Once() + chainReader.On("ContractStore", + mock.Anything, // context + chainConfig.LinkTokenAddress, + []byte(fmt.Sprintf(`{"balance":{"address":"%s"}}`, feedConfig.ContractAddressBech32)), + ).Return(balanceRes, nil).Once() + // Execute Fetch() + factory := NewEnvelopeSourceFactory(chainReader, newNullLogger()) + source, err := factory.NewSource(chainConfig, feedConfig) + require.NoError(t, err) + rawEnvelope, err := source.Fetch(ctx) + require.NoError(t, err) + // Assertions on returned envelope. + envelope, ok := rawEnvelope.(relayMonitoring.Envelope) + require.True(t, ok) + require.Equal(t, envelope.ConfigDigest, ocr2types.ConfigDigest{0x0, 0x2, 0xc2, 0x45, 0xd0, 0xb4, 0x57, 0x5d, 0x38, 0xcb, 0x5b, 0xb8, 0xb7, 0xb3, 0x4d, 0x4b, 0x58, 0xa8, 0xb8, 0xcf, 0x9c, 0x6d, 0x4d, 0x92, 0x5c, 0x15, 0x54, 0x2c, 0x4c, 0xe0, 0x62, 0xac}) + require.Equal(t, envelope.Epoch, uint32(1732)) + require.Equal(t, envelope.Round, uint8(6)) + require.Equal(t, envelope.LatestAnswer, big.NewInt(2727000000)) + require.Equal(t, envelope.LatestTimestamp, time.Unix(1645206820, 0)) + + require.Equal(t, envelope.ContractConfig.ConfigDigest, ocr2types.ConfigDigest{0x0, 0x2, 0xc2, 0x45, 0xd0, 0xb4, 0x57, 0x5d, 0x38, 0xcb, 0x5b, 0xb8, 0xb7, 0xb3, 0x4d, 0x4b, 0x58, 0xa8, 0xb8, 0xcf, 0x9c, 0x6d, 0x4d, 0x92, 0x5c, 0x15, 0x54, 0x2c, 0x4c, 0xe0, 0x62, 0xac}) + require.Equal(t, envelope.ContractConfig.ConfigCount, uint64(1)) + require.Equal(t, envelope.ContractConfig.Signers, []ocr2types.OnchainPublicKey{ + mustHexaToByteArr("0d04b01e7b81d79c5ff7c0ba8fb3f09bd12273c1c478a84d2f1547b2f754b5da"), + mustHexaToByteArr("74f8fec126138a69bdbe6e05b9255a91f1269447ece88c57a07e5de83cb9ab9d"), + mustHexaToByteArr("fdc93b531eff20d8dd7115f872f00d398ff568f24aaee732d2ce18fb62e27509"), + mustHexaToByteArr("d77c5d17e2b43d166a343b312d7515957b2ba20395962e853781f99745e6f822"), + mustHexaToByteArr("8697b65476d687349573e16bceb1a6ee748c3fe4981656dd3673808ff9af1ec6"), + mustHexaToByteArr("a9d56584774baca0f3f451bcce230bc1c816cc41780e39c72d308fab79f23680"), + mustHexaToByteArr("b15355db6d3ddba6a02f3c30da00d0516bb462b3360f80ac596308cea262f28c"), + mustHexaToByteArr("fbe37462a4f3a8ed7e47eb296f097c8a760160a05f28f82b0baab96b144605d4"), + mustHexaToByteArr("f117ac6e19a17b558b2bfc732780049184fa5924857dfff6aac07e0bcefd2b46"), + mustHexaToByteArr("4c86e398b4708d30e597a90296c6ec9d306d17ea8f7f9d0e1267cfa2ea8dde89"), + mustHexaToByteArr("7e1d60331ef87c908b64a89b75a878264fb3a2099a1a5225f214931e01ebe0a5"), + mustHexaToByteArr("45a8e0c13d15972fc97d50cbb2e39bd0b4c75b8b4cd2dc5ba355b2cc702d494b"), + mustHexaToByteArr("f9ad962c062fa5d47af67b229a368b5ef7f6a46f889117b676b9d8bf42632b96"), + mustHexaToByteArr("e990e3738686976c46e35b11b5552a7a453ba1c62f14b84efb36e28a943d0cd0"), + mustHexaToByteArr("1fc37e4833745ee9a9f2bb39c9810cd43b75431dc2fe60814a2f23d00f64e7ac"), + mustHexaToByteArr("efdee2a3454a0a94449ed70a4e4a2d4fd113de4d56efe198d18adfa89a960dd1"), + }) + require.Equal(t, envelope.ContractConfig.Transmitters, []ocr2types.Account{ + "terra1pagnmhas5q0twmkqn9kcvyzezfzjqnsukz47ha", + "terra17tdm630tz3u5a47pxpysd4ktltycjlckew0g3a", + "terra14szqs99f0jap3f4lwqvavnct23gq2jrj9qg6nf", + "terra1x2hgypr08vf466sej4zqc5wzwdfh7mw7j8lwqc", + "terra1q2l2ep768vrrjfmxacpa6kl8e8aezeru9646pw", + "terra1rmt28lv8cs50wtxy7qpuvwlmphdjsm2lyvtqhh", + "terra167h3sh8c4pgs8grxz24pam2x764flydv3h9pd8", + "terra14va5jfwuuqs9pls379c5dc2d7lyvv2yul9nrnq", + "terra1qv4w60avcm653a544apsq8r6z2dqzd89ndz3ta", + "terra13cxfg5awyscnj7c7u4ycwgplf528krf8c90cp6", + "terra1tfx3q08q780u9uu4qlw0drn375uktfka7kgh93", + "terra16mk5h5ma7ksr2vqpxcsaqxjny2ea68jap8mf6a", + "terra1h0ygh9tr8t8sc97ntq5tnams9vprchxmgl9ame", + "terra17rzwyj72rxzm4g2g7zy72tqn67cl3nncvd5sj4", + "terra14wxk4hy63wa5punxehvc3wg2wr64hhcdnz3qm0", + "terra16vueyxmul8kczd0nxvw0ge7kzfzpmtsgqc9tup", + }) + require.Equal(t, envelope.ContractConfig.F, uint8(5)) + require.Equal(t, envelope.ContractConfig.OnchainConfig, []byte{0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x98, 0x96, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe8, 0xd4, 0xa5, 0x10, 0x0}) + require.Equal(t, envelope.ContractConfig.OffchainConfigVersion, uint64(2)) + require.Equal(t, envelope.ContractConfig.OffchainConfig, []byte{0x8, 0x80, 0xc8, 0xaf, 0xa0, 0x25, 0x10, 0x80, 0xd8, 0x8e, 0xe1, 0x6f, 0x18, 0x80, 0xa0, 0xd9, 0xe6, 0x1d, 0x20, 0x80, 0x94, 0xeb, 0xdc, 0x3, 0x28, 0x80, 0x98, 0xdc, 0x93, 0x34, 0x30, 0xc, 0x3a, 0x10, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x42, 0x20, 0xdd, 0x87, 0xb2, 0x65, 0x84, 0x8a, 0x6e, 0x50, 0xf6, 0x94, 0x2a, 0x12, 0x42, 0xbd, 0x14, 0xa1, 0xca, 0xd7, 0xf2, 0x89, 0x53, 0xd5, 0xc, 0x3a, 0x97, 0xb6, 0x75, 0xec, 0x9d, 0xd4, 0xa4, 0xaa, 0x42, 0x20, 0x5f, 0x75, 0x6a, 0x2f, 0x3, 0x15, 0x45, 0xbd, 0xf5, 0xa3, 0x44, 0x31, 0x85, 0x8c, 0xf8, 0xac, 0x94, 0xa1, 0x14, 0x55, 0xa7, 0xc, 0x61, 0x4d, 0x58, 0x68, 0x25, 0xf4, 0x77, 0x42, 0xd7, 0xda, 0x42, 0x20, 0xdc, 0xe6, 0x7c, 0x83, 0xe5, 0x53, 0xe3, 0xc1, 0x46, 0x56, 0x30, 0x5c, 0x7a, 0x4d, 0x73, 0x4c, 0xb2, 0xc2, 0x53, 0xd2, 0x54, 0x5, 0xb4, 0x3f, 0x70, 0x6, 0xc2, 0x7, 0xa0, 0x81, 0x51, 0x41, 0x42, 0x20, 0x44, 0x7b, 0x7f, 0x4, 0x87, 0xdf, 0x47, 0x3c, 0xa4, 0xbc, 0x6e, 0xd4, 0xd, 0x82, 0xff, 0x39, 0xc, 0x77, 0xee, 0xc3, 0xb4, 0x5e, 0x8, 0x8a, 0xd6, 0x5, 0x26, 0x87, 0x60, 0x18, 0x90, 0x47, 0x42, 0x20, 0x4d, 0xfd, 0xb4, 0xa, 0xb3, 0x81, 0xe6, 0x1e, 0x7b, 0xa3, 0xa, 0xe9, 0xed, 0xb0, 0xc2, 0x7d, 0x1e, 0xac, 0xdc, 0x6, 0x6e, 0xb2, 0x24, 0xb0, 0x82, 0x9f, 0x10, 0x85, 0x16, 0x8b, 0x5d, 0xa4, 0x42, 0x20, 0xe5, 0x4, 0x93, 0x83, 0x90, 0xdf, 0x11, 0x5c, 0xa4, 0x50, 0xf6, 0x41, 0xe6, 0x44, 0xab, 0x32, 0x5a, 0x12, 0x8c, 0x82, 0x28, 0x2c, 0xaf, 0x62, 0x66, 0x76, 0xc4, 0xae, 0x16, 0xe8, 0xf3, 0x96, 0x42, 0x20, 0x37, 0xa2, 0x49, 0x29, 0xcb, 0x9b, 0x1d, 0xa0, 0x38, 0xe6, 0x86, 0x56, 0x9e, 0x90, 0xa0, 0xac, 0x7b, 0xd, 0xa0, 0xd, 0x2d, 0xe5, 0x17, 0xf7, 0x88, 0x57, 0xfb, 0xa9, 0xb0, 0x11, 0xd4, 0xfd, 0x42, 0x20, 0xc5, 0xb6, 0x7b, 0xa0, 0x65, 0x62, 0x10, 0xb, 0x63, 0x5, 0x82, 0xa5, 0x38, 0x19, 0xbf, 0x70, 0x88, 0x7, 0x14, 0xb5, 0xae, 0x55, 0xd1, 0xad, 0x37, 0xf6, 0x55, 0x24, 0x11, 0x55, 0x2, 0xc, 0x42, 0x20, 0xe5, 0x91, 0x48, 0xd5, 0xba, 0x18, 0x1c, 0xfa, 0xbd, 0x64, 0x46, 0xf7, 0x75, 0x1, 0xe8, 0x2a, 0x47, 0xe2, 0xed, 0x1e, 0x65, 0xa1, 0xbd, 0x39, 0xd4, 0x53, 0xff, 0x3c, 0xb5, 0x41, 0x99, 0xc9, 0x42, 0x20, 0x4b, 0xab, 0x7d, 0x9, 0xa7, 0xde, 0x89, 0x11, 0xa, 0x36, 0x14, 0xca, 0xe5, 0x92, 0x8e, 0xd5, 0xf2, 0x4d, 0xf, 0xe5, 0x40, 0xd9, 0x13, 0xb5, 0x5d, 0xdc, 0xc5, 0x35, 0x3b, 0xa5, 0xae, 0x86, 0x42, 0x20, 0x6e, 0x2e, 0x8c, 0x34, 0x5c, 0x22, 0xbd, 0x11, 0x9e, 0x9c, 0xf3, 0xd4, 0x58, 0x30, 0x77, 0x10, 0x40, 0x74, 0x38, 0x12, 0x57, 0x87, 0xd6, 0x13, 0x95, 0xbf, 0x8f, 0xcb, 0xf9, 0xa4, 0x56, 0x1, 0x42, 0x20, 0x9, 0x13, 0xfa, 0xac, 0xc0, 0x1c, 0xe5, 0xfa, 0xe0, 0xd1, 0x7a, 0xf4, 0xa8, 0xce, 0x31, 0x12, 0x9, 0x9b, 0xfd, 0x4d, 0x24, 0xb4, 0x77, 0xb9, 0x5e, 0x5a, 0x89, 0x42, 0xee, 0xc2, 0xc1, 0xa8, 0x42, 0x20, 0x71, 0xe3, 0xce, 0x7c, 0x98, 0x42, 0x9e, 0xe1, 0xfe, 0xf9, 0x62, 0x47, 0x5a, 0x74, 0x99, 0x9f, 0xf9, 0xbd, 0x60, 0xe1, 0xb0, 0x8d, 0x62, 0x20, 0x68, 0xd, 0xff, 0x34, 0x2b, 0xda, 0x9b, 0xc2, 0x42, 0x20, 0xd3, 0x6, 0xd2, 0xaa, 0xa4, 0x28, 0x77, 0xd5, 0x29, 0x72, 0x61, 0x73, 0xd3, 0xdb, 0xd8, 0x45, 0x87, 0xb2, 0xe3, 0x6d, 0x3d, 0x12, 0x64, 0xd6, 0x70, 0x5b, 0xf3, 0xb7, 0xaf, 0x24, 0x5c, 0x9f, 0x42, 0x20, 0x51, 0xaa, 0x9a, 0xb4, 0xe, 0xde, 0x3, 0x4, 0x3c, 0xa3, 0x4a, 0x1e, 0xa7, 0xd4, 0x50, 0x9e, 0x7d, 0x52, 0x3f, 0xbd, 0x2, 0x57, 0xe2, 0x63, 0xb, 0x5a, 0xf5, 0x1, 0x6e, 0x89, 0x13, 0x67, 0x42, 0x20, 0x2f, 0x40, 0xf0, 0x74, 0xef, 0x8c, 0x6f, 0x3b, 0xb, 0xcd, 0x24, 0xff, 0x6a, 0x1f, 0x4, 0x9f, 0xfd, 0xd0, 0xf9, 0xf1, 0x11, 0x1a, 0x72, 0x3c, 0x99, 0xa9, 0xce, 0x8, 0x15, 0xe7, 0x1e, 0x18, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x50, 0x6e, 0x48, 0x44, 0x64, 0x41, 0x74, 0x66, 0x69, 0x65, 0x39, 0x79, 0x75, 0x37, 0x65, 0x39, 0x78, 0x75, 0x54, 0x61, 0x68, 0x69, 0x52, 0x66, 0x35, 0x53, 0x36, 0x73, 0x6a, 0x41, 0x46, 0x63, 0x36, 0x52, 0x33, 0x51, 0x78, 0x64, 0x5a, 0x67, 0x4d, 0x72, 0x36, 0x64, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x43, 0x47, 0x46, 0x4c, 0x72, 0x5a, 0x42, 0x66, 0x38, 0x33, 0x78, 0x74, 0x38, 0x55, 0x70, 0x4e, 0x54, 0x71, 0x42, 0x68, 0x4e, 0x73, 0x77, 0x38, 0x73, 0x51, 0x51, 0x42, 0x72, 0x52, 0x72, 0x4e, 0x34, 0x56, 0x35, 0x70, 0x7a, 0x32, 0x64, 0x38, 0x6a, 0x6d, 0x41, 0x70, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x43, 0x4c, 0x6b, 0x55, 0x67, 0x6e, 0x50, 0x67, 0x44, 0x34, 0x58, 0x37, 0x6f, 0x32, 0x6b, 0x6f, 0x37, 0x75, 0x71, 0x68, 0x78, 0x34, 0x72, 0x4e, 0x79, 0x6f, 0x37, 0x4c, 0x48, 0x42, 0x33, 0x59, 0x72, 0x46, 0x67, 0x57, 0x70, 0x34, 0x56, 0x35, 0x43, 0x6a, 0x73, 0x79, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4a, 0x50, 0x41, 0x54, 0x51, 0x42, 0x5a, 0x32, 0x6f, 0x56, 0x6a, 0x77, 0x65, 0x55, 0x68, 0x79, 0x54, 0x4a, 0x33, 0x4d, 0x33, 0x71, 0x50, 0x39, 0x6a, 0x68, 0x66, 0x51, 0x69, 0x32, 0x57, 0x6f, 0x48, 0x55, 0x6e, 0x61, 0x31, 0x68, 0x70, 0x57, 0x6b, 0x67, 0x76, 0x44, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4d, 0x39, 0x71, 0x39, 0x48, 0x4c, 0x46, 0x34, 0x51, 0x35, 0x4e, 0x4d, 0x33, 0x45, 0x47, 0x4d, 0x50, 0x54, 0x78, 0x5a, 0x56, 0x76, 0x33, 0x50, 0x6f, 0x46, 0x68, 0x7a, 0x42, 0x37, 0x74, 0x47, 0x44, 0x66, 0x7a, 0x63, 0x6d, 0x36, 0x4e, 0x47, 0x5a, 0x75, 0x62, 0x54, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x47, 0x65, 0x6d, 0x78, 0x45, 0x39, 0x6a, 0x64, 0x4d, 0x41, 0x75, 0x71, 0x4d, 0x4b, 0x78, 0x64, 0x70, 0x54, 0x32, 0x33, 0x56, 0x75, 0x65, 0x46, 0x65, 0x71, 0x4b, 0x67, 0x4a, 0x39, 0x74, 0x54, 0x50, 0x31, 0x55, 0x64, 0x38, 0x54, 0x44, 0x43, 0x36, 0x58, 0x43, 0x58, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x52, 0x67, 0x59, 0x62, 0x4d, 0x6a, 0x74, 0x6b, 0x39, 0x72, 0x73, 0x6b, 0x64, 0x32, 0x6e, 0x79, 0x78, 0x57, 0x6e, 0x32, 0x63, 0x62, 0x73, 0x6d, 0x54, 0x31, 0x52, 0x72, 0x67, 0x48, 0x7a, 0x37, 0x4a, 0x63, 0x61, 0x57, 0x69, 0x57, 0x76, 0x63, 0x73, 0x61, 0x70, 0x6e, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x42, 0x36, 0x63, 0x6d, 0x59, 0x47, 0x32, 0x32, 0x6d, 0x6e, 0x59, 0x75, 0x4e, 0x67, 0x44, 0x65, 0x55, 0x42, 0x53, 0x55, 0x7a, 0x45, 0x51, 0x6f, 0x68, 0x48, 0x33, 0x55, 0x75, 0x48, 0x6b, 0x32, 0x50, 0x70, 0x6f, 0x52, 0x50, 0x4a, 0x7a, 0x6d, 0x4b, 0x70, 0x75, 0x63, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4d, 0x58, 0x7a, 0x39, 0x68, 0x4b, 0x63, 0x73, 0x64, 0x33, 0x68, 0x51, 0x56, 0x59, 0x75, 0x39, 0x6d, 0x42, 0x4c, 0x45, 0x54, 0x52, 0x55, 0x33, 0x6b, 0x76, 0x55, 0x70, 0x45, 0x41, 0x71, 0x4a, 0x55, 0x64, 0x72, 0x5a, 0x39, 0x73, 0x78, 0x65, 0x31, 0x64, 0x64, 0x78, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4d, 0x55, 0x4e, 0x45, 0x6a, 0x58, 0x64, 0x53, 0x45, 0x4a, 0x4d, 0x56, 0x51, 0x68, 0x44, 0x6e, 0x64, 0x71, 0x36, 0x71, 0x74, 0x65, 0x79, 0x45, 0x39, 0x54, 0x65, 0x39, 0x7a, 0x32, 0x45, 0x38, 0x53, 0x7a, 0x73, 0x45, 0x42, 0x72, 0x6d, 0x56, 0x79, 0x50, 0x4d, 0x79, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x41, 0x6f, 0x71, 0x4c, 0x62, 0x5a, 0x62, 0x70, 0x47, 0x4b, 0x59, 0x64, 0x35, 0x62, 0x36, 0x79, 0x42, 0x41, 0x75, 0x48, 0x4e, 0x47, 0x7a, 0x56, 0x4c, 0x34, 0x43, 0x54, 0x38, 0x55, 0x52, 0x55, 0x44, 0x68, 0x32, 0x6d, 0x72, 0x74, 0x38, 0x6e, 0x33, 0x6a, 0x46, 0x78, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x52, 0x48, 0x52, 0x4c, 0x41, 0x73, 0x6b, 0x53, 0x74, 0x4d, 0x38, 0x46, 0x46, 0x59, 0x51, 0x73, 0x35, 0x44, 0x35, 0x65, 0x46, 0x62, 0x43, 0x37, 0x75, 0x4b, 0x6f, 0x70, 0x39, 0x44, 0x48, 0x6b, 0x44, 0x7a, 0x53, 0x61, 0x5a, 0x42, 0x57, 0x4b, 0x45, 0x42, 0x31, 0x54, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4b, 0x5a, 0x79, 0x44, 0x46, 0x61, 0x32, 0x75, 0x63, 0x70, 0x69, 0x32, 0x6d, 0x4a, 0x6e, 0x4c, 0x37, 0x79, 0x72, 0x69, 0x56, 0x47, 0x52, 0x6d, 0x44, 0x38, 0x42, 0x6f, 0x38, 0x39, 0x76, 0x6e, 0x72, 0x61, 0x4b, 0x67, 0x5a, 0x77, 0x31, 0x56, 0x59, 0x6a, 0x73, 0x78, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4c, 0x39, 0x53, 0x4e, 0x7a, 0x42, 0x50, 0x6f, 0x4d, 0x34, 0x71, 0x6d, 0x51, 0x53, 0x48, 0x69, 0x37, 0x78, 0x32, 0x33, 0x74, 0x58, 0x48, 0x73, 0x4d, 0x62, 0x5a, 0x53, 0x4c, 0x44, 0x43, 0x74, 0x35, 0x56, 0x54, 0x77, 0x4a, 0x42, 0x79, 0x59, 0x34, 0x53, 0x68, 0x4d, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4a, 0x32, 0x79, 0x54, 0x5a, 0x6b, 0x61, 0x44, 0x5a, 0x45, 0x51, 0x74, 0x74, 0x73, 0x70, 0x48, 0x36, 0x70, 0x36, 0x4c, 0x39, 0x55, 0x68, 0x53, 0x45, 0x4e, 0x62, 0x57, 0x42, 0x4d, 0x58, 0x4b, 0x46, 0x4d, 0x6a, 0x61, 0x76, 0x77, 0x4b, 0x41, 0x37, 0x6b, 0x4c, 0x41, 0x4a, 0x34, 0x31, 0x32, 0x44, 0x33, 0x4b, 0x6f, 0x6f, 0x57, 0x4d, 0x6b, 0x6a, 0x48, 0x71, 0x31, 0x55, 0x53, 0x42, 0x57, 0x61, 0x54, 0x4e, 0x52, 0x6a, 0x43, 0x44, 0x59, 0x74, 0x70, 0x77, 0x38, 0x66, 0x4a, 0x75, 0x55, 0x39, 0x39, 0x78, 0x45, 0x57, 0x31, 0x52, 0x62, 0x6d, 0x59, 0x4e, 0x38, 0x6f, 0x48, 0x75, 0x45, 0x39, 0x77, 0x52, 0x11, 0x10, 0xc0, 0x8d, 0xb7, 0x1, 0x20, 0xc0, 0x8d, 0xb7, 0x1, 0x28, 0x80, 0xe8, 0xed, 0xa1, 0xba, 0x1, 0x60, 0x80, 0xe4, 0x97, 0xd0, 0x12, 0x68, 0x80, 0x94, 0xeb, 0xdc, 0x3, 0x70, 0x80, 0x94, 0xeb, 0xdc, 0x3, 0x78, 0x80, 0x94, 0xeb, 0xdc, 0x3, 0x82, 0x1, 0xe4, 0x2, 0xa, 0x20, 0x4c, 0xec, 0xde, 0x15, 0xbb, 0x91, 0x5f, 0xa6, 0xc9, 0xdd, 0xcc, 0x23, 0x6e, 0x61, 0x51, 0xbc, 0x4e, 0x14, 0xfb, 0xa5, 0x62, 0xad, 0x1b, 0x53, 0xf6, 0xc0, 0x20, 0x0, 0x92, 0x35, 0x46, 0x73, 0x12, 0x20, 0x9d, 0xab, 0xb5, 0x5, 0x67, 0x58, 0xa0, 0xed, 0xe7, 0x84, 0x43, 0xd5, 0xd7, 0xc4, 0xcf, 0x67, 0x98, 0xfa, 0x49, 0x4c, 0x90, 0xcc, 0x48, 0x10, 0x3e, 0x8, 0x4c, 0x76, 0x92, 0xcf, 0x9, 0xad, 0x1a, 0x10, 0x90, 0x99, 0x1f, 0xc1, 0xcd, 0x72, 0x94, 0x1e, 0xcb, 0x73, 0x82, 0x8c, 0xca, 0x1, 0x36, 0x1b, 0x1a, 0x10, 0xb0, 0x4f, 0xc4, 0x2f, 0x48, 0x82, 0x0, 0x62, 0xb4, 0x53, 0x82, 0xd2, 0x94, 0x16, 0xf5, 0xd3, 0x1a, 0x10, 0xe3, 0xde, 0xb5, 0x3, 0x4e, 0x9e, 0xe7, 0x1a, 0xd8, 0xdc, 0x91, 0x3a, 0xc7, 0xc3, 0x7f, 0x18, 0x1a, 0x10, 0x5e, 0xff, 0x2, 0x38, 0xca, 0xab, 0x4f, 0xc7, 0x1, 0x76, 0x24, 0x54, 0x94, 0xc4, 0xdd, 0x64, 0x1a, 0x10, 0xbc, 0xb3, 0xa5, 0x97, 0xcf, 0xb4, 0x33, 0x3d, 0xb5, 0x3d, 0x93, 0xa5, 0xd8, 0xe2, 0xfa, 0xfa, 0x1a, 0x10, 0xe, 0xa8, 0x2d, 0x1b, 0x49, 0xbb, 0xd0, 0xbc, 0x12, 0x36, 0x63, 0x74, 0xee, 0x6c, 0xe2, 0x4d, 0x1a, 0x10, 0x96, 0xc5, 0x6d, 0xf, 0x52, 0x5, 0x3f, 0xe4, 0xde, 0x1d, 0xf8, 0xa, 0xbe, 0xdf, 0xf7, 0x84, 0x1a, 0x10, 0x5f, 0x7c, 0x90, 0xa3, 0x2a, 0x41, 0x7d, 0x6f, 0xd5, 0x3c, 0xd5, 0x3, 0x79, 0x92, 0xe0, 0x39, 0x1a, 0x10, 0x30, 0x9a, 0x8f, 0x2b, 0xb4, 0x75, 0x7d, 0xca, 0x2, 0x7f, 0xcb, 0xfe, 0x90, 0x13, 0xa, 0x49, 0x1a, 0x10, 0x9, 0xbd, 0x95, 0xef, 0xaa, 0x74, 0x60, 0x40, 0xa8, 0x37, 0x96, 0x9a, 0xdd, 0xf3, 0x1f, 0x0, 0x1a, 0x10, 0xd3, 0xa4, 0xee, 0xf1, 0xb8, 0x78, 0x50, 0x4, 0x19, 0x9b, 0x7b, 0x4d, 0xd8, 0x10, 0x14, 0xd0, 0x1a, 0x10, 0x75, 0x5e, 0x2c, 0xe9, 0xf4, 0x71, 0x8, 0x12, 0xb9, 0x9c, 0x9f, 0x83, 0x40, 0x37, 0xb0, 0x13, 0x1a, 0x10, 0xd4, 0x3b, 0x38, 0x2b, 0xff, 0xaf, 0xec, 0x64, 0xfd, 0x1c, 0x9c, 0xa8, 0xa8, 0x91, 0xde, 0xab, 0x1a, 0x10, 0xbd, 0x83, 0x7b, 0x39, 0x7, 0x9c, 0x27, 0xe5, 0x65, 0xe5, 0xf7, 0xd8, 0x75, 0x17, 0xa, 0x42, 0x1a, 0x10, 0x53, 0x22, 0x80, 0x7a, 0x60, 0xfa, 0x8f, 0x6d, 0x55, 0xc0, 0x31, 0x11, 0x6f, 0x12, 0x1e, 0x3b, 0x1a, 0x10, 0x7a, 0x1f, 0x40, 0xa1, 0x96, 0x74, 0x61, 0xf6, 0x2b, 0x49, 0x1f, 0x9a, 0x86, 0x92, 0xbb, 0x7d}) + + require.Equal(t, envelope.BlockNumber, uint64(987654321)) + require.Equal(t, envelope.Transmitter, ocr2types.Account("terra16vueyxmul8kczd0nxvw0ge7kzfzpmtsgqc9tup")) + require.Equal(t, envelope.LinkBalance, big.NewInt(1234567890987654321)) + require.Equal(t, envelope.JuelsPerFeeCoin, big.NewInt(302815889)) + require.Equal(t, envelope.AggregatorRoundID, uint32(452)) +} + +func mustHexaToByteArr(encoded string) []byte { + decoded, err := hex.DecodeString(encoded) + if err != nil { + panic(err) + } + return decoded +} diff --git a/pkg/monitoring/source_proxy.go b/pkg/monitoring/source_proxy.go new file mode 100644 index 000000000..c694c2690 --- /dev/null +++ b/pkg/monitoring/source_proxy.go @@ -0,0 +1,146 @@ +package monitoring + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sync" + + relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" + "go.uber.org/multierr" +) + +// ProxyData is a subset of the data returned by the Terra feed proxy contract's "latest_round_data" method. +type ProxyData struct { + Answer *big.Int + // This value is not taken from the proxy contract but from the backend contract. + // It's added here for convenience. + LinkAvailableForPayment *big.Int +} + +// NewProxySourceFactory does wisott. +func NewProxySourceFactory(client ChainReader, log relayMonitoring.Logger) relayMonitoring.SourceFactory { + return &proxySourceFactory{client, log} +} + +type proxySourceFactory struct { + client ChainReader + log relayMonitoring.Logger +} + +func (p *proxySourceFactory) NewSource( + chainConfig relayMonitoring.ChainConfig, + feedConfig relayMonitoring.FeedConfig, +) (relayMonitoring.Source, error) { + terraConfig, ok := chainConfig.(TerraConfig) + if !ok { + return nil, fmt.Errorf("expected chainConfig to be of type TerraConfig not %T", chainConfig) + } + terraFeedConfig, ok := feedConfig.(TerraFeedConfig) + if !ok { + return nil, fmt.Errorf("expected feedConfig to be of type TerraFeedConfig not %T", feedConfig) + } + return &proxySource{ + p.client, + p.log, + terraConfig, + terraFeedConfig, + }, nil +} + +func (p *proxySourceFactory) GetType() string { + return "proxy" +} + +type proxySource struct { + client ChainReader + log relayMonitoring.Logger + terraConfig TerraConfig + terraFeedConfig TerraFeedConfig +} + +func (p *proxySource) Fetch(ctx context.Context) (interface{}, error) { + if p.terraFeedConfig.ProxyAddressBech32 == "" { + p.log.Debugw("skipping fetch because no proxy contract is configured", "feed", p.terraFeedConfig.ContractAddressBech32) + return nil, relayMonitoring.ErrNoUpdate + } + proxyData := ProxyData{} + var proxyErr error + proxyDataMu := &sync.Mutex{} + wg := &sync.WaitGroup{} + wg.Add(2) + go func() { + defer wg.Done() + answer, err := p.fetchLatestRoundFromProxy(ctx) + proxyDataMu.Lock() + defer proxyDataMu.Unlock() + if err != nil { + proxyErr = multierr.Combine(proxyErr, err) + } else { + proxyData.Answer = answer + } + }() + go func() { + defer wg.Done() + amount, err := p.fetchLinkAvailableForPayment(ctx) + proxyDataMu.Lock() + defer proxyDataMu.Unlock() + if err != nil { + proxyErr = multierr.Combine(proxyErr, err) + } else { + proxyData.LinkAvailableForPayment = amount + } + }() + wg.Wait() + return proxyData, proxyErr +} + +// latestRoundDataRes corresponds to a subset of the Round type in the proxy contract. +type latestRoundDataRes struct { + Answer string `json:"answer,omitempty"` +} + +func (p *proxySource) fetchLatestRoundFromProxy(ctx context.Context) (*big.Int, error) { + res, err := p.client.ContractStore( + ctx, + p.terraFeedConfig.ProxyAddress, + []byte(`"latest_round_data"`), + ) + if err != nil { + return nil, fmt.Errorf("failed to read latest_round_data from the proxy contract: %w", err) + } + latestRoundData := latestRoundDataRes{} + if err := json.Unmarshal(res, &latestRoundData); err != nil { + return nil, fmt.Errorf("failed to unmarshal round data from the response '%s': %w", string(res), err) + } + answer, success := new(big.Int).SetString(latestRoundData.Answer, 10) + if !success { + return nil, fmt.Errorf("failed to parse proxy answer '%s' into a big.Int", latestRoundData.Answer) + } + return answer, nil +} + +type linkAvailableForPaymentRes struct { + Amount string `json:"amount,omitempty"` +} + +func (p *proxySource) fetchLinkAvailableForPayment(ctx context.Context) (*big.Int, error) { + res, err := p.client.ContractStore( + ctx, + p.terraFeedConfig.ContractAddress, + []byte(`"link_available_for_payment"`), + ) + if err != nil { + return nil, fmt.Errorf("failed to read link_available_for_payment from the proxy contract: %w", err) + } + linkAvailableForPayment := linkAvailableForPaymentRes{} + if err := json.Unmarshal(res, &linkAvailableForPayment); err != nil { + return nil, fmt.Errorf("failed to unmarshal link available data from the response '%s': %w", string(res), err) + } + amount, success := new(big.Int).SetString(linkAvailableForPayment.Amount, 10) + if !success { + return nil, fmt.Errorf("failed to parse amount of link available for payment from string '%s' into a big.Int", linkAvailableForPayment.Amount) + } + return amount, nil +} diff --git a/pkg/monitoring/source_txresults.go b/pkg/monitoring/source_txresults.go index 04c40851a..68c41a88a 100644 --- a/pkg/monitoring/source_txresults.go +++ b/pkg/monitoring/source_txresults.go @@ -4,21 +4,21 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "sync" relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" - "github.com/smartcontractkit/chainlink/core/logger" ) // NewTxResultsSourceFactory builds sources of TxResults objects expected by the relay monitoring. -func NewTxResultsSourceFactory(log logger.Logger) relayMonitoring.SourceFactory { +func NewTxResultsSourceFactory(log relayMonitoring.Logger) relayMonitoring.SourceFactory { return &txResultsSourceFactory{log, &http.Client{}} } type txResultsSourceFactory struct { - log logger.Logger + log relayMonitoring.Logger httpClient *http.Client } @@ -44,8 +44,12 @@ func (t *txResultsSourceFactory) NewSource( }, nil } +func (t *txResultsSourceFactory) GetType() string { + return "txresults" +} + type txResultsSource struct { - log logger.Logger + log relayMonitoring.Logger terraConfig TerraConfig terraFeedConfig TerraFeedConfig httpClient *http.Client @@ -68,12 +72,13 @@ func (t *txResultsSource) Fetch(ctx context.Context) (interface{}, error) { // Query the FCD endpoint. query := url.Values{} query.Set("account", t.terraFeedConfig.ContractAddressBech32) - query.Set("limit", "100") + query.Set("limit", "10") query.Set("offset", "0") getTxsURL, err := url.Parse(t.terraConfig.FCDURL) if err != nil { return nil, err } + getTxsURL.Path = "/v1/txs" getTxsURL.RawQuery = query.Encode() readTxsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, getTxsURL.String(), nil) if err != nil { @@ -86,11 +91,12 @@ func (t *txResultsSource) Fetch(ctx context.Context) (interface{}, error) { defer res.Body.Close() // Decode the response txsResponse := fcdTxsResponse{} - decoder := json.NewDecoder(res.Body) - if err := decoder.Decode(&txsResponse); err != nil { - return nil, fmt.Errorf("unable to decode transactions from response: %w", err) + resBody, _ := io.ReadAll(res.Body) + if err := json.Unmarshal(resBody, &txsResponse); err != nil { + return nil, fmt.Errorf("unable to decode transactions from response '%s': %w", resBody, err) } // Filter recent transactions + // TODO (dru) keep latest processed tx in the state. recentTxs := []fcdTx{} func() { t.latestTxIDMu.Lock() diff --git a/pkg/monitoring/source_txresults_test.go b/pkg/monitoring/source_txresults_test.go index 7c94049ab..23c422b0a 100644 --- a/pkg/monitoring/source_txresults_test.go +++ b/pkg/monitoring/source_txresults_test.go @@ -9,7 +9,6 @@ import ( "time" relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" - "github.com/smartcontractkit/chainlink/core/logger" "github.com/stretchr/testify/require" ) @@ -28,7 +27,7 @@ func TestTxResultsSource(t *testing.T) { chainConfig.FCDURL = srv.URL feedConfig := generateFeedConfig() - factory := NewTxResultsSourceFactory(logger.NullLogger) + factory := NewTxResultsSourceFactory(newNullLogger()) source, err := factory.NewSource(chainConfig, feedConfig) require.NoError(t, err) diff --git a/pkg/monitoring/testutils.go b/pkg/monitoring/testutils.go index aebc2deb9..e0ff8859d 100644 --- a/pkg/monitoring/testutils.go +++ b/pkg/monitoring/testutils.go @@ -1,11 +1,14 @@ package monitoring import ( + "context" + cryptoRand "crypto/rand" "fmt" "math/big" "math/rand" "time" + relayMonitoring "github.com/smartcontractkit/chainlink-relay/pkg/monitoring" "github.com/smartcontractkit/terra.go/msg" ) @@ -29,6 +32,7 @@ func generateFeedConfig() TerraFeedConfig { coins := []string{"btc", "eth", "matic", "link", "avax", "ftt", "srm", "usdc", "sol", "ray"} coin := coins[rand.Intn(len(coins))] address, _ := msg.AccAddressFromBech32("terra106x8mk9asfnptt5rqw5kx6hs8f75fseqa8rfz2") + proxyAddress, _ := msg.AccAddressFromBech32("terra106x8mk9asfnptt5rqw5kx6hs8f75fseqa8rfz2") return TerraFeedConfig{ Name: fmt.Sprintf("%s / usd", coin), Path: fmt.Sprintf("%s-usd", coin), @@ -38,12 +42,85 @@ func generateFeedConfig() TerraFeedConfig { ContractStatus: "status", Multiply: big.NewInt(1000), - ContractAddressBech32: "terra106x8mk9asfnptt5rqw5kx6hs8f75fseqa8rfz2", + ContractAddressBech32: address.String(), ContractAddress: address, + ProxyAddressBech32: proxyAddress.String(), + ProxyAddress: proxyAddress, } } +func generateBigInt(bitSize uint8) *big.Int { + maxBigInt := new(big.Int) + maxBigInt.Exp(big.NewInt(2), big.NewInt(int64(bitSize)), nil).Sub(maxBigInt, big.NewInt(1)) + + //Generate cryptographically strong pseudo-random between 0 - max + num, err := cryptoRand.Int(cryptoRand.Reader, maxBigInt) + if err != nil { + panic(fmt.Sprintf("failed to generate a really big number: %v", err)) + } + return num +} + +func generateProxyData() ProxyData { + return ProxyData{ + Answer: generateBigInt(128), + LinkAvailableForPayment: generateBigInt(160), + } +} + +// Sources + +// NewFakeProxySourceFactory makes a source that generates random proxy data. +func NewFakeProxySourceFactory(log relayMonitoring.Logger) relayMonitoring.SourceFactory { + return &fakeProxySourceFactory{log} +} + +type fakeProxySourceFactory struct { + log relayMonitoring.Logger +} + +func (f *fakeProxySourceFactory) NewSource( + _ relayMonitoring.ChainConfig, + _ relayMonitoring.FeedConfig, +) (relayMonitoring.Source, error) { + return &fakeProxySource{f.log}, nil +} + +func (f *fakeProxySourceFactory) GetType() string { + return "fake-proxy" +} + +type fakeProxySource struct { + log relayMonitoring.Logger +} + +func (f *fakeProxySource) Fetch(ctx context.Context) (interface{}, error) { + return generateProxyData(), nil +} + +// Logger + +type nullLogger struct{} + +func newNullLogger() relayMonitoring.Logger { + return &nullLogger{} +} + +func (n *nullLogger) With(args ...interface{}) relayMonitoring.Logger { + return n +} + +func (n *nullLogger) Tracew(format string, values ...interface{}) {} +func (n *nullLogger) Debugw(format string, values ...interface{}) {} +func (n *nullLogger) Infow(format string, values ...interface{}) {} +func (n *nullLogger) Warnw(format string, values ...interface{}) {} +func (n *nullLogger) Errorw(format string, values ...interface{}) {} +func (n *nullLogger) Criticalw(format string, values ...interface{}) {} +func (n *nullLogger) Panicw(format string, values ...interface{}) {} +func (n *nullLogger) Fatalw(format string, values ...interface{}) {} + var ( + _ = newNullLogger() _ = generateChainConfig() _ = generateFeedConfig() ) diff --git a/pkg/terra/chain.go b/pkg/terra/chain.go index 488bc27de..f23bbcaec 100644 --- a/pkg/terra/chain.go +++ b/pkg/terra/chain.go @@ -14,7 +14,7 @@ type Chain interface { ID() string Config() Config - MsgEnqueuer() MsgEnqueuer + TxManager() TxManager // Reader returns a new Reader. If nodeName is provided, the underlying client must use that node. Reader(nodeName string) (client.Reader, error) } diff --git a/pkg/terra/client/client.go b/pkg/terra/client/client.go index c5de397ed..4395548ec 100644 --- a/pkg/terra/client/client.go +++ b/pkg/terra/client/client.go @@ -325,7 +325,7 @@ type BatchSimResults struct { Succeeded SimMsgs } -var failedMsgIndexRe = regexp.MustCompile(`^.*failed to execute message; message index: (?P\d{1}):.*$`) +var failedMsgIndexRe = regexp.MustCompile(`^.*failed to execute message; message index: (?P\d+):.*$`) func (c *Client) failedMsgIndex(err error) (bool, int) { if err == nil { diff --git a/pkg/terra/client/client_test.go b/pkg/terra/client/client_test.go index e89382596..77ec48406 100644 --- a/pkg/terra/client/client_test.go +++ b/pkg/terra/client/client_test.go @@ -1,21 +1,21 @@ package client import ( - "time" - "fmt" "testing" + "time" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" txtypes "github.com/cosmos/cosmos-sdk/types/tx" - "github.com/smartcontractkit/chainlink-terra/pkg/terra/mocks" "github.com/smartcontractkit/terra.go/msg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/tendermint/tendermint/abci/types" wasmtypes "github.com/terra-money/core/x/wasm/types" + + "github.com/smartcontractkit/chainlink-terra/pkg/terra/mocks" ) func TestErrMatch(t *testing.T) { @@ -23,11 +23,20 @@ func TestErrMatch(t *testing.T) { m := failedMsgIndexRe.FindStringSubmatch(errStr) require.Equal(t, 2, len(m)) assert.Equal(t, m[1], "0") + + errStr = "rpc error: code = InvalidArgument desc = failed to execute message; message index: 10: Error parsing into type my_first_contract::msg::ExecuteMsg: unknown variant `blah`, expected `increment` or `reset`: execute wasm contract failed: invalid request" + m = failedMsgIndexRe.FindStringSubmatch(errStr) + require.Equal(t, 2, len(m)) + assert.Equal(t, m[1], "10") + + errStr = "rpc error: code = InvalidArgument desc = failed to execute message; message index: 10000: Error parsing into type my_first_contract::msg::ExecuteMsg: unknown variant `blah`, expected `increment` or `reset`: execute wasm contract failed: invalid request" + m = failedMsgIndexRe.FindStringSubmatch(errStr) + require.Equal(t, 2, len(m)) + assert.Equal(t, m[1], "10000") } func TestBatchSim(t *testing.T) { - accounts, testdir := SetupLocalTerraNode(t, "42") - tendermintURL := "http://127.0.0.1:26657" + accounts, testdir, tendermintURL := SetupLocalTerraNode(t, "42") lggr := new(mocks.Logger) lggr.Test(t) @@ -38,7 +47,7 @@ func TestBatchSim(t *testing.T) { lggr) require.NoError(t, err) - contract := DeployTestContract(t, accounts[0], accounts[0], tc, testdir, "../testdata/my_first_contract.wasm") + contract := DeployTestContract(t, tendermintURL, accounts[0], accounts[0], tc, testdir, "../testdata/my_first_contract.wasm") var succeed sdk.Msg = &wasmtypes.MsgExecuteContract{Sender: accounts[0].Address.String(), Contract: contract.String(), ExecuteMsg: []byte(`{"reset":{"count":5}}`)} var fail sdk.Msg = &wasmtypes.MsgExecuteContract{Sender: accounts[0].Address.String(), Contract: contract.String(), ExecuteMsg: []byte(`{"blah":{"count":5}}`)} @@ -111,8 +120,7 @@ func TestBatchSim(t *testing.T) { func TestTerraClient(t *testing.T) { // Local only for now, could maybe run on CI if we install terrad there? - accounts, testdir := SetupLocalTerraNode(t, "42") - tendermintURL := "http://127.0.0.1:26657" + accounts, testdir, tendermintURL := SetupLocalTerraNode(t, "42") lggr := new(mocks.Logger) lggr.Test(t) lggr.On("Infof", mock.Anything, mock.Anything, mock.Anything).Maybe() @@ -126,7 +134,7 @@ func TestTerraClient(t *testing.T) { gpe := NewFixedGasPriceEstimator(map[string]sdk.DecCoin{ "uluna": sdk.NewDecCoinFromDec("uluna", sdk.MustNewDecFromStr("0.01")), }) - contract := DeployTestContract(t, accounts[0], accounts[0], tc, testdir, "../testdata/my_first_contract.wasm") + contract := DeployTestContract(t, tendermintURL, accounts[0], accounts[0], tc, testdir, "../testdata/my_first_contract.wasm") t.Run("send tx between accounts", func(t *testing.T) { // Assert balance before diff --git a/pkg/terra/client/mocks/Logger.go b/pkg/terra/client/mocks/Logger.go index 95a0600db..c880e73c7 100644 --- a/pkg/terra/client/mocks/Logger.go +++ b/pkg/terra/client/mocks/Logger.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.8.0. DO NOT EDIT. +// Code generated by mockery v2.9.4. DO NOT EDIT. package mocks diff --git a/pkg/terra/client/test_helpers.go b/pkg/terra/client/test_helpers.go index f01592edb..15836786f 100644 --- a/pkg/terra/client/test_helpers.go +++ b/pkg/terra/client/test_helpers.go @@ -1,9 +1,12 @@ package client import ( + "bytes" + "crypto/rand" "encoding/json" "fmt" "io/ioutil" + "math/big" "os" "os/exec" "path" @@ -39,15 +42,16 @@ type Account struct { // 0.001 var minGasPrice = msg.NewDecCoinFromDec("uluna", msg.NewDecWithPrec(1, 3)) -func SetupLocalTerraNode(t *testing.T, chainID string) ([]Account, string) { +// SetupLocalTerraNode sets up a local terra node via terrad, and returns pre-funded accounts, the test directory, and the url. +func SetupLocalTerraNode(t *testing.T, chainID string) ([]Account, string, string) { testdir, err := ioutil.TempDir("", "integration-test") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, os.RemoveAll(testdir)) }) t.Log(testdir) - _, err = exec.Command("terrad", "init", "integration-test", "-o", "--chain-id", chainID, "--home", testdir).Output() - require.NoError(t, err) + out, err := exec.Command("terrad", "init", "integration-test", "-o", "--chain-id", chainID, "--home", testdir).Output() + require.NoError(t, err, string(out)) p := path.Join(testdir, "config", "app.toml") f, err := os.ReadFile(p) @@ -75,8 +79,8 @@ func SetupLocalTerraNode(t *testing.T, chainID string) ([]Account, string) { privateKey, address := createKeyFromMnemonic(t, k.Mnemonic) require.Equal(t, expAcctAddr, address) // Give it 100 luna - _, err = exec.Command("terrad", "add-genesis-account", k.Address, "100000000uluna", "--home", testdir).Output() - require.NoError(t, err) + out2, err2 := exec.Command("terrad", "add-genesis-account", k.Address, "100000000uluna", "--home", testdir).Output() //nolint:gosec + require.NoError(t, err2, string(out2)) accounts = append(accounts, Account{ Name: account, Address: address, @@ -84,21 +88,41 @@ func SetupLocalTerraNode(t *testing.T, chainID string) ([]Account, string) { }) } // Stake 10 luna in first acct - out, err := exec.Command("terrad", "gentx", accounts[0].Name, "10000000uluna", fmt.Sprintf("--chain-id=%s", chainID), "--keyring-backend", "test", "--keyring-dir", testdir, "--home", testdir).CombinedOutput() + out, err = exec.Command("terrad", "gentx", accounts[0].Name, "10000000uluna", fmt.Sprintf("--chain-id=%s", chainID), "--keyring-backend", "test", "--keyring-dir", testdir, "--home", testdir).CombinedOutput() //nolint:gosec require.NoError(t, err, string(out)) out, err = exec.Command("terrad", "collect-gentxs", "--home", testdir).CombinedOutput() require.NoError(t, err, string(out)) - cmd := exec.Command("terrad", "start", "--home", testdir) + + port := mustRandomPort() + tendermintURL := fmt.Sprintf("http://127.0.0.1:%d", port) + t.Log(tendermintURL) + cmd := exec.Command("terrad", "start", "--home", testdir, + "--rpc.laddr", fmt.Sprintf("tcp://127.0.0.1:%d", port), + "--rpc.pprof_laddr", "0.0.0.0:0", + "--grpc.address", "0.0.0.0:0", + "--grpc-web.address", "0.0.0.0:0", + "--p2p.laddr", "0.0.0.0:0") + var stdErr bytes.Buffer + cmd.Stderr = &stdErr require.NoError(t, cmd.Start()) t.Cleanup(func() { - require.NoError(t, cmd.Process.Kill()) + assert.NoError(t, cmd.Process.Kill()) + if err2 := cmd.Wait(); assert.Error(t, err2) { + if !assert.Contains(t, err2.Error(), "signal: killed", cmd.ProcessState.String()) { + t.Log("terrad stderr:", stdErr.String()) + } + } }) + // Wait for api server to boot var ready bool - for i := 0; i < 10; i++ { - time.Sleep(1 * time.Second) - out, err = exec.Command("curl", "http://127.0.0.1:26657/abci_info").Output() - require.NoError(t, err) + for i := 0; i < 30; i++ { + time.Sleep(time.Second) + out, err = exec.Command("curl", tendermintURL+"/abci_info").Output() //nolint:gosec + if err != nil { + t.Logf("API server not ready yet (attempt %d): %v\n", i+1, err) + continue + } var a struct { Result struct { Response struct { @@ -106,18 +130,22 @@ func SetupLocalTerraNode(t *testing.T, chainID string) ([]Account, string) { } `json:"response"` } `json:"result"` } - require.NoError(t, json.Unmarshal(out, &a)) - if a.Result.Response.LastBlockHeight != "" { - ready = true - break + require.NoError(t, json.Unmarshal(out, &a), string(out)) + if a.Result.Response.LastBlockHeight == "" { + t.Logf("API server not ready yet (attempt %d)\n", i+1) + continue } + ready = true + break } require.True(t, ready) - return accounts, testdir + return accounts, testdir, tendermintURL } -func DeployTestContract(t *testing.T, deployAccount, ownerAccount Account, tc *Client, testdir, wasmTestContractPath string) sdk.AccAddress { - out, err := exec.Command("terrad", "tx", "wasm", "store", wasmTestContractPath, +// DeployTestContract deploys a test contract. +func DeployTestContract(t *testing.T, tendermintURL string, deployAccount, ownerAccount Account, tc *Client, testdir, wasmTestContractPath string) sdk.AccAddress { + //nolint:gosec + out, err := exec.Command("terrad", "tx", "wasm", "store", wasmTestContractPath, "--node", tendermintURL, "--from", deployAccount.Name, "--gas", "auto", "--fees", "100000uluna", "--chain-id", "42", "--broadcast-mode", "block", "--home", testdir, "--keyring-backend", "test", "--keyring-dir", testdir, "--yes").CombinedOutput() require.NoError(t, err, string(out)) an, sn, err2 := tc.Account(ownerAccount.Address) @@ -154,3 +182,11 @@ func GetContractAddr(t *testing.T, tc *Client, deploymentHash string) sdk.AccAdd require.NoError(t, err) return contract } + +func mustRandomPort() int { + r, err := rand.Int(rand.Reader, big.NewInt(65535-1023)) + if err != nil { + panic(fmt.Errorf("unexpected error generating random port: %w", err)) + } + return int(r.Int64() + 1024) +} diff --git a/pkg/terra/config_digester.go b/pkg/terra/config_digester.go index 269ff84dc..4f12b950b 100644 --- a/pkg/terra/config_digester.go +++ b/pkg/terra/config_digester.go @@ -3,7 +3,9 @@ package terra import ( "bytes" "encoding/binary" + "errors" "fmt" + "math" cosmosSDK "github.com/cosmos/cosmos-sdk/types" @@ -31,6 +33,14 @@ func (cd OffchainConfigDigester) ConfigDigest(cfg types.ContractConfig) (types.C digest := types.ConfigDigest{} buf := bytes.NewBuffer([]byte{}) + if len(cd.chainID) > math.MaxUint8 { + return digest, errors.New("chainID exceeds max uint8 length") + } + + if err := binary.Write(buf, binary.BigEndian, uint8(len(cd.chainID))); err != nil { + return digest, err + } + if _, err := buf.Write([]byte(cd.chainID)); err != nil { return digest, err } @@ -53,6 +63,11 @@ func (cd OffchainConfigDigester) ConfigDigest(cfg types.ContractConfig) (types.C } } + // NOTE: We assume that signers and transmitters have the same length, currently + // enforced onchain https://github.com/smartcontractkit/chainlink-terra/blob/9465a4ace6954b8647869d279363a25d1ae1b934/contracts/ocr2/src/contract.rs#L508 + // and that they have fixed sizes, thus we don't need a transmitter length prefix + // here to avoid config digest collisions. Should that enforcement change + // we'll need to add a length prefix here. for _, transmitter := range cfg.Transmitters { if _, err := buf.Write([]byte(transmitter)); err != nil { return digest, err diff --git a/pkg/terra/config_digester_test.go b/pkg/terra/config_digester_test.go index 48b0119f5..7c961db94 100644 --- a/pkg/terra/config_digester_test.go +++ b/pkg/terra/config_digester_test.go @@ -1,38 +1,50 @@ package terra import ( + "strings" "testing" "github.com/smartcontractkit/libocr/offchainreporting2/types" "github.com/stretchr/testify/assert" ) +var testConfig = types.ContractConfig{ + ConfigCount: 1, + Signers: []types.OnchainPublicKey{ + []byte{28, 69, 220, 255, 145, 161, 41, 242, 208, 125, 181, 65, 174, 4, 255, 77, 61, 37, 134, 54, 130, 230, 11, 172, 175, 166, 100, 99, 69, 122, 138, 128}, + []byte{245, 123, 138, 66, 133, 4, 54, 37, 129, 106, 119, 250, 131, 43, 174, 81, 139, 147, 232, 202, 3, 177, 159, 111, 170, 76, 143, 137, 250, 67, 69, 125}, + []byte{146, 9, 73, 38, 35, 203, 190, 72, 88, 255, 219, 63, 192, 95, 118, 108, 236, 15, 144, 179, 62, 29, 223, 222, 245, 61, 164, 73, 208, 76, 72, 59}, + []byte{243, 96, 118, 131, 178, 167, 101, 157, 94, 246, 73, 127, 240, 101, 36, 36, 102, 191, 168, 19, 47, 217, 47, 45, 245, 233, 119, 230, 53, 102, 153, 74}, + }, + Transmitters: []types.Account{ + "terra1tghjf8lcrf7ad9hjw9ap0ptxn0q5nkang9m3p4", + "terra1wk3s8vpkxj8c08rswt8m2ur0ufe2lkxcwh7l3d", + "terra1ddkw35crxpeddenmcjewh5dqraxsv7vwm48xak", + "terra1gcu7jcnyh6k74f0cp95gp4lzg4k0w26shkw22l", + }, + F: 1, + OnchainConfig: []byte{}, + OffchainConfigVersion: 2, + OffchainConfig: []byte{8, 128, 168, 214, 185, 7, 16, 128, 148, 235, 220, 3, 24, 128, 148, 235, 220, 3, 32, 128, 202, 181, 238, 1, 40, 128, 168, 214, 185, 7, 48, 3, 58, 4, 1, 1, 1, 1, 66, 32, 136, 99, 127, 179, 112, 251, 210, 5, 179, 14, 165, 40, 178, 72, 177, 95, 153, 70, 125, 163, 116, 227, 213, 217, 77, 208, 194, 7, 151, 116, 212, 160, 66, 32, 88, 247, 149, 158, 51, 177, 58, 136, 11, 0, 206, 196, 97, 202, 194, 189, 249, 27, 54, 211, 54, 208, 184, 216, 15, 61, 233, 177, 39, 97, 213, 69, 66, 32, 56, 122, 236, 208, 44, 127, 77, 118, 178, 31, 172, 160, 227, 177, 171, 61, 137, 247, 136, 89, 211, 54, 157, 119, 235, 17, 213, 190, 36, 80, 68, 233, 66, 32, 89, 136, 237, 203, 198, 53, 101, 102, 194, 23, 36, 136, 10, 131, 164, 242, 82, 56, 135, 70, 71, 252, 228, 74, 22, 145, 234, 199, 176, 124, 240, 110, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 69, 105, 57, 68, 107, 85, 68, 56, 66, 122, 110, 109, 89, 70, 119, 109, 70, 110, 107, 115, 52, 69, 111, 65, 53, 70, 74, 110, 102, 114, 67, 77, 88, 57, 54, 121, 112, 53, 74, 120, 104, 71, 99, 89, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 81, 107, 52, 54, 68, 113, 52, 103, 122, 104, 70, 65, 78, 68, 77, 100, 85, 51, 88, 102, 113, 90, 57, 69, 117, 97, 78, 110, 109, 53, 85, 120, 122, 116, 75, 72, 100, 118, 107, 75, 55, 102, 86, 117, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 67, 90, 113, 106, 121, 115, 87, 57, 81, 104, 118, 49, 68, 72, 82, 76, 69, 117, 83, 81, 85, 88, 98, 109, 49, 80, 113, 88, 88, 65, 74, 102, 80, 57, 71, 97, 112, 99, 99, 107, 74, 105, 119, 109, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 66, 97, 106, 119, 111, 106, 72, 121, 109, 102, 86, 66, 68, 82, 56, 120, 67, 104, 118, 84, 55, 76, 52, 56, 50, 50, 74, 53, 107, 89, 70, 107, 65, 113, 115, 80, 104, 112, 81, 78, 121, 81, 74, 114, 82, 16, 0, 0, 0, 0, 0, 45, 198, 192, 0, 0, 0, 0, 0, 0, 0, 0, 88, 128, 225, 235, 23, 96, 128, 225, 235, 23, 104, 128, 225, 235, 23, 112, 128, 225, 235, 23, 120, 128, 225, 235, 23, 130, 1, 140, 1, 10, 32, 181, 88, 224, 203, 224, 168, 227, 94, 172, 196, 63, 164, 146, 136, 91, 186, 91, 111, 129, 189, 44, 156, 168, 196, 184, 11, 66, 188, 195, 115, 137, 20, 18, 32, 26, 27, 247, 83, 194, 80, 93, 171, 82, 4, 178, 132, 241, 254, 253, 125, 196, 235, 131, 246, 48, 70, 100, 201, 194, 244, 60, 191, 66, 86, 102, 96, 26, 16, 198, 216, 18, 243, 142, 77, 172, 75, 15, 162, 86, 225, 152, 154, 30, 61, 26, 16, 178, 15, 108, 85, 146, 35, 250, 89, 217, 237, 111, 20, 87, 215, 173, 142, 26, 16, 43, 40, 56, 124, 16, 177, 93, 71, 182, 227, 75, 241, 146, 174, 93, 244, 26, 16, 92, 222, 61, 101, 97, 47, 37, 21, 39, 37, 172, 18, 140, 109, 236, 20}, +} + func TestConfigDigester(t *testing.T) { d := NewOffchainConfigDigester( "localterra", MustAccAddress("terra16huq7fzc95eyy89xsghzchde2tvucn9ahqja3j"), ) - config := types.ContractConfig{ - ConfigCount: 1, - Signers: []types.OnchainPublicKey{ - []byte{28, 69, 220, 255, 145, 161, 41, 242, 208, 125, 181, 65, 174, 4, 255, 77, 61, 37, 134, 54, 130, 230, 11, 172, 175, 166, 100, 99, 69, 122, 138, 128}, - []byte{245, 123, 138, 66, 133, 4, 54, 37, 129, 106, 119, 250, 131, 43, 174, 81, 139, 147, 232, 202, 3, 177, 159, 111, 170, 76, 143, 137, 250, 67, 69, 125}, - []byte{146, 9, 73, 38, 35, 203, 190, 72, 88, 255, 219, 63, 192, 95, 118, 108, 236, 15, 144, 179, 62, 29, 223, 222, 245, 61, 164, 73, 208, 76, 72, 59}, - []byte{243, 96, 118, 131, 178, 167, 101, 157, 94, 246, 73, 127, 240, 101, 36, 36, 102, 191, 168, 19, 47, 217, 47, 45, 245, 233, 119, 230, 53, 102, 153, 74}, - }, - Transmitters: []types.Account{ - "terra1tghjf8lcrf7ad9hjw9ap0ptxn0q5nkang9m3p4", - "terra1wk3s8vpkxj8c08rswt8m2ur0ufe2lkxcwh7l3d", - "terra1ddkw35crxpeddenmcjewh5dqraxsv7vwm48xak", - "terra1gcu7jcnyh6k74f0cp95gp4lzg4k0w26shkw22l", - }, - F: 1, - OnchainConfig: []byte{}, - OffchainConfigVersion: 2, - OffchainConfig: []byte{8, 128, 168, 214, 185, 7, 16, 128, 148, 235, 220, 3, 24, 128, 148, 235, 220, 3, 32, 128, 202, 181, 238, 1, 40, 128, 168, 214, 185, 7, 48, 3, 58, 4, 1, 1, 1, 1, 66, 32, 136, 99, 127, 179, 112, 251, 210, 5, 179, 14, 165, 40, 178, 72, 177, 95, 153, 70, 125, 163, 116, 227, 213, 217, 77, 208, 194, 7, 151, 116, 212, 160, 66, 32, 88, 247, 149, 158, 51, 177, 58, 136, 11, 0, 206, 196, 97, 202, 194, 189, 249, 27, 54, 211, 54, 208, 184, 216, 15, 61, 233, 177, 39, 97, 213, 69, 66, 32, 56, 122, 236, 208, 44, 127, 77, 118, 178, 31, 172, 160, 227, 177, 171, 61, 137, 247, 136, 89, 211, 54, 157, 119, 235, 17, 213, 190, 36, 80, 68, 233, 66, 32, 89, 136, 237, 203, 198, 53, 101, 102, 194, 23, 36, 136, 10, 131, 164, 242, 82, 56, 135, 70, 71, 252, 228, 74, 22, 145, 234, 199, 176, 124, 240, 110, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 69, 105, 57, 68, 107, 85, 68, 56, 66, 122, 110, 109, 89, 70, 119, 109, 70, 110, 107, 115, 52, 69, 111, 65, 53, 70, 74, 110, 102, 114, 67, 77, 88, 57, 54, 121, 112, 53, 74, 120, 104, 71, 99, 89, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 81, 107, 52, 54, 68, 113, 52, 103, 122, 104, 70, 65, 78, 68, 77, 100, 85, 51, 88, 102, 113, 90, 57, 69, 117, 97, 78, 110, 109, 53, 85, 120, 122, 116, 75, 72, 100, 118, 107, 75, 55, 102, 86, 117, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 67, 90, 113, 106, 121, 115, 87, 57, 81, 104, 118, 49, 68, 72, 82, 76, 69, 117, 83, 81, 85, 88, 98, 109, 49, 80, 113, 88, 88, 65, 74, 102, 80, 57, 71, 97, 112, 99, 99, 107, 74, 105, 119, 109, 74, 52, 49, 50, 68, 51, 75, 111, 111, 87, 66, 97, 106, 119, 111, 106, 72, 121, 109, 102, 86, 66, 68, 82, 56, 120, 67, 104, 118, 84, 55, 76, 52, 56, 50, 50, 74, 53, 107, 89, 70, 107, 65, 113, 115, 80, 104, 112, 81, 78, 121, 81, 74, 114, 82, 16, 0, 0, 0, 0, 0, 45, 198, 192, 0, 0, 0, 0, 0, 0, 0, 0, 88, 128, 225, 235, 23, 96, 128, 225, 235, 23, 104, 128, 225, 235, 23, 112, 128, 225, 235, 23, 120, 128, 225, 235, 23, 130, 1, 140, 1, 10, 32, 181, 88, 224, 203, 224, 168, 227, 94, 172, 196, 63, 164, 146, 136, 91, 186, 91, 111, 129, 189, 44, 156, 168, 196, 184, 11, 66, 188, 195, 115, 137, 20, 18, 32, 26, 27, 247, 83, 194, 80, 93, 171, 82, 4, 178, 132, 241, 254, 253, 125, 196, 235, 131, 246, 48, 70, 100, 201, 194, 244, 60, 191, 66, 86, 102, 96, 26, 16, 198, 216, 18, 243, 142, 77, 172, 75, 15, 162, 86, 225, 152, 154, 30, 61, 26, 16, 178, 15, 108, 85, 146, 35, 250, 89, 217, 237, 111, 20, 87, 215, 173, 142, 26, 16, 43, 40, 56, 124, 16, 177, 93, 71, 182, 227, 75, 241, 146, 174, 93, 244, 26, 16, 92, 222, 61, 101, 97, 47, 37, 21, 39, 37, 172, 18, 140, 109, 236, 20}, - } - digest, err := d.ConfigDigest(config) + digest, err := d.ConfigDigest(testConfig) assert.NoError(t, err) - assert.Equal(t, "0002d5b58e9164cba342550204a08494d008ae8e68899911665553604d90d988", digest.Hex()) + assert.Equal(t, "0002c337db7a24692b6dc0a0da59c4ee80a3afbd4c782e40e4d8267454f3cf61", digest.Hex()) +} + +func TestConfigDigester_InvalidChainID(t *testing.T) { + d := NewOffchainConfigDigester( + strings.Repeat("a", 256), // chain ID is too long + MustAccAddress("terra16huq7fzc95eyy89xsghzchde2tvucn9ahqja3j"), + ) + + _, err := d.ConfigDigest(testConfig) + assert.Error(t, err) } diff --git a/pkg/terra/contract_reader.go b/pkg/terra/contract_reader.go index 7d9b5a48d..6a4639cbd 100644 --- a/pkg/terra/contract_reader.go +++ b/pkg/terra/contract_reader.go @@ -67,66 +67,153 @@ func (r *OCR2Reader) LatestConfig(ctx context.Context, changedInBlock uint64) (t for _, event := range res.TxResponses[0].Logs[0].Events { if event.Type == "wasm-set_config" { - return parseAttributes(event.Attributes) + cc, unknown, err := parseAttributes(event.Attributes) + if len(unknown) > 0 { + r.lggr.Warnf("wasm-set_config event contained unrecognized attributes: %v", unknown) + } + return cc, err } } return types.ContractConfig{}, fmt.Errorf("No set_config event found for tx %s", res.TxResponses[0].TxHash) } -func parseAttributes(attrs []cosmosSDK.Attribute) (output types.ContractConfig, err error) { +// parseAttributes returns a ContractConfig parsed from attrs. +// An error will be returned if any of the 8 required attributes are not present, or if any duplicates are found for +// unique attributes. +// unknownKeys contains counts of any unrecognized keys, which are otherwise ignored. +func parseAttributes(attrs []cosmosSDK.Attribute) (output types.ContractConfig, unknownKeys map[string]int, err error) { + const uniqueKeys = 8 + known := make(map[string]struct{}, uniqueKeys) + first := func(key string) bool { + _, ok := known[key] + if ok { + return false + } + known[key] = struct{}{} + return true + } for _, attr := range attrs { key, value := attr.Key, attr.Value switch key { case "latest_config_digest": + if !first(key) { + err = ErrAttrDupe(key) + return + } // parse byte array encoded as hex string - if err := HexToConfigDigest(value, &output.ConfigDigest); err != nil { - return types.ContractConfig{}, err + if err = HexToConfigDigest(value, &output.ConfigDigest); err != nil { + err = &ErrAttrInvalid{Err: err, Key: key} + return } case "config_count": - i, err := strconv.ParseInt(value, 10, 64) + if !first(key) { + err = ErrAttrDupe(key) + return + } + var i int64 + i, err = strconv.ParseInt(value, 10, 64) if err != nil { - return types.ContractConfig{}, err + err = &ErrAttrInvalid{Err: err, Key: key} + return } output.ConfigCount = uint64(i) case "signers": + known[key] = struct{}{} // this assumes the value will be a hex encoded string which each signer 32 bytes and each signer will be a separate parameter var v []byte - if err := HexToByteArray(value, &v); err != nil { - return types.ContractConfig{}, err + if err = HexToByteArray(value, &v); err != nil { + err = &ErrAttrInvalid{Err: err, Key: key} + return + } + if len(v) != 32 { + err = fmt.Errorf("failed to parse attribute %q: length '%d' != 32", key, len(v)) + return } output.Signers = append(output.Signers, v) case "transmitters": + known[key] = struct{}{} // this assumes the return value be a string for each transmitter and each transmitter will be separate output.Transmitters = append(output.Transmitters, types.Account(attr.Value)) case "f": - i, err := strconv.ParseInt(value, 10, 8) + if !first(key) { + err = ErrAttrDupe(key) + return + } + var i int64 + i, err = strconv.ParseInt(value, 10, 8) if err != nil { - return types.ContractConfig{}, err + err = &ErrAttrInvalid{Err: err, Key: key} + return } output.F = uint8(i) case "onchain_config": + if !first(key) { + err = ErrAttrDupe(key) + return + } + var config []byte // parse byte array encoded as base64 - config, err := base64.StdEncoding.DecodeString(value) + config, err = base64.StdEncoding.DecodeString(value) if err != nil { - return types.ContractConfig{}, err + err = &ErrAttrInvalid{Err: err, Key: key} + return } output.OnchainConfig = config case "offchain_config_version": - i, err := strconv.ParseInt(value, 10, 64) + if !first(key) { + err = ErrAttrDupe(key) + return + } + var i int64 + i, err = strconv.ParseInt(value, 10, 64) if err != nil { - return types.ContractConfig{}, err + err = &ErrAttrInvalid{Err: err, Key: key} + return } output.OffchainConfigVersion = uint64(i) case "offchain_config": + if !first(key) { + err = ErrAttrDupe(key) + return + } + var bytes []byte // parse byte array encoded as base64 - bytes, err := base64.StdEncoding.DecodeString(value) + bytes, err = base64.StdEncoding.DecodeString(value) if err != nil { - return types.ContractConfig{}, err + err = &ErrAttrInvalid{Err: err, Key: key} + return } output.OffchainConfig = bytes + default: + if unknownKeys == nil { + unknownKeys = make(map[string]int) + } + unknownKeys[key]++ } } - return output, nil + if len(known) != uniqueKeys { + err = fmt.Errorf("expected %d types of known keys, but found %d: %v", uniqueKeys, len(known), known) + } + return +} + +// ErrAttrInvalid is returned when parsing fails. +type ErrAttrInvalid struct { + Key string + Err error +} + +func (e *ErrAttrInvalid) Error() string { + return fmt.Sprintf("failed to parse attribute %q: %s", e.Key, e.Err.Error()) +} + +func (e *ErrAttrInvalid) Unwrap() error { return e.Err } + +// ErrAttrDupe is returned when a duplicate attribute is found for a unique key. +type ErrAttrDupe string + +func (e ErrAttrDupe) Error() string { + return fmt.Sprintf("duplicate attributes for %q", string(e)) } // LatestTransmissionDetails fetches the latest transmission details from address state diff --git a/pkg/terra/contract_reader_test.go b/pkg/terra/contract_reader_test.go new file mode 100644 index 000000000..4f386d19b --- /dev/null +++ b/pkg/terra/contract_reader_test.go @@ -0,0 +1,97 @@ +package terra + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + cosmosSDK "github.com/cosmos/cosmos-sdk/types" + "github.com/smartcontractkit/libocr/offchainreporting2/types" +) + +func Test_parseAttributes(t *testing.T) { + valid := []cosmosSDK.Attribute{ + {Key: "config_count", Value: "1"}, + {Key: "f", Value: "79"}, + {Key: "latest_config_digest", Value: "7465737420636f6e66696720646967657374203332206368617273206c6f6e67"}, + {Key: "offchain_config", Value: "AwQ="}, + {Key: "offchain_config_version", Value: "111"}, + {Key: "onchain_config", Value: "AQI="}, + {Key: "signers", Value: "0101010101010101010101010101010101010101010101010101010101010101"}, + {Key: "signers", Value: "0202020202020202020202020202020202020202020202020202020202020202"}, + {Key: "transmitters", Value: "account1"}, + {Key: "transmitters", Value: "account2"}, + } + validResult := types.ContractConfig{ + ConfigDigest: mustStringToConfigDigest(t, "test config digest 32 chars long"), + ConfigCount: 1, + Signers: []types.OnchainPublicKey{ + types.OnchainPublicKey(bytes.Repeat([]byte{0x01}, 32)), + types.OnchainPublicKey(bytes.Repeat([]byte{0x02}, 32)), + }, + Transmitters: []types.Account{"account1", "account2"}, + F: 79, + OnchainConfig: []byte{0x01, 0x02}, + OffchainConfigVersion: 111, + OffchainConfig: []byte{0x03, 0x04}, + } + tests := []struct { + name string + attrs []cosmosSDK.Attribute + exp types.ContractConfig + expErrIs error + expErrStr string + expUnknown map[string]int + }{ + {name: "valid", attrs: valid, exp: validResult}, + { + name: "valid-unknown", + attrs: append(valid, cosmosSDK.Attribute{Key: "foo"}, cosmosSDK.Attribute{Key: "foo"}, cosmosSDK.Attribute{Key: "bar"}), + exp: validResult, + expUnknown: map[string]int{"foo": 2, "bar": 1}, + }, + + // invalid + {name: "empty", attrs: nil, exp: types.ContractConfig{}, expErrStr: "expected 8 types of known keys"}, + {name: "missing_config-count", attrs: valid[1:], expErrStr: "expected 8 types of known keys"}, + {name: "dupe_config-count", attrs: append([]cosmosSDK.Attribute{valid[0]}, valid...), expErrIs: ErrAttrDupe("config_count")}, + {name: "config_count-decimal", expErrIs: strconv.ErrSyntax, attrs: []cosmosSDK.Attribute{ + {Key: "config_count", Value: "1.1"}}}, + {name: "f-hex", expErrIs: strconv.ErrSyntax, attrs: []cosmosSDK.Attribute{ + {Key: "f", Value: "0xabcd"}}}, + {name: "latest_config_digest-truncated", expErrStr: "cannot convert bytes to ConfigDigest. bytes have wrong length", attrs: []cosmosSDK.Attribute{ + {Key: "latest_config_digest", Value: "7465737420636f6e6669672064"}}}, + {name: "offchain_config-hex", expErrIs: base64.CorruptInputError(4), attrs: []cosmosSDK.Attribute{ + {Key: "offchain_config", Value: "0x1234"}}}, + {name: "offchain_config_version-word", expErrIs: strconv.ErrSyntax, attrs: []cosmosSDK.Attribute{ + {Key: "offchain_config_version", Value: "hundred"}}}, + {name: "signers-base64", expErrIs: hex.InvalidByteError('Q'), attrs: []cosmosSDK.Attribute{ + {Key: "signers", Value: "AQ=="}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, unknown, err := parseAttributes(tt.attrs) + if tt.expErrIs != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.expErrIs) + } else if tt.expErrStr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expErrStr) + } else { + require.NoError(t, err) + require.Equal(t, tt.exp, got) + } + require.Equal(t, tt.expUnknown, unknown) + }) + } +} + +func mustStringToConfigDigest(t *testing.T, s string) types.ConfigDigest { + d, err := types.BytesToConfigDigest([]byte(s)) + require.NoError(t, err) + return d +} diff --git a/pkg/terra/contract_transmitter.go b/pkg/terra/contract_transmitter.go index 00951c860..1a06e51f0 100644 --- a/pkg/terra/contract_transmitter.go +++ b/pkg/terra/contract_transmitter.go @@ -65,11 +65,7 @@ func (ct *ContractTransmitter) Transmit( return err } m := terraSDK.NewMsgExecuteContract(ct.sender, ct.contract, msgBytes, cosmosSDK.Coins{}) - d, err := m.Marshal() - if err != nil { - return err - } - _, err = ct.msgEnqueuer.Enqueue(ct.contract.String(), d) + _, err = ct.msgEnqueuer.Enqueue(ct.contract.String(), m) return err } diff --git a/pkg/terra/db/db.go b/pkg/terra/db/db.go index 804d37a5b..2b5e85174 100644 --- a/pkg/terra/db/db.go +++ b/pkg/terra/db/db.go @@ -78,7 +78,8 @@ type Msg struct { ChainID string `db:"terra_chain_id"` ContractID string State State - Raw []byte // serialized msg + Type string // cosmos-sdk/types.MsgTypeURL() + Raw []byte // proto.Marshal() TxHash *string CreatedAt time.Time UpdatedAt time.Time diff --git a/pkg/terra/marshal_signed_int.go b/pkg/terra/marshal_signed_int.go new file mode 100644 index 000000000..96226f226 --- /dev/null +++ b/pkg/terra/marshal_signed_int.go @@ -0,0 +1,64 @@ +package terra + +import ( + "bytes" + "fmt" + "math/big" +) + +var i = big.NewInt + +func bounds(numBytes uint) (*big.Int, *big.Int) { + max := i(0).Sub(i(0).Lsh(i(1), numBytes*8-1), i(1)) // 2**(numBytes*8-1)- 1 + min := i(0).Sub(i(0).Neg(max), i(1)) // -2**(numBytes*8-1) + return min, max +} + +// ToBigInt interprets bytes s as a big-endian signed integer +// of size numBytes. +func ToBigInt(s []byte, numBytes uint) (*big.Int, error) { + if uint(len(s)) != numBytes { + return nil, fmt.Errorf("invalid int length: expected %d got %d", numBytes, len(s)) + } + val := (&big.Int{}).SetBytes(s) + numBits := numBytes * 8 + _, max := bounds(numBytes) + negative := val.Cmp(max) > 0 + if negative { + // Get the complement wrt to 2^numBits + maxUint := big.NewInt(1) + maxUint.Lsh(maxUint, numBits) + val.Sub(maxUint, val) + val.Neg(val) + } + return val, nil +} + +// ToBytes converts *big.Int o into bytes as a big-endian signed +// integer of size numBytes +func ToBytes(o *big.Int, numBytes uint) ([]byte, error) { + min, max := bounds(numBytes) + if o.Cmp(max) > 0 || o.Cmp(min) < 0 { + return nil, fmt.Errorf("value won't fit in int%v: 0x%x", numBytes*8, o) + } + negative := o.Sign() < 0 + val := (&big.Int{}) + numBits := numBytes * 8 + if negative { + // compute two's complement as 2**numBits - abs(o) = 2**numBits + o + val.SetInt64(1) + val.Lsh(val, numBits) + val.Add(val, o) + } else { + val.Set(o) + } + b := val.Bytes() // big-endian representation of abs(val) + if uint(len(b)) > numBytes { + return nil, fmt.Errorf("b must fit in %v bytes", numBytes) + } + b = bytes.Join([][]byte{bytes.Repeat([]byte{0}, int(numBytes)-len(b)), b}, []byte{}) + if uint(len(b)) != numBytes { + return nil, fmt.Errorf("wrong length; there must be an error in the padding of b: %v", b) + } + return b, nil +} diff --git a/pkg/terra/marshal_signed_int_test.go b/pkg/terra/marshal_signed_int_test.go new file mode 100644 index 000000000..87a815301 --- /dev/null +++ b/pkg/terra/marshal_signed_int_test.go @@ -0,0 +1,186 @@ +package terra + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMarshalSignedInt(t *testing.T) { + var tt = []struct { + bytesVal string + size uint + expected *big.Int + expectErr bool + }{ + { + "ffffffffffffffff", + 8, + big.NewInt(-1), + false, + }, + { + "fffffffffffffffe", + 8, + big.NewInt(-2), + false, + }, + { + "0000000000000000", + 8, + big.NewInt(0), + false, + }, + { + "0000000000000001", + 8, + big.NewInt(1), + false, + }, + { + "0000000000000002", + 8, + big.NewInt(2), + false, + }, + { + "7fffffffffffffff", + 8, + big.NewInt(9223372036854775807), // 2^63 - 1 + false, + }, + { + "00000000000000000000000000000000", + 16, + big.NewInt(0), + false, + }, + { + "00000000000000000000000000000001", + 16, + big.NewInt(1), + false, + }, + { + "00000000000000000000000000000002", + 16, + big.NewInt(2), + false, + }, + { + "7fffffffffffffffffffffffffffffff", // 2^127 - 1 + 16, + big.NewInt(0).Sub(big.NewInt(0).Lsh(big.NewInt(1), 127), big.NewInt(1)), + false, + }, + { + "ffffffffffffffffffffffffffffffff", + 16, + big.NewInt(-1), + false, + }, + { + "fffffffffffffffffffffffffffffffe", + 16, + big.NewInt(-2), + false, + }, + { + "000000000000000000000000000000000000000000000000", + 24, + big.NewInt(0), + false, + }, + { + "000000000000000000000000000000000000000000000001", + 24, + big.NewInt(1), + false, + }, + { + "000000000000000000000000000000000000000000000002", + 24, + big.NewInt(2), + false, + }, + { + "ffffffffffffffffffffffffffffffffffffffffffffffff", + 24, + big.NewInt(-1), + false, + }, + { + "fffffffffffffffffffffffffffffffffffffffffffffffe", + 24, + big.NewInt(-2), + false, + }, + } + for _, tc := range tt { + tc := tc + b, err := hex.DecodeString(tc.bytesVal) + require.NoError(t, err) + i, err := ToBigInt(b, tc.size) + require.NoError(t, err) + assert.Equal(t, i.String(), tc.expected.String()) + + // Marshalling back should give us the same bytes + bAfter, err := ToBytes(i, tc.size) + require.NoError(t, err) + assert.Equal(t, tc.bytesVal, hex.EncodeToString(bAfter)) + } + + var tt2 = []struct { + o *big.Int + numBytes uint + expectErr bool + }{ + { + big.NewInt(128), + 1, + true, + }, + { + big.NewInt(-129), + 1, + true, + }, + { + big.NewInt(-128), + 1, + false, + }, + { + big.NewInt(2147483648), + 4, + true, + }, + { + big.NewInt(2147483647), + 4, + false, + }, + { + big.NewInt(-2147483649), + 4, + true, + }, + { + big.NewInt(-2147483648), + 4, + false, + }, + } + for _, tc := range tt2 { + tc := tc + _, err := ToBytes(tc.o, tc.numBytes) + if tc.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } +} diff --git a/pkg/terra/mocks/Logger.go b/pkg/terra/mocks/Logger.go index ab268fd3a..955d2b3dc 100644 --- a/pkg/terra/mocks/Logger.go +++ b/pkg/terra/mocks/Logger.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.8.0. DO NOT EDIT. +// Code generated by mockery v2.9.4. DO NOT EDIT. package mocks diff --git a/pkg/terra/relay.go b/pkg/terra/relay.go index f6ba84726..0fd4f95b3 100644 --- a/pkg/terra/relay.go +++ b/pkg/terra/relay.go @@ -2,6 +2,7 @@ package terra import ( "errors" + "fmt" cosmosSDK "github.com/cosmos/cosmos-sdk/types" uuid "github.com/satori/go.uuid" @@ -12,6 +13,15 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2/types" ) +// ErrMsgUnsupported is returned when an unsupported type of message is encountered. +type ErrMsgUnsupported struct { + Msg cosmosSDK.Msg +} + +func (e *ErrMsgUnsupported) Error() string { + return fmt.Sprintf("unsupported message type %T: %s", e.Msg, e.Msg) +} + //go:generate mockery --name Logger --output ./mocks/ type Logger interface { Tracef(format string, values ...interface{}) @@ -25,9 +35,19 @@ type Logger interface { } type MsgEnqueuer interface { - Enqueue(contractID string, msg []byte) (int64, error) - Start() error - Close() error + // Enqueue enqueues msg for broadcast and returns its id. + // Returns ErrMsgUnsupported for unsupported message types. + Enqueue(contractID string, msg cosmosSDK.Msg) (int64, error) +} + +// TxManager manages txs composed of batches of queued messages. +type TxManager interface { + MsgEnqueuer + + // GetMsgs returns any messages matching ids. + GetMsgs(ids ...int64) (Msgs, error) + // GasPrice returns the gas price in uluna. + GasPrice() (cosmosSDK.DecCoin, error) } // CL Core OCR2 job spec RelayConfig member for Terra @@ -61,12 +81,14 @@ func NewRelayer(lggr Logger, chainSet ChainSet) *Relayer { } func (r *Relayer) Start() error { - return r.chainSet.Start() + if r.chainSet == nil { + return errors.New("Terra unavailable") + } + return nil } -// Close will close all open subservices func (r *Relayer) Close() error { - return r.chainSet.Close() + return nil } func (r *Relayer) Ready() error { @@ -92,7 +114,7 @@ func (r *Relayer) NewOCR2Provider(externalJobID uuid.UUID, s interface{}) (relay if err != nil { return nil, err } - msgEnqueuer := chain.MsgEnqueuer() + msgEnqueuer := chain.TxManager() contractAddr, err := cosmosSDK.AccAddressFromBech32(spec.ContractID) if err != nil { diff --git a/pkg/terra/report.go b/pkg/terra/report.go index 1f2d6a197..4d48e8bcd 100644 --- a/pkg/terra/report.go +++ b/pkg/terra/report.go @@ -10,6 +10,26 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2/types" ) +const ( + timestampSizeBytes = 4 + observersSizeBytes = 32 + observationsLenBytes = 1 + prefixSizeBytes = timestampSizeBytes + observersSizeBytes + observationsLenBytes + juelsPerFeeCoinSizeBytes = 16 +) + +type observation []byte + +const observationSizeBytes = 16 + +func newObservationFromInt(o *big.Int) (observation, error) { + return ToBytes(o, observationSizeBytes) +} + +func (o observation) ToBigInt() (*big.Int, error) { + return ToBigInt(o, observationSizeBytes) +} + var _ median.ReportCodec = (*ReportCodec)(nil) type ReportCodec struct{} @@ -40,57 +60,67 @@ func (c ReportCodec) BuildReport(oo []median.ParsedAttributedObservation) (types return oo[i].Value.Cmp(oo[j].Value) < 0 }) - observers := [32]byte{} - observations := []*big.Int{} - + var observers [32]byte + var observations []*big.Int for i, o := range oo { observers[i] = byte(o.Observer) observations = append(observations, o.Value) } - // encoding - report := []byte{} - + // Add timestamp + var report []byte time := make([]byte, 4) binary.BigEndian.PutUint32(time, timestamp) report = append(report, time[:]...) + // Add observers report = append(report, observers[:]...) + // Add length of observations report = append(report, byte(len(observations))) - + // Add observations for _, o := range observations { - oBytes := make([]byte, MedianLen) - report = append(report, o.FillBytes(oBytes)[:]...) + obs, err := newObservationFromInt(o) + if err != nil { + return nil, err + } + report = append(report, obs[:]...) } - jBytes := make([]byte, JuelsLen) - report = append(report, juelsPerFeeCoin.FillBytes(jBytes)[:]...) - - return types.Report(report), nil + // Add juels per fee coin value + jBytes, err := ToBytes(juelsPerFeeCoin, juelsPerFeeCoinSizeBytes) + if err != nil { + return nil, err + } + report = append(report, jBytes...) + return report, nil } func (c ReportCodec) MedianFromReport(report types.Report) (*big.Int, error) { // report should at least be able to contain timestamp, observers, observations length rLen := len(report) - if rLen < PrefixLen { - return nil, fmt.Errorf("report length missmatch: %d (received), %d (expected)", rLen, PrefixLen) + if rLen < prefixSizeBytes { + return nil, fmt.Errorf("report length missmatch: %d (received), %d (expected)", rLen, prefixSizeBytes) } - n := int(report[4+32]) + // Read observations length + n := int(report[timestampSizeBytes+observersSizeBytes]) if n == 0 { return nil, fmt.Errorf("unpacked report has no 'observations'") } - if rLen < PrefixLen+(MedianLen*n)+JuelsLen { - return nil, fmt.Errorf("report does not contain enough observations or is missing juels/eth observation") + if rLen < prefixSizeBytes+(observationSizeBytes*n)+juelsPerFeeCoinSizeBytes { + return nil, fmt.Errorf("report does not contain enough observations or is missing juels/feeCoin observation") } // unpack observations - observations := []*big.Int{} + var observations []*big.Int for i := 0; i < n; i++ { - start := PrefixLen + MedianLen*i - end := start + MedianLen - o := big.NewInt(0).SetBytes(report[start:end]) + start := prefixSizeBytes + observationSizeBytes*i + end := start + observationSizeBytes + o, err := observation(report[start:end]).ToBigInt() + if err != nil { + return nil, err + } observations = append(observations, o) } diff --git a/pkg/terra/report_test.go b/pkg/terra/report_test.go index 184aed705..f54a74526 100644 --- a/pkg/terra/report_test.go +++ b/pkg/terra/report_test.go @@ -39,7 +39,7 @@ func TestBuildReport(t *testing.T) { assert.NoError(t, err) // validate length - totalLen := PrefixLen + MedianLen*n + JuelsLen + totalLen := prefixSizeBytes + observationSizeBytes*n + juelsPerFeeCoinSizeBytes assert.Equal(t, totalLen, len(report), "validate length") // validate timestamp @@ -54,12 +54,12 @@ func TestBuildReport(t *testing.T) { // validate observations for i := 0; i < n; i++ { - index := PrefixLen + MedianLen*i - assert.Equal(t, oo[0].Value.FillBytes(make([]byte, MedianLen)), []byte(report[index:index+MedianLen]), fmt.Sprintf("validate median observation #%d", i)) + index := prefixSizeBytes + observationSizeBytes*i + assert.Equal(t, oo[0].Value.FillBytes(make([]byte, observationSizeBytes)), []byte(report[index:index+observationSizeBytes]), fmt.Sprintf("validate median observation #%d", i)) } // validate juelsToEth - assert.Equal(t, v.FillBytes(make([]byte, JuelsLen)), []byte(report[totalLen-JuelsLen:totalLen]), "validate juelsToEth") + assert.Equal(t, v.FillBytes(make([]byte, juelsPerFeeCoinSizeBytes)), []byte(report[totalLen-juelsPerFeeCoinSizeBytes:totalLen]), "validate juelsToEth") } func TestMedianFromReport(t *testing.T) { diff --git a/pkg/terra/types.go b/pkg/terra/types.go index d447d5afc..a8f84d1af 100644 --- a/pkg/terra/types.go +++ b/pkg/terra/types.go @@ -1,20 +1,14 @@ package terra import ( + "github.com/smartcontractkit/terra.go/msg" + "github.com/smartcontractkit/chainlink-terra/pkg/terra/client" "github.com/smartcontractkit/chainlink-terra/pkg/terra/db" - "github.com/smartcontractkit/terra.go/msg" "github.com/smartcontractkit/libocr/offchainreporting2/types" ) -const ( - // Report data - PrefixLen = 4 + 32 + 1 - MedianLen = 16 - JuelsLen = 16 -) - type TransmitMsg struct { Transmit TransmitPayload `json:"transmit"` } @@ -47,7 +41,7 @@ type Msg struct { db.Msg // In memory only - ExecuteContract *msg.ExecuteContract + DecodedMsg msg.Msg } type Msgs []Msg @@ -57,7 +51,7 @@ func (tms Msgs) GetSimMsgs() client.SimMsgs { for i := range tms { msgs = append(msgs, client.SimMsg{ ID: tms[i].ID, - Msg: tms[i].ExecuteContract, + Msg: tms[i].DecodedMsg, }) } return msgs diff --git a/pkg/terra/utils.go b/pkg/terra/utils.go index 9068c7ebc..1f264f1bd 100644 --- a/pkg/terra/utils.go +++ b/pkg/terra/utils.go @@ -2,11 +2,6 @@ package terra import ( "encoding/hex" - "encoding/json" - "errors" - "reflect" - - "strconv" cosmosSDK "github.com/cosmos/cosmos-sdk/types" @@ -31,47 +26,6 @@ func HexToConfigDigest(s string, digest *types.ConfigDigest) (err error) { return } -// HexToArray process a hex encoded array by splitting -// currently not used, but left in case needed in the future -// `n` specifies the expected length of each element -// `output` is the expected output array -// `postprocess` allows the []byte output to be processed in any way -func HexToArray(s string, n int, output interface{}, parse func([]byte) interface{}) error { - // check to make sure hex encoded 2*n characters - if len(s)%(n*2) != 0 { - return errors.New("invalid string length") - } - - // parse to bytes - var b []byte - if err := HexToByteArray(s, &b); err != nil { - return err - } - - // create new array of parsed values based on `n` elements - arr := reflect.ValueOf(output) // get the array - arr = arr.Elem() // make settable - for i := 0; i < len(b); i += n { - // append values to array + use parse for type conversion - arr = reflect.Append(arr, reflect.ValueOf(parse(b[i:i+n]))) - } - - // writer - writer := reflect.ValueOf(output) // create output writer - writer = writer.Elem() // make settable - writer.Set(arr) // set - return nil -} - -// RawMessageStringIntToInt converts a json string number to an int -func RawMessageStringIntToInt(msg json.RawMessage) (int, error) { - var temp string - if err := json.Unmarshal(msg, &temp); err != nil { - return 0, err - } - return strconv.Atoi(temp) -} - func MustAccAddress(addr string) cosmosSDK.AccAddress { accAddr, err := cosmosSDK.AccAddressFromBech32(addr) if err != nil { diff --git a/pkg/terra/utils_test.go b/pkg/terra/utils_test.go index 766aba249..538b04e34 100644 --- a/pkg/terra/utils_test.go +++ b/pkg/terra/utils_test.go @@ -1,8 +1,6 @@ package terra import ( - "encoding/json" - "strings" "testing" "github.com/smartcontractkit/libocr/offchainreporting2/types" @@ -61,124 +59,3 @@ func TestHexToConfigDigest(t *testing.T) { }) } } - -func TestHexToArray(t *testing.T) { - single := "7465737420636f6e66696720646967657374203332206368617273206c6f6e67" - singleStr := "test config digest 32 chars long" - multiple := []string{single, single, single, single, single, single} - - t.Run("success-single", func(t *testing.T) { - var out [][]byte - err := HexToArray(single, 32, &out, func(b []byte) interface{} { - return b - }) - assert.NoError(t, err) - assert.Equal(t, 1, len(out)) - assert.Equal(t, singleStr, string(out[0])) - }) - - t.Run("success-short", func(t *testing.T) { - var out [][]byte - err := HexToArray(single, 8, &out, func(b []byte) interface{} { - return b - }) - assert.NoError(t, err) - assert.Equal(t, 4, len(out)) - for _, o := range out { - assert.True(t, strings.Contains(singleStr, string(o))) - } - }) - - t.Run("success", func(t *testing.T) { - var out [][]byte - err := HexToArray(strings.Join(multiple, ""), 32, &out, func(b []byte) interface{} { - return b - }) - assert.NoError(t, err) - assert.Equal(t, len(multiple), len(out)) - for _, o := range out { - assert.Equal(t, []byte(singleStr), o) - } - }) - - t.Run("success-string", func(t *testing.T) { - var out []string - err := HexToArray(strings.Join(multiple, ""), 32, &out, func(b []byte) interface{} { - return string(b) - }) - assert.NoError(t, err) - assert.Equal(t, len(multiple), len(out)) - for _, o := range out { - assert.Equal(t, singleStr, o) - } - }) - - t.Run("success-account", func(t *testing.T) { - var out []types.Account - err := HexToArray(strings.Join(multiple, ""), 32, &out, func(b []byte) interface{} { - return types.Account(b) - }) - assert.NoError(t, err) - assert.Equal(t, len(multiple), len(out)) - for _, o := range out { - assert.Equal(t, types.Account(singleStr), o) - } - }) - - t.Run("fail-invalid-length", func(t *testing.T) { - var out [][]byte - err := HexToArray(single[0:62], 32, &out, func(b []byte) interface{} { - return b - }) - assert.EqualError(t, err, "invalid string length") - }) - - t.Run("fail-invalid-char", func(t *testing.T) { - var out [][]byte - err := HexToArray(single[0:63]+"t", 32, &out, func(b []byte) interface{} { - return b - }) - assert.EqualError(t, err, "encoding/hex: invalid byte: U+0074 't'") - }) -} - -func TestRawMessageStringIntToInt(t *testing.T) { - inputs := []struct { - name string - input json.RawMessage - output int - success bool - }{ - { - name: "success", - input: json.RawMessage(`"32"`), - output: 32, - success: true, - }, - { - name: "fail-invalid", - input: json.RawMessage(`"3a"`), - output: 32, - success: false, - }, - { - name: "fail-unmarshal", - input: json.RawMessage(`[]`), - output: 32, - success: false, - }, - } - - for _, i := range inputs { - t.Run(i.name, func(t *testing.T) { - num, err := RawMessageStringIntToInt(i.input) - if !i.success { - assert.Error(t, err) - return - } - - assert.Equal(t, i.output, num) - assert.NoError(t, err) - }) - } -} diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 000000000..2be4e784c --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,3 @@ +# Contents + +- [How To Run E2E Tests](../../docs/RunningE2eTests.md) \ No newline at end of file diff --git a/tests/e2e/chaos/chainlink-relay-terra.yaml b/tests/e2e/chaos/chainlink-relay-terra.yaml index 47628aa2b..ae763c99a 100644 --- a/tests/e2e/chaos/chainlink-relay-terra.yaml +++ b/tests/e2e/chaos/chainlink-relay-terra.yaml @@ -12,8 +12,8 @@ charts: replicas: 5 chainlink: image: - image: "795953128386.dkr.ecr.us-west-2.amazonaws.com/chainlink" - version: "develop.latest" + image: "public.ecr.aws/z0b1w9r9/chainlink" + version: "develop" db: stateful: true capacity: 2Gi diff --git a/tests/e2e/chaos/chaos_test.go b/tests/e2e/chaos/chaos_test.go index 645817624..5ec0ab3e9 100644 --- a/tests/e2e/chaos/chaos_test.go +++ b/tests/e2e/chaos/chaos_test.go @@ -5,23 +5,22 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - tc "github.com/smartcontractkit/chainlink-terra/tests/e2e/common" "github.com/smartcontractkit/chainlink-terra/tests/e2e/smoke/common" "github.com/smartcontractkit/integrations-framework/actions" ) -var _ = Describe("Solana chaos suite", func() { +var _ = Describe("Terra chaos suite", func() { var state = &common.OCRv2State{} BeforeEach(func() { By("Deploying OCRv2 cluster", func() { state.DeployCluster(5, true) state.LabelChaosGroups() - tc.ImitateSource(state.MockServer, common.SourceChangeInterval, 2, 10) + state.SetAllAdapterResponsesToTheSameValue(2) }) }) It("Can tolerate chaos experiments", func() { By("Stable and working", func() { - state.ValidateRoundsAfter(time.Now(), 10) + state.ValidateRoundsAfter(time.Now(), 10, false) }) By("Can work with faulty nodes offline", func() { state.CanWorkWithFaultyNodesOffline() diff --git a/tests/e2e/common/common.go b/tests/e2e/common/common.go index 5c7f90797..fcf09e8b9 100644 --- a/tests/e2e/common/common.go +++ b/tests/e2e/common/common.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "fmt" "math/big" - "net/url" "sort" "strings" "time" @@ -159,17 +158,6 @@ func DefaultOffChainConfigParamsFromNodes(nodes []client.Chainlink) (contracts.O }, nkb, nil } -func ImitateSource(mockServer *client.MockserverClient, changeInterval time.Duration, min int, max int) { - go func() { - for { - _ = mockServer.SetValuePath("/variable", min) - time.Sleep(changeInterval) - _ = mockServer.SetValuePath("/variable", max) - time.Sleep(changeInterval) - } - }() -} - func CreateJobs(ocr2Addr string, nodes []client.Chainlink, nkb []NodeKeysBundle, mock *client.MockserverClient) error { bootstrapPeers := []client.P2PData{ { @@ -179,13 +167,13 @@ func CreateJobs(ocr2Addr string, nodes []client.Chainlink, nkb []NodeKeysBundle, }, } for nIdx, n := range nodes { - var IsBootstrapPeer bool + jobType := "offchainreporting2" if nIdx == 0 { - IsBootstrapPeer = true + jobType = "bootstrap" } sourceValueBridge := client.BridgeTypeAttributes{ Name: "variable", - URL: fmt.Sprintf("%s/variable", mock.Config.ClusterURL), + URL: fmt.Sprintf("%s/node%d", mock.Config.ClusterURL, nIdx), RequestData: "{}", } observationSource := client.ObservationSourceSpecBridge(sourceValueBridge) @@ -224,12 +212,13 @@ func CreateJobs(ocr2Addr string, nodes []client.Chainlink, nkb []NodeKeysBundle, } jobSpec := &client.OCR2TaskJobSpec{ Name: fmt.Sprintf("terra-OCRv2-%d-%s", nIdx, uuid.NewV4().String()), + JobType: jobType, ContractID: ocr2Addr, Relay: ChainName, RelayConfig: relayConfig, P2PPeerID: nkb[nIdx].PeerID, + PluginType: "median", P2PBootstrapPeers: bootstrapPeers, - IsBootstrapPeer: IsBootstrapPeer, OCRKeyBundleID: nkb[nIdx].OCR2Key.Data.ID, TransmitterID: nkb[nIdx].TXKey.Data.ID, ObservationSource: observationSource, @@ -241,17 +230,3 @@ func CreateJobs(ocr2Addr string, nodes []client.Chainlink, nkb []NodeKeysBundle, } return nil } - -// GetDefaultGauntletConfig gets the default config gauntlet will need to start making commands -// against the environment -func GetDefaultGauntletConfig(nodeUrl *url.URL) map[string]string { - networkConfig := map[string]string{ - "NETWORK": "localterra", - "NODE_URL": nodeUrl.String(), - "CHAIN_ID": "localterra", - "DEFAULT_GAS_PRICE": "1", - "MNEMONIC": "satisfy adjust timber high purchase tuition stool faith fine install that you unaware feed domain license impose boss human eager hat rent enjoy dawn", - } - - return networkConfig -} diff --git a/tests/e2e/env.go b/tests/e2e/env.go index d16395b67..e64776d67 100644 --- a/tests/e2e/env.go +++ b/tests/e2e/env.go @@ -22,8 +22,8 @@ func NewChainlinkTerraEnv(nodes int, stateful bool) *environment.Config { "replicas": nodes, "chainlink": map[string]interface{}{ "image": map[string]interface{}{ - "image": "795953128386.dkr.ecr.us-west-2.amazonaws.com/chainlink", - "version": "develop.latest", + "image": "public.ecr.aws/z0b1w9r9/chainlink", + "version": "develop", }, }, "env": map[string]interface{}{ diff --git a/tests/e2e/gauntlet_deployer.go b/tests/e2e/gauntlet_deployer.go new file mode 100644 index 000000000..48ba7f7c4 --- /dev/null +++ b/tests/e2e/gauntlet_deployer.go @@ -0,0 +1,377 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + + . "github.com/onsi/gomega" + "github.com/smartcontractkit/chainlink-terra/tests/e2e/utils" + "github.com/smartcontractkit/integrations-framework/gauntlet" +) + +const TERRA_COMMAND_ERROR = "Terra Command execution error" +const RETRY_COUNT = 5 + +type GauntletDeployer struct { + Cli *gauntlet.Gauntlet + Version string + LinkToken string + BillingAccessController string + RequesterAccessController string + Flags string + DeviationFlaggingValidator string + OCR string + RddPath string + ProposalId string + ProposalDigest string + OffchainProposalSecret string +} + +type InspectionResult struct { + Pass bool + Key string + Expected string + Actual string +} + +// GetDefaultGauntletConfig gets the default config gauntlet will need to start making commands +// against the environment +func GetDefaultGauntletConfig(nodeUrl *url.URL) map[string]string { + networkConfig := map[string]string{ + "NODE_URL": nodeUrl.String(), + "CHAIN_ID": "localterra", + "DEFAULT_GAS_PRICE": "1", + "MNEMONIC": "symbol force gallery make bulk round subway violin worry mixture penalty kingdom boring survey tool fringe patrol sausage hard admit remember broken alien absorb", + } + + return networkConfig +} + +// UpdateReportName updates the report name to be used by gauntlet on completion +func UpdateReportName(reportName string, g *gauntlet.Gauntlet) { + g.NetworkConfig["REPORT_NAME"] = filepath.Join(utils.Reports, reportName) + err := g.WriteNetworkConfigMap(utils.Networks) + Expect(err).ShouldNot(HaveOccurred(), "Failed to write the updated .env file") +} + +// GetInspectionResultsFromOutput parses the inpsectiond data from the output +// TODO we should really update the inspection command to just output json in the future +func GetInspectionResultsFromOutput(output string) (map[string]InspectionResult, error) { + lines := strings.Split(output, "\n") + passRegex, err := regexp.Compile("✅ (.+) matches: (.+)$") + if err != nil { + return map[string]InspectionResult{}, err + } + failRegex, err := regexp.Compile("⚠️ (.+) invalid: expected (.+) but actually (.*)$") + if err != nil { + return map[string]InspectionResult{}, err + } + results := map[string]InspectionResult{} + for _, l := range lines { + passMatches := passRegex.FindStringSubmatch(l) + failMatches := failRegex.FindStringSubmatch(l) + if len(passMatches) == 3 { + results[passMatches[1]] = InspectionResult{ + Pass: true, + Key: passMatches[1], + Expected: "", + Actual: passMatches[2], + } + } else if len(failMatches) == 4 { + results[failMatches[1]] = InspectionResult{ + Pass: false, + Key: failMatches[1], + Expected: failMatches[2], + Actual: failMatches[3], + } + } + } + + return results, nil +} + +// LoadReportJson loads a gauntlet report into a generic map +func LoadReportJson(file string) (map[string]interface{}, error) { + jsonFile, err := os.Open(filepath.Join(utils.Reports, file)) + if err != nil { + return map[string]interface{}{}, err + } + defer jsonFile.Close() + + byteValue, err := ioutil.ReadAll(jsonFile) + if err != nil { + return map[string]interface{}{}, err + } + + var data map[string]interface{} + err = json.Unmarshal([]byte(byteValue), &data) + + return data, err +} + +// GetTxAddressFromReport gets the address from the typical place in the json report data +func GetTxAddressFromReport(report map[string]interface{}) string { + return report["responses"].([]interface{})[0].(map[string]interface{})["tx"].(map[string]interface{})["address"].(string) +} + +// DeployToken deploys the link token +func (gd *GauntletDeployer) DeployToken() string { + codeIds := gd.Cli.Flag("codeIDs", filepath.Join(utils.CodeIds, fmt.Sprintf("%s%s", gd.Cli.Network, ".json"))) + artifacts := gd.Cli.Flag("artifacts", filepath.Join(utils.GauntletTerraContracts, "artifacts", "bin")) + reportName := "deploy_token" + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "token:deploy", + gd.Cli.Flag("version", gd.Version), + codeIds, + artifacts, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to deploy link token") + report, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + return GetTxAddressFromReport(report) +} + +// Upload uploads the terra contracts +func (gd *GauntletDeployer) Upload() { + UpdateReportName("upload", gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "upload", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("maxRetry", "10"), + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to upload contracts") +} + +// deployAccessController deploys an access controller +func (gd *GauntletDeployer) deployAccessController(name string) string { + codeIds := gd.Cli.Flag("codeIDs", filepath.Join(utils.CodeIds, fmt.Sprintf("%s%s", gd.Cli.Network, ".json"))) + UpdateReportName(name, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "access_controller:deploy", + gd.Cli.Flag("version", gd.Version), + codeIds, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to deploy the billing access controller") + report, err := LoadReportJson(name + ".json") + Expect(err).ShouldNot(HaveOccurred()) + return GetTxAddressFromReport(report) +} + +// DeployBillingAccessController deploys a biller +func (gd *GauntletDeployer) DeployBillingAccessController() string { + billingAccessController := gd.deployAccessController("billing_ac_deploy") + gd.Cli.NetworkConfig["BILLING_ACCESS_CONTROLLER"] = billingAccessController + return billingAccessController +} + +// DeployRequesterAccessController deploys a requester +func (gd *GauntletDeployer) DeployRequesterAccessController() string { + requesterAccessController := gd.deployAccessController("requester_ac_deploy") + gd.Cli.NetworkConfig["REQUESTER_ACCESS_CONTROLLER"] = requesterAccessController + return requesterAccessController +} + +// DeployFlags deploys the flags for the lowering and raising access controllers +func (gd *GauntletDeployer) DeployFlags(billingAccessController, requesterAccessController string) string { + reportName := "flags_deploy" + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "flags:deploy", + gd.Cli.Flag("loweringAccessController", billingAccessController), + gd.Cli.Flag("raisingAccessController", requesterAccessController), + gd.Cli.Flag("version", gd.Version), + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to deploy the flag") + flagsReport, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + flags := GetTxAddressFromReport(flagsReport) + return flags +} + +// DeployDeviationFlaggingValidator deploys the deviation flagging validator with the threshold provided +func (gd *GauntletDeployer) DeployDeviationFlaggingValidator(flags string, flaggingThreshold int) string { + reportName := "dfv_deploy" + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "deviation_flagging_validator:deploy", + gd.Cli.Flag("flaggingThreshold", fmt.Sprintf("%v", uint32(flaggingThreshold))), + gd.Cli.Flag("flags", flags), + gd.Cli.Flag("version", gd.Version), + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to deploy the deviation flagging validator") + dfvReport, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + return GetTxAddressFromReport(dfvReport) +} + +// DeployOcr deploys ocr, it creates an rdd file in the process and updates it with the ocr address on completion +func (gd *GauntletDeployer) DeployOcr() (string, string) { + rddPath := filepath.Join(utils.Rdd, fmt.Sprintf("directory-terra-%s.json", gd.Cli.Network)) + tmpId := "terra1test0000000000000000000000000000000000" + ocrRddContract := NewRddContract(tmpId) + err := WriteRdd(ocrRddContract, rddPath) + Expect(err).ShouldNot(HaveOccurred(), "Did not write the rdd json correctly") + reportName := "ocr_deploy" + UpdateReportName(reportName, gd.Cli) + _, err = gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:deploy", + gd.Cli.Flag("rdd", rddPath), + gd.Cli.Flag("version", gd.Version), + tmpId, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to deploy ocr2") + ocrReport, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + ocr := GetTxAddressFromReport(ocrReport) + + // add the new contract to the rdd + ocrRddContract.Contracts[ocr] = ocrRddContract.Contracts[tmpId] + err = WriteRdd(ocrRddContract, rddPath) + Expect(err).ShouldNot(HaveOccurred(), "Did not write the rdd json correctly") + return ocr, rddPath +} + +// SetBiling sets the billing info that exists in the rdd file for the ocr address you pass in +func (gd *GauntletDeployer) SetBilling(ocr, rddPath string) { + UpdateReportName("set_billing", gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:set_billing", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("rdd", rddPath), + ocr, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to set billing") +} + +// BeginProposal begins the proposal +func (gd *GauntletDeployer) BeginProposal(ocr, rddPath string) string { + reportName := "begin_proposal" + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:begin_proposal", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("rdd", rddPath), + ocr, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to begin proposal") + beginProposalReport, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + return beginProposalReport["data"].(map[string]interface{})["proposalId"].(string) +} + +// ProposeConfig proposes the config +func (gd *GauntletDeployer) ProposeConfig(ocr, proposalId, rddPath string) { + reportName := "propose_config" + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:propose_config", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("rdd", rddPath), + gd.Cli.Flag("proposalId", proposalId), + gd.OCR, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to propose config") +} + +// ProposeOffchainConfig proposes the offchain config +func (gd *GauntletDeployer) ProposeOffchainConfig(ocr, proposalId, rddPath string) string { + reportName := "propose_offchain_config" + gd.Cli.NetworkConfig["SECRET"] = gd.Cli.NetworkConfig["MNEMONIC"] + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:propose_offchain_config", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("rdd", rddPath), + gd.Cli.Flag("proposalId", proposalId), + ocr, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to propose offchain config") + offchainProposalReport, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + return offchainProposalReport["data"].(map[string]interface{})["randomSecret"].(string) +} + +// FinalizeProposal finalizes the proposal +func (gd *GauntletDeployer) FinalizeProposal(ocr, proposalId, rddPath string) string { + reportName := "finalize_proposal" + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:finalize_proposal", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("rdd", rddPath), + gd.Cli.Flag("proposalId", proposalId), + ocr, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to finalize proposal") + finalizeProposalReport, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + return finalizeProposalReport["data"].(map[string]interface{})["digest"].(string) +} + +// AcceptProposal accepts the proposal +func (gd *GauntletDeployer) AcceptProposal(ocr, proposalId, proposalDigest, secret, rddPath string) string { + reportName := "accept_proposal" + UpdateReportName(reportName, gd.Cli) + _, err := gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:accept_proposal", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("rdd", rddPath), + gd.Cli.Flag("proposalId", proposalId), + gd.Cli.Flag("digest", proposalDigest), + gd.Cli.Flag("secret", secret), + ocr, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to accept proposal") + acceptProposalReport, err := LoadReportJson(reportName + ".json") + Expect(err).ShouldNot(HaveOccurred()) + return acceptProposalReport["data"].(map[string]interface{})["digest"].(string) +} + +// OcrInspect gets the inspections results data +func (gd *GauntletDeployer) OcrInspect(ocr, rddPath string) map[string]InspectionResult { + UpdateReportName("inspect", gd.Cli) + output, err := gd.Cli.ExecCommandWithRetries([]string{ + "ocr2:inspect", + gd.Cli.Flag("version", gd.Version), + gd.Cli.Flag("rdd", rddPath), + ocr, + }, []string{ + TERRA_COMMAND_ERROR, + }, RETRY_COUNT) + Expect(err).ShouldNot(HaveOccurred(), "Failed to inspect") + + results, err := GetInspectionResultsFromOutput(output) + Expect(err).ShouldNot(HaveOccurred()) + return results +} diff --git a/tests/e2e/migration/README.md b/tests/e2e/migration/README.md new file mode 100644 index 000000000..23da1df8a --- /dev/null +++ b/tests/e2e/migration/README.md @@ -0,0 +1,4 @@ +### Migration test +```shell +CHAINLINK_IMAGE="${REGISTRY}" CHAINLINK_VERSION="${IMAGE_SHA}" CHAINLINK_IMAGE_TO="${REGISTRY}" CHAINLINK_VERSION_TO="${IMAGE_SHA}" make test_migration +``` \ No newline at end of file diff --git a/tests/e2e/migration/ocr2_spec_migration_test.go b/tests/e2e/migration/ocr2_spec_migration_test.go new file mode 100644 index 000000000..7cef88c27 --- /dev/null +++ b/tests/e2e/migration/ocr2_spec_migration_test.go @@ -0,0 +1,50 @@ +package migration_test + +import ( + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + tc "github.com/smartcontractkit/chainlink-terra/tests/e2e/smoke/common" + "github.com/smartcontractkit/integrations-framework/actions" +) + +var _ = Describe("Terra OCRv2 @ocr-spec-migration", func() { + var state *tc.OCRv2State + var nodes = 5 + var rounds = 5 + var migrateToImage string + var migrateToVersion string + + BeforeEach(func() { + state = &tc.OCRv2State{} + By("Deploying the cluster", func() { + migrateToImage = os.Getenv("CHAINLINK_IMAGE_TO") + if migrateToImage == "" { + Fail("Provide CHAINLINK_IMAGE_TO variable: an image on which we migrate") + } + migrateToVersion = os.Getenv("CHAINLINK_VERSION_TO") + if migrateToVersion == "" { + Fail("Provide CHAINLINK_VERSION_TO variable: a version on which we migrate") + } + state.DeployCluster(nodes, true) + state.SetAllAdapterResponsesToTheSameValue(2) + }) + }) + + Describe("with Terra OCR2", func() { + It("performs OCR2 round", func() { + state.ValidateRoundsAfter(time.Now(), rounds, false) + state.UpdateChainlinkVersion(migrateToImage, migrateToVersion) + state.ValidateRoundsAfter(time.Now(), rounds, false) + }) + }) + + AfterEach(func() { + By("Tearing down the environment", func() { + err := actions.TeardownSuite(state.Env, nil, "logs", nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) +}) diff --git a/tests/e2e/migration/suite_test.go b/tests/e2e/migration/suite_test.go new file mode 100644 index 000000000..e8b04a25f --- /dev/null +++ b/tests/e2e/migration/suite_test.go @@ -0,0 +1,14 @@ +package migration_test + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-terra/tests/e2e/utils" + + . "github.com/onsi/ginkgo/v2" +) + +func Test_Suite(t *testing.T) { + utils.GinkgoSuite() + RunSpecs(t, "Migration") +} diff --git a/tests/e2e/ocr2_proxy.go b/tests/e2e/ocr2_proxy.go index 548a5b818..762a3e05b 100644 --- a/tests/e2e/ocr2_proxy.go +++ b/tests/e2e/ocr2_proxy.go @@ -1,6 +1,14 @@ package e2e import ( + "context" + "encoding/json" + "strconv" + + "github.com/rs/zerolog/log" + "github.com/smartcontractkit/chainlink-terra/tests/e2e/ocr2proxytypes" + "github.com/smartcontractkit/chainlink-terra/tests/e2e/ocr2types" + terraClient "github.com/smartcontractkit/terra.go/client" "github.com/smartcontractkit/terra.go/msg" ) @@ -14,13 +22,104 @@ func (m *OCRv2Proxy) Address() string { } func (m *OCRv2Proxy) ProposeContract(addr string) error { - panic("implement me") + executeMsg := ocr2proxytypes.ProposeContractMsg{ + ContractAddress: addr, + } + executeMsgBytes, err := json.Marshal(executeMsg) + if err != nil { + return err + } + return m.send(executeMsgBytes) } func (m *OCRv2Proxy) ConfirmContract(addr string) error { - panic("implement me") + executeMsg := ocr2proxytypes.ConfirmContractMsg{ + ContractAddress: addr, + } + executeMsgBytes, err := json.Marshal(executeMsg) + if err != nil { + return err + } + return m.send(executeMsgBytes) +} + +func (m *OCRv2Proxy) TransferOwnership(to string) error { + executeMsg := ocr2proxytypes.TransferOwnershipMsg{ + ToAddress: to, + } + executeMsgBytes, err := json.Marshal(executeMsg) + if err != nil { + return err + } + return m.send(executeMsgBytes) +} + +func (m *OCRv2Proxy) send(executeMsgBytes []byte) error { + sender := m.client.DefaultWallet.AccAddress + _, err := m.client.SendTX(terraClient.CreateTxOptions{ + Msgs: []msg.Msg{ + msg.NewMsgExecuteContract( + sender, + m.address, + executeMsgBytes, + msg.NewCoins(), + ), + }, + }, true) + return err +} + +func (m *OCRv2Proxy) GetLatestRoundData() (uint64, uint64, uint64, error) { + resp := ocr2types.QueryLatestRoundDataResponse{} + log.Warn().Interface("Addr", m.address) + if err := m.client.QuerySmart(context.Background(), m.address, ocr2types.QueryLatestRoundData, &resp); err != nil { + return 0, 0, 0, err + } + answer, _ := strconv.Atoi(resp.QueryResult.Answer) + return uint64(answer), resp.QueryResult.TransmissionTimestamp, resp.QueryResult.RoundID, nil +} + +func (m *OCRv2Proxy) GetRoundData(roundID uint32) (map[string]interface{}, error) { + resp := make(map[string]interface{}) + if err := m.client.QuerySmart( + context.Background(), + m.address, + ocr2types.QueryRoundDataMsg{ + RoundData: ocr2types.QueryRoundDataTypeMsg{ + RoundID: roundID, + }, + }, + &resp, + ); err != nil { + return nil, err + } + return resp, nil +} + +func (m *OCRv2Proxy) GetDecimals() (int, error) { + resp := make(map[string]int) + if err := m.client.QuerySmart( + context.Background(), + m.address, + ocr2types.QueryDecimals, + &resp, + ); err != nil { + return 0, err + } + log.Info().Interface("Decimals response", resp).Msg("The decimals from the proxy") + return resp["query_result"], nil } -func (m *OCRv2Proxy) TransferOwnership(addr string) error { - panic("implement me") +func (m *OCRv2Proxy) GetDescription() (string, error) { + resp := make(map[string]string) + if err := m.client.QuerySmart( + context.Background(), + m.address, + ocr2types.QueryDescription, + &resp, + ); err != nil { + return "", err + } + log.Info().Interface("Description response", resp).Msg("The description from the proxy") + return resp["query_result"], nil } diff --git a/tests/e2e/ocr2proxytypes/proxy.go b/tests/e2e/ocr2proxytypes/proxy.go index f4685c99a..34aef05b4 100644 --- a/tests/e2e/ocr2proxytypes/proxy.go +++ b/tests/e2e/ocr2proxytypes/proxy.go @@ -3,3 +3,23 @@ package ocr2proxytypes type InstantiateMsg struct { ContractAddress string `json:"contract_address"` } + +type ProposeContractMsg struct { + ContractAddress string `json:"propose_contract"` +} + +type ConfirmContractMsg struct { + ContractAddress string `json:"confirm_contract"` +} + +type TransferOwnershipMsg struct { + ToAddress string `json:"transfer_ownership"` +} + +type ContractAddress struct { + Address string `json:"address"` +} + +type ToAddress struct { + To string `json:"to"` +} diff --git a/tests/e2e/rdd.go b/tests/e2e/rdd.go new file mode 100644 index 000000000..06c306c46 --- /dev/null +++ b/tests/e2e/rdd.go @@ -0,0 +1,167 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/rs/zerolog/log" + "github.com/smartcontractkit/terra.go/key" + "github.com/smartcontractkit/terra.go/msg" +) + +type RddConfig struct { + Apis map[string]interface{} `json:"apis"` + Contracts map[string]interface{} `json:"contracts"` + Flags map[string]interface{} `json:"flags"` + Network map[string]interface{} `json:"network"` + Operators map[string]interface{} `json:"operators"` + Proxies map[string]interface{} `json:"proxies"` + Validators map[string]interface{} `json:"validators"` +} + +// WriteRdd writes the rdd data to a file +func WriteRdd(rdd *RddConfig, file string) error { + f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer f.Close() + + j, err := json.Marshal(rdd) + if err != nil { + return err + } + log.Info().Str("Out", string(j)).Msg("The stuff we are writing") + + _, err = f.Write(j) + + return err +} + +// NewChainlinkTerraEnv returns a cluster config with LocalTerra node +func NewRddContract(contractId string) *RddConfig { + rdd := &RddConfig{ + Apis: map[string]interface{}{}, + Contracts: map[string]interface{}{ + contractId: map[string]interface{}{ + "billing": map[string]interface{}{ + "observationPaymentGjuels": "1", + "recommendedGasPriceMicro": "1.1", + "transmissionPaymentGjuels": "1", + }, + "config": map[string]interface{}{ + "deltaGrace": "1s", + "deltaProgress": "12s", + "deltaResend": "30s", + "deltaRound": "10s", + "deltaStage": "14s", + "f": 1, + "maxDurationObservation": "1s", + "maxDurationQuery": "1s", + "maxDurationReport": "5s", + "maxDurationShouldAcceptFinalizedReport": "1s", + "maxDurationShouldTransmitAcceptedReport": "1s", + "rMax": 6, + "reportingPluginConfig": map[string]interface{}{ + "alphaAcceptInfinite": true, + "alphaAcceptPpb": "3000000", + "alphaReportInfinite": true, + "alphaReportPpb": "3000000", + "deltaC": "50s", + }, + "s": []int{ + 1, + 1, + 2, + 2, + }, + }, + "contractVersion": 6, + "decimals": 8, + "docsHidden": true, + "externalAdapterRequestParams": map[string]interface{}{ + "from": "ETH", + "to": "USD", + }, + "marketing": map[string]interface{}{ + "decimalPlaces": 2, + "formatDecimalPlaces": 0, + "history": true, + "pair": []string{ + "ETH", + "USD", + }, + "path": "eth-usd-ocr2", + }, + "maxSubmissionValue": "99999999999999999999999999999", + "minSubmissionValue": "0", + "name": "ETH / USD", + "oracles": []interface{}{ + newOracle("node-1"), + newOracle("node-2"), + newOracle("node-3"), + newOracle("node-4"), + }, + "status": "live", + "type": "numerical_median_feed", + }, + }, + Flags: map[string]interface{}{}, + Network: map[string]interface{}{}, + Operators: map[string]interface{}{ + "node-1": newOperator("node-1", 1), + "node-2": newOperator("node-2", 2), + "node-3": newOperator("node-3", 3), + "node-4": newOperator("node-4", 4), + }, + Proxies: map[string]interface{}{}, + Validators: map[string]interface{}{}, + } + return rdd +} + +func newOperator(nodeName string, index int) map[string]interface{} { + // create a public key node address + mnemonic, _ := key.CreateMnemonic() + privKeyBz, _ := key.DerivePrivKeyBz(mnemonic, key.CreateHDPath(0, 0)) + privKey, _ := key.PrivKeyGen(privKeyBz) + addr := msg.AccAddress(privKey.PubKey().Address()) + + return map[string]interface{}{ + "displayName": nodeName, + "adminAddress": "terra1mskaupg53dc8jh50nstcjmctm4sud9fc2t8rjn", + "csaKeys": []interface{}{ + map[string]interface{}{ + "nodeAddress": "terra1mskaupg53dc8jh50nstcjmctm4sud9fc2t8rjn", + "nodeName": "node 1", + "publicKey": fmt.Sprintf("c880f65f9e2118063c1e61b5f54c84c80651f2b8a367f46d3dbfbad4966c7f8%v", index), + }, + }, + "ocr2ConfigPublicKey": []interface{}{ + fmt.Sprintf("ocr2cfg_terra_b90e50daf82024624549e7708199dd05b6de8e10d6df62cd27581c65e5096b2%v", index), + }, + "ocr2OffchainPublicKey": []interface{}{ + fmt.Sprintf("ocr2off_terra_3bdd39af448a824cb6042b981274baf26f7501f2918ae825afc51a2442ef699%v", index), + }, + "ocr2OnchainPublicKey": []interface{}{ + fmt.Sprintf("ocr2on_terra_9c41de50e875fbca65643ffe60f90e84f1b9b4871092b7c3cbf4eff4b07e454%v", index), + }, + "ocrNodeAddress": []interface{}{ + addr, + }, + "peerId": []interface{}{ + fmt.Sprintf("12D3KooWHzGXm2NSRgYcn6B3szqfEr486kq2ipAEPXdqmE6nE2a%v", index), + }, + "status": "active", + } +} + +func newOracle(nodeName string) map[string]interface{} { + return map[string]interface{}{ + "api": []string{ + nodeName, + }, + "operator": nodeName, + } +} diff --git a/tests/e2e/smoke/common/common.go b/tests/e2e/smoke/common/common.go index 1a38cce57..cdf6e25bf 100644 --- a/tests/e2e/smoke/common/common.go +++ b/tests/e2e/smoke/common/common.go @@ -2,6 +2,7 @@ package common import ( "encoding/json" + "fmt" "math/big" "os" "time" @@ -159,10 +160,25 @@ func (m *OCRv2State) DeployContracts() { Expect(m.Err).ShouldNot(HaveOccurred()) } +func (m *OCRv2State) SetAllAdapterResponsesToTheSameValue(response int) { + for i := range m.Nodes { + path := fmt.Sprintf("/node%d", i) + m.Err = m.MockServer.SetValuePath(path, response) + Expect(m.Err).ShouldNot(HaveOccurred()) + } +} + +func (m *OCRv2State) SetAllAdapterResponsesToDifferentValues(responses []int) { + Expect(len(responses)).Should(BeNumerically("==", len(m.Nodes))) + for i := range m.Nodes { + m.Err = m.MockServer.SetValuePath(fmt.Sprintf("/node%d", i), responses[i]) + Expect(m.Err).ShouldNot(HaveOccurred()) + } +} + // CreateJobs creating OCR jobs and EA stubs func (m *OCRv2State) CreateJobs() { - m.Err = m.MockServer.SetValuePath("/variable", 5) - Expect(m.Err).ShouldNot(HaveOccurred()) + m.SetAllAdapterResponsesToTheSameValue(5) m.Err = m.MockServer.SetValuePath("/juels", 1) Expect(m.Err).ShouldNot(HaveOccurred()) m.Err = common.CreateJobs(m.OCR2.Address(), m.Nodes, m.NodeKeysBundle, m.MockServer) @@ -190,6 +206,21 @@ func (m *OCRv2State) LoadContracts() error { return nil } +func (m *OCRv2State) UpdateChainlinkVersion(image string, version string) { + chart, err := m.Env.Charts.Get("chainlink") + Expect(err).ShouldNot(HaveOccurred()) + chart.Values["chainlink"] = map[string]interface{}{ + "image": map[string]interface{}{ + "image": image, + "version": version, + }, + } + err = chart.Upgrade() + Expect(err).ShouldNot(HaveOccurred()) + err = m.Env.ConnectAll() + Expect(err).ShouldNot(HaveOccurred()) +} + // DumpContracts dumps contracts to a file func (m *OCRv2State) DumpContracts() error { s := &ContractsAddresses{OCR: m.OCR2.Address()} @@ -213,11 +244,17 @@ func (m *OCRv2State) ValidateNoRoundsAfter(chaosStartTime time.Time) { } // ValidateRoundsAfter validates there are new rounds after some point in time -func (m *OCRv2State) ValidateRoundsAfter(chaosStartTime time.Time, rounds int) { +func (m *OCRv2State) ValidateRoundsAfter(chaosStartTime time.Time, rounds int, throughProxy bool) { m.RoundsFound = 0 m.LastRoundTime = chaosStartTime Eventually(func(g Gomega) { - answer, timestamp, roundID, err := m.OCR2.GetLatestRoundData() + var answer, timestamp, roundID uint64 + var err error + if throughProxy { + answer, timestamp, roundID, err = m.OCR2Proxy.GetLatestRoundData() + } else { + answer, timestamp, roundID, err = m.OCR2.GetLatestRoundData() + } g.Expect(err).ShouldNot(HaveOccurred()) roundTime := time.Unix(int64(timestamp), 0) g.Expect(roundTime.After(m.LastRoundTime)).Should(BeTrue()) diff --git a/tests/e2e/smoke/common/experiments.go b/tests/e2e/smoke/common/experiments.go index 0b91193d6..c85e43ba6 100644 --- a/tests/e2e/smoke/common/experiments.go +++ b/tests/e2e/smoke/common/experiments.go @@ -45,7 +45,7 @@ func (m *OCRv2State) CanRecoverAllNodesValidatorConnectionLoss() { time.Sleep(ChaosAwaitingApply) err = m.Env.ClearAllChaosExperiments() Expect(err).ShouldNot(HaveOccurred()) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } func (m *OCRv2State) CanWorkYellowGroupNoValidatorConnection() { @@ -63,7 +63,7 @@ func (m *OCRv2State) CanWorkYellowGroupNoValidatorConnection() { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } func (m *OCRv2State) CantWorkWithFaultyNodesFailed() { @@ -97,7 +97,7 @@ func (m *OCRv2State) CanWorkWithFaultyNodesOffline() { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } func (m *OCRv2State) CantWorkWithMoreThanFaultyNodesOffline() { @@ -115,7 +115,7 @@ func (m *OCRv2State) CantWorkWithMoreThanFaultyNodesOffline() { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } func (m *OCRv2State) NetworkCorrupt(group string, corrupt int, rounds int) { @@ -133,7 +133,7 @@ func (m *OCRv2State) NetworkCorrupt(group string, corrupt int, rounds int) { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), rounds) + m.ValidateRoundsAfter(time.Now(), rounds, false) } func (m *OCRv2State) CanWorkAfterAllOraclesIPChange() { @@ -148,7 +148,7 @@ func (m *OCRv2State) CanWorkAfterAllOraclesIPChange() { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } func (m *OCRv2State) CanMigrateBootstrap() { @@ -164,7 +164,7 @@ func (m *OCRv2State) CanMigrateBootstrap() { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) // now we working without bootstrap, killing all oracles except one, remaining one must bootstrap _, err = m.Env.ApplyChaosExperiment( &experiments.PodKill{ @@ -175,7 +175,7 @@ func (m *OCRv2State) CanMigrateBootstrap() { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } func (m *OCRv2State) RestoredAfterNetworkSplit() { @@ -205,7 +205,7 @@ func (m *OCRv2State) RestoredAfterNetworkSplit() { m.ValidateNoRoundsAfter(time.Now()) err = m.Env.ClearAllChaosExperiments() Expect(err).ShouldNot(HaveOccurred()) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } func (m *OCRv2State) CanWorkWithTimeSkewYellowGroup() { @@ -223,5 +223,5 @@ func (m *OCRv2State) CanWorkWithTimeSkewYellowGroup() { ) Expect(err).ShouldNot(HaveOccurred()) time.Sleep(ChaosAwaitingApply) - m.ValidateRoundsAfter(time.Now(), 10) + m.ValidateRoundsAfter(time.Now(), 10, false) } diff --git a/tests/e2e/smoke/gauntlet_test.go b/tests/e2e/smoke/gauntlet_test.go index cb8657536..b42e06313 100644 --- a/tests/e2e/smoke/gauntlet_test.go +++ b/tests/e2e/smoke/gauntlet_test.go @@ -1,6 +1,7 @@ package smoke_test import ( + "fmt" "math/big" "os" "path/filepath" @@ -9,89 +10,123 @@ import ( . "github.com/onsi/gomega" "github.com/smartcontractkit/chainlink-terra/tests/e2e" "github.com/smartcontractkit/chainlink-terra/tests/e2e/common" + tc "github.com/smartcontractkit/chainlink-terra/tests/e2e/smoke/common" "github.com/smartcontractkit/chainlink-terra/tests/e2e/utils" "github.com/smartcontractkit/helmenv/environment" - "github.com/smartcontractkit/helmenv/tools" "github.com/smartcontractkit/integrations-framework/actions" - "github.com/smartcontractkit/integrations-framework/client" "github.com/smartcontractkit/integrations-framework/gauntlet" ) var _ = Describe("Terra Gauntlet @gauntlet", func() { var ( - e *environment.Environment - g *gauntlet.Gauntlet - nodes []client.Chainlink - nets *client.Networks - nkb []common.NodeKeysBundle - err error - networkDirPath string + gd *e2e.GauntletDeployer + state *tc.OCRv2State ) - terraCommandError := "Terra Command execution error" - BeforeEach(func() { By("Deploying the environment", func() { - e, err = environment.DeployOrLoadEnvironment( - e2e.NewChainlinkTerraEnv(1, false), - tools.ChartsRoot, - ) - Expect(err).ShouldNot(HaveOccurred()) - err = e.ConnectAll() - Expect(err).ShouldNot(HaveOccurred()) - }) - By("Setting up client", func() { - networkRegistry := client.NewNetworkRegistry() - networkRegistry.RegisterNetwork( - "terra", - e2e.ClientInitFunc(), - e2e.ClientURLSFunc(), - ) - nets, err = networkRegistry.GetNetworks(e) - Expect(err).ShouldNot(HaveOccurred()) - nodes, err = client.ConnectChainlinkNodes(e) - Expect(err).ShouldNot(HaveOccurred()) - }) - By("Funding Wallets", func() { - _, nkb, err = common.DefaultOffChainConfigParamsFromNodes(nodes) - Expect(err).ShouldNot(HaveOccurred()) + gd = &e2e.GauntletDeployer{ + Version: "local", + } + state = &tc.OCRv2State{} + state.DeployEnv(1, false) + state.SetupClients() + if state.Nets.Default.ContractsDeployed() { + err := state.LoadContracts() + Expect(err).ShouldNot(HaveOccurred()) + } + + state.OCConfig, state.NodeKeysBundle, state.Err = common.DefaultOffChainConfigParamsFromNodes(state.Nodes) + Expect(state.Err).ShouldNot(HaveOccurred()) - err = common.FundOracles(nets.Default, nkb, big.NewFloat(5e12)) + // Remove the stuff below when the token:deploy command is fixed to work for automated testing + cd := e2e.NewTerraContractDeployer(state.Nets.Default) + linkToken, err := cd.DeployLinkTokenContract() + Expect(err).ShouldNot(HaveOccurred(), "Failed to deploy link token") + gd.LinkToken = linkToken.Address() + err = common.FundOracles(state.Nets.Default, state.NodeKeysBundle, big.NewFloat(5e12)) Expect(err).ShouldNot(HaveOccurred()) + // }) By("Setup Gauntlet", func() { - networkDirPath = filepath.Join(utils.ProjectRoot, "./packages-ts/gauntlet-terra-contracts/networks") - cwd, _ := os.Getwd() + cwd, err := os.Getwd() + Expect(err).ShouldNot(HaveOccurred(), "Failed to get the working directory") err = os.Chdir(filepath.Join(cwd + "../../../..")) Expect(err).ShouldNot(HaveOccurred()) - g, err = gauntlet.NewGauntlet() + gd.Cli, err = gauntlet.NewGauntlet() Expect(err).ShouldNot(HaveOccurred()) - terraNodeUrl, err := e.Charts.Connections("localterra").LocalURLByPort("lcd", environment.HTTP) + terraNodeUrl, err := state.Env.Charts.Connections("localterra").LocalURLByPort("lcd", environment.HTTP) Expect(err).ShouldNot(HaveOccurred()) - g.NetworkConfig = common.GetDefaultGauntletConfig(terraNodeUrl) - err = g.WriteNetworkConfigMap(networkDirPath) + gd.Cli.NetworkConfig = e2e.GetDefaultGauntletConfig(terraNodeUrl) + err = gd.Cli.WriteNetworkConfigMap(utils.Networks) Expect(err).ShouldNot(HaveOccurred(), "failed to write the .env file") + gd.Cli.NetworkConfig["LINK"] = gd.LinkToken }) }) Describe("Run Gauntlet Commands", func() { - It("should upload the contracts", func() { - _, err = g.ExecCommandWithRetries([]string{ - "upload", - g.Flag("version", "local"), - g.Flag("maxRetry", "10"), - }, []string{ - terraCommandError, - }, 5) - Expect(err).ShouldNot(HaveOccurred(), "Failed to upload contracts") + It("should deploy ocr and accept a proposal", func() { + // upload artifacts + gd.Upload() + + // Uncomment the below when token:deploy command is fixed for automated testing + // token:deploy + // gd.LinkToken = gd.DeployToken() + // gd.Cli.NetworkConfig["LINK"] = gd.LinkToken + // err := common.FundOracles(state.Nets.Default, state.NodeKeysBundle, big.NewFloat(5e12)) + // Expect(err).ShouldNot(HaveOccurred()) + // + + // deploy access controllers + gd.BillingAccessController = gd.DeployBillingAccessController() + gd.RequesterAccessController = gd.DeployRequesterAccessController() + + // write the updated values for link and access controllers to the .env file + err := gd.Cli.WriteNetworkConfigMap(utils.Networks) + Expect(err).ShouldNot(HaveOccurred(), "Failed to write the updated .env file") + + // flags:deploy + gd.Flags = gd.DeployFlags(gd.BillingAccessController, gd.RequesterAccessController) + + // deviation_flagging_validator:deploy + gd.DeviationFlaggingValidator = gd.DeployDeviationFlaggingValidator(gd.Flags, 8000) + + // ocr2:deploy + gd.OCR, gd.RddPath = gd.DeployOcr() + + // ocr2:set_billing + gd.SetBilling(gd.OCR, gd.RddPath) + + // ocr2:begin_proposal + gd.ProposalId = gd.BeginProposal(gd.OCR, gd.RddPath) + + // ocr2:propose_config + gd.ProposeConfig(gd.OCR, gd.ProposalId, gd.RddPath) + + // ocr2:propose_offchain_config + gd.OffchainProposalSecret = gd.ProposeOffchainConfig(gd.OCR, gd.ProposalId, gd.RddPath) + + // ocr2:finalize_proposal + gd.ProposalDigest = gd.FinalizeProposal(gd.OCR, gd.ProposalId, gd.RddPath) + + // ocr2:accept_proposal + gd.AcceptProposal(gd.OCR, gd.ProposalId, gd.ProposalDigest, gd.OffchainProposalSecret, gd.RddPath) + + // ocr2:inspect + results := gd.OcrInspect(gd.OCR, gd.RddPath) + Expect(len(results)).Should(Equal(28), "Did not find the expected number of results in the output") + for _, v := range results { + Expect(v.Pass).Should(Equal(true), fmt.Sprintf("%s expected %s but actually %s", v.Key, v.Expected, v.Actual)) + + } }) }) AfterEach(func() { By("Tearing down the environment", func() { - err = actions.TeardownSuite(e, nil, "logs", nil) + err := actions.TeardownSuite(state.Env, nil, "logs", nil) Expect(err).ShouldNot(HaveOccurred()) }) }) diff --git a/tests/e2e/smoke/ocr2_proxy_test.go b/tests/e2e/smoke/ocr2_proxy_test.go new file mode 100644 index 000000000..46060a38e --- /dev/null +++ b/tests/e2e/smoke/ocr2_proxy_test.go @@ -0,0 +1,56 @@ +package smoke_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/smartcontractkit/chainlink-terra/tests/e2e" + tc "github.com/smartcontractkit/chainlink-terra/tests/e2e/smoke/common" + "github.com/smartcontractkit/integrations-framework/actions" +) + +var _ = Describe("Terra OCRv2 Proxy @ocr_proxy", func() { + var state *tc.OCRv2State + + BeforeEach(func() { + state = &tc.OCRv2State{} + By("Deploying the cluster", func() { + state.DeployCluster(5, false) + state.SetAllAdapterResponsesToTheSameValue(2) + }) + }) + + Describe("with Terra OCR2 Proxy", func() { + It("performs OCR2 round through proxy", func() { + expectedDecimals := 8 + expectedDescription := "ETH/USD" + + cd := e2e.NewTerraContractDeployer(state.Nets.Default) + + // deploy the proxy pointing at the ocr2 address + state.OCR2Proxy, state.Err = cd.DeployOCRv2Proxy(state.OCR2.Address()) + Expect(state.Err).ShouldNot(HaveOccurred()) + + // latestRoundData + state.ValidateRoundsAfter(time.Now(), 10, true) + + // decimals + dec, err := state.OCR2Proxy.GetDecimals() + Expect(err).ShouldNot(HaveOccurred()) + Expect(dec).Should(Equal(expectedDecimals)) + + // description + desc, err := state.OCR2Proxy.GetDescription() + Expect(err).ShouldNot(HaveOccurred()) + Expect(desc).Should(Equal(expectedDescription)) + }) + }) + + AfterEach(func() { + By("Tearing down the environment", func() { + err := actions.TeardownSuite(state.Env, nil, "logs", nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) +}) diff --git a/tests/e2e/smoke/ocr2_test.go b/tests/e2e/smoke/ocr2_test.go index 525c7f5c8..c68783ca2 100644 --- a/tests/e2e/smoke/ocr2_test.go +++ b/tests/e2e/smoke/ocr2_test.go @@ -5,25 +5,24 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/smartcontractkit/chainlink-terra/tests/e2e/common" tc "github.com/smartcontractkit/chainlink-terra/tests/e2e/smoke/common" "github.com/smartcontractkit/integrations-framework/actions" ) -var _ = Describe("Terra OCRv2 @ocr", func() { +var _ = Describe("Terra OCRv2 @ocr2", func() { var state *tc.OCRv2State BeforeEach(func() { state = &tc.OCRv2State{} - By("Deoloying the cluster", func() { + By("Deploying the cluster", func() { state.DeployCluster(5, false) - common.ImitateSource(state.MockServer, 1*time.Second, 2, 10) + state.SetAllAdapterResponsesToTheSameValue(2) }) }) Describe("with Terra OCR2", func() { It("performs OCR2 round", func() { - state.ValidateRoundsAfter(time.Now(), 10) + state.ValidateRoundsAfter(time.Now(), 10, false) }) }) diff --git a/tests/e2e/smoke/rdd/.gitkeep b/tests/e2e/smoke/rdd/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/smoke/reports/.gitkeep b/tests/e2e/smoke/reports/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/utils/project_path.go b/tests/e2e/utils/project_path.go index ef9daf97f..9ca17d403 100644 --- a/tests/e2e/utils/project_path.go +++ b/tests/e2e/utils/project_path.go @@ -11,8 +11,18 @@ var ( ProjectRoot = filepath.Join(filepath.Dir(b), "/../../..") // ContractsDir contracts dir with wasm artifacts ContractsDir = filepath.Join(ProjectRoot, "artifacts") - // CommonContractsDir is common artifacts dir, for example cw20_base.wasm - CommonContractsDir = filepath.Join(TestsDir, "common_artifacts") // TestsDir path to e2e tests dir TestsDir = filepath.Join(ProjectRoot, "tests", "e2e") + // CommonContractsDir is common artifacts dir, for example cw20_base.wasm + CommonContractsDir = filepath.Join(TestsDir, "common_artifacts") + // Reports path to the gauntlet reports directory + Reports = filepath.Join(TestsDir, "smoke", "reports") + // Rdd path to the gauntlet rdd directory + Rdd = filepath.Join(TestsDir, "smoke", "rdd") + // GauntletTerraContracts path to the gauntlet-terra-contracts dir + GauntletTerraContracts = filepath.Join(ProjectRoot, "packages-ts", "gauntlet-terra-contracts") + // Networks path to the networks directory + Networks = filepath.Join(GauntletTerraContracts, "networks") + // CodeIds path to the codeIds directory + CodeIds = filepath.Join(GauntletTerraContracts, "codeIds") ) diff --git a/tsconfig.json b/tsconfig.json index fb612e2ae..e99b113c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,9 @@ { "path": "./packages-ts/gauntlet-terra" }, + { + "path": "./packages-ts/gauntlet-terra-cw-plus" + }, { "path": "./packages-ts/gauntlet-terra-contracts" }