diff --git a/Cargo.lock b/Cargo.lock index 39107b6..52b0c9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" +[[package]] +name = "bs58" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5353f36341f7451062466f0b755b96ac3a9547e4d7f6b70d603fc721a7d7896" +dependencies = [ + "tinyvec", +] + [[package]] name = "byte-slice-cast" version = "1.2.1" @@ -1524,7 +1533,7 @@ dependencies = [ "arrayref", "blake2", "borsh", - "bs58", + "bs58 0.4.0", "c2-chacha", "curve25519-dalek", "derive_more", @@ -1571,7 +1580,7 @@ checksum = "75ed2263518ca67a3c158c144813832fd96f48ab239494bb9d7793d315f31417" dependencies = [ "base64", "borsh", - "bs58", + "bs58 0.4.0", "byteorder", "chrono", "derive_more", @@ -1603,7 +1612,7 @@ checksum = "c2b3fb5acf3a494aed4e848446ef2d6ebb47dbe91c681105d4d1786c2ee63e52" dependencies = [ "base64", "borsh", - "bs58", + "bs58 0.4.0", "derive_more", "hex", "lazy_static", @@ -1686,7 +1695,7 @@ checksum = "c7383e242d3e07bf0951e8589d6eebd7f18bb1c1fc5fbec3fad796041a6aebd1" dependencies = [ "base64", "borsh", - "bs58", + "bs58 0.4.0", "near-primitives-core", "near-sdk-macros", "near-vm-logic", @@ -1778,7 +1787,7 @@ checksum = "e11cb28a2d07f37680efdaf860f4c9802828c44fc50c08009e7884de75d982c5" dependencies = [ "base64", "borsh", - "bs58", + "bs58 0.4.0", "byteorder", "near-primitives-core", "near-runtime-utils", @@ -2369,6 +2378,7 @@ dependencies = [ name = "request_conversion_proxy" version = "0.1.0" dependencies = [ + "bs58 0.5.0", "conversion_proxy", "fungible_conversion_proxy", "fungible_proxy", diff --git a/Cargo.toml b/Cargo.toml index 41b88c3..660eabb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ near-sdk = "3.1.0" serde = "1.0.118" # near-sdk = "4.0.0-pre.4" hex = "0.4" +bs58 = "0.5.0" [dev-dependencies] near-sdk-sim = "3.2.0" @@ -28,4 +29,4 @@ panic = "abort" overflow-checks = true [workspace] -members = ["conversion_proxy", "fungible_conversion_proxy", "fungible_proxy", "mocks"] \ No newline at end of file +members = ["conversion_proxy", "fungible_conversion_proxy", "fungible_proxy", "mocks"] diff --git a/README.md b/README.md index aa93846..28d916e 100644 --- a/README.md +++ b/README.md @@ -24,28 +24,26 @@ Smart contracts on NEAR used by the Run all contracts unit tests like this: ``` -cd near-contracts/conversion_proxy -cargo test -cd near-contracts/fungible_conversion_proxy -cargo test -cd near-contracts/fungible_proxy -cargo test -cd near-contracts/mocks -cargo test +cargo test -p conversion_proxy +cargo test -p fungible_conversion_proxy +cargo test -p fungible_proxy ``` -## Integration tests +## Integration tests (on a simulated VM with mocked 3rd party contracts) + +Integration tests are located in [tests/sim](tests/sim). ``` -# To test everything +# To test everything (unit tests, sanity checks, simulated tests) +# Requires building contracts (release) and mocks (debug) for simulated tests. ./test.sh -# To test contracts one by one: +# To run integration tests on contracts one by one: cargo test conversion_proxy cargo test fungible_conversionproxy cargo test fungible_proxy -# To run integration tests one by one (examples with main transfers): +# To run any tests one by one (examples with main transfers on simulated VM): cargo test conversion_proxy::test_transfer -- --exact cargo test fungible_conversionproxy::test_transfer -- --exact cargo test fungible_proxy::test_transfer -- --exact diff --git a/conversion_proxy/src/lib.rs b/conversion_proxy/src/lib.rs index a9004b2..12d9ae4 100644 --- a/conversion_proxy/src/lib.rs +++ b/conversion_proxy/src/lib.rs @@ -1,9 +1,12 @@ +use std::convert::TryInto; + use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::json_types::{ValidAccountId, U128, U64}; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::serde_json::json; use near_sdk::{ - env, log, near_bindgen, serde_json, AccountId, Balance, Gas, Promise, PromiseResult, Timestamp, + bs58, env, log, near_bindgen, serde_json, AccountId, Balance, Gas, Promise, PromiseResult, + PublicKey, Timestamp, }; near_sdk::setup_alloc!(); @@ -16,33 +19,49 @@ const MIN_GAS: Gas = 50_000_000_000_000; const BASIC_GAS: Gas = 10_000_000_000_000; /** - * Flux oracle-related declarations + * Switchboard oracle-related declarations */ -// Return type the Flux price oracle +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +pub struct SwitchboardDecimal { + pub mantissa: i128, + pub scale: u32, +} + #[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] pub struct PriceEntry { - pub price: U128, // Last reported price - pub decimals: u16, // Amount of decimals (e.g. if 2, 100 = 1.00) - pub last_update: Timestamp, // Time of report + pub result: SwitchboardDecimal, + pub num_success: u32, + pub num_error: u32, + pub round_open_timestamp: Timestamp, +} + +pub type Uuid = [u8; 32]; + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +pub struct SwitchboardIx { + pub address: Uuid, // This feed address reference a specific price feed, see https://app.switchboard.xyz + pub payer: Uuid, } -// Interface of the Flux price oracle -#[near_sdk::ext_contract(fpo_contract)] -trait FPOContract { - fn get_entry(pair: String, provider: AccountId) -> Promise; +// Interface of the Switchboard feed parser +#[near_sdk::ext_contract(sb_contract)] +trait Switchboard { + fn aggregator_read(ix: SwitchboardIx) -> Promise; } /// /// This contract -/// - oracle_account_id: should be a valid FPO oracle account ID -/// - provider_account_id: should be a valid FPO provider account ID +/// - feed_parser: should be a valid Switchboard feed parser +/// - feed_address: should be a valid NEAR/USD price feed +/// - feed_payer: pays for feeds not sponsored by Switchboard /// - owner_id: only the owner can edit the contract state values above (default = deployer) #[near_bindgen] #[derive(Default, BorshDeserialize, BorshSerialize)] pub struct ConversionProxy { - pub oracle_account_id: AccountId, - pub provider_account_id: AccountId, + pub feed_parser: AccountId, + pub feed_address: Uuid, + pub feed_payer: Uuid, pub owner_id: AccountId, } @@ -86,7 +105,7 @@ impl ConversionProxy { /// - `payment_reference`: used for indexing and matching the payment with a request /// - `payment_address`: `amount` in `currency` of NEAR will be paid to this address /// - `amount`: in `currency` with 2 decimals (eg. 1000 is 10.00) - /// - `currency`: ticker, most likely fiat (eg. 'USD') + /// - `currency`: ticker, only "USD" implemented for now /// - `fee_payment_address`: `fee_amount` in `currency` of NEAR will be paid to this address /// - `fee_amount`: in `currency` /// - `max_rate_timespan`: in nanoseconds, the maximum validity for the oracle rate response (or 0 if none) @@ -107,15 +126,21 @@ impl ConversionProxy { env::prepaid_gas(), MIN_GAS ); + assert_eq!( + currency, "USD", + "Only payments denominated in USD are implemented for now" + ); let reference_vec: Vec = hex::decode(payment_reference.replace("0x", "")) .expect("Payment reference value error"); assert_eq!(reference_vec.len(), 8, "Incorrect payment reference length"); - let get_rate = fpo_contract::get_entry( - "NEAR/".to_owned() + ¤cy, - self.provider_account_id.clone(), - &self.oracle_account_id, + let get_rate = sb_contract::aggregator_read( + SwitchboardIx { + address: self.feed_address.clone(), + payer: self.feed_payer.clone(), + }, + &self.feed_parser, NO_DEPOSIT, BASIC_GAS, ); @@ -137,39 +162,46 @@ impl ConversionProxy { } #[init] - pub fn new(oracle_account_id: AccountId, provider_account_id: AccountId) -> Self { + pub fn new(feed_parser: AccountId, feed_address_pk: &String) -> Self { let owner_id = env::signer_account_id(); + let feed_payer = Self::get_uuid(env::signer_account_pk()).expect("ERR_OWNER_PK_LENGTH"); + let feed_address = Self::get_uuid_from_string(feed_address_pk); Self { - oracle_account_id, - provider_account_id, + feed_parser, + feed_address, + feed_payer, owner_id, } } - pub fn set_oracle_account(&mut self, oracle: ValidAccountId) { + pub fn set_feed_parser(&mut self, feed_parser: AccountId) { let signer_id = env::predecessor_account_id(); if self.owner_id == signer_id { - self.oracle_account_id = oracle.to_string(); + self.feed_parser = feed_parser; } else { panic!("ERR_PERMISSION"); } } - pub fn get_oracle_account(&self) -> AccountId { - return self.oracle_account_id.to_string(); + pub fn get_feed_parser(&self) -> AccountId { + return self.feed_parser.clone(); } - pub fn set_provider_account(&mut self, oracle: ValidAccountId) { + pub fn set_feed_address(&mut self, feed_address: &String) { let signer_id = env::predecessor_account_id(); if self.owner_id == signer_id { - self.provider_account_id = oracle.to_string(); + self.feed_address = Self::get_uuid_from_string(feed_address); } else { panic!("ERR_PERMISSION"); } } - pub fn get_provider_account(&self) -> AccountId { - return self.provider_account_id.to_string(); + pub fn get_feed_address(&self) -> Uuid { + return self.feed_address.clone(); + } + + pub fn get_encoded_feed_address(&self) -> String { + return bs58::encode(self.feed_address.clone()).into_string(); } pub fn set_owner(&mut self, owner: ValidAccountId) { @@ -181,6 +213,48 @@ impl ConversionProxy { } } + pub fn set_feed_payer(&mut self) { + let signer_id = env::predecessor_account_id(); + if self.owner_id == signer_id { + self.feed_payer = + Self::get_uuid(env::signer_account_pk()).expect("ERR_OWNER_PK_LENGTH"); + } else { + panic!("ERR_PERMISSION"); + } + } + + pub fn get_feed_payer(&self) -> Uuid { + return self.feed_payer.clone(); + } + + pub fn get_encoded_feed_payer(&self) -> String { + return bs58::encode(self.feed_payer.clone()).into_string(); + } + + /// This method transforms a PublicKey (eg. ed25519:3H8UcosBhKfPcuZj7ffr3QqG5BxiGzJECqPZAZka5fJn) into a Uuid (alias for [u8; 32]) + /// Should be useless onchain. + #[private] + pub fn get_uuid(public_key: PublicKey) -> Option { + let vec_length = public_key.len(); + if vec_length == 32 { + return Some(public_key.try_into().unwrap()); + } + // For some reason, the local VM sometimes prepends a 0 in front of the 32-long vector + if vec_length == 33 && public_key[0] == 0_u8 { + return Some(public_key[1..].try_into().unwrap()); + } + return None; + } + + #[private] + pub fn get_uuid_from_string(public_key: &String) -> Uuid { + bs58::decode(public_key) + .into_vec() + .expect("public_key should be decodable into a vector") + .try_into() + .expect("public_key should be decodable into [u8; 32]") + } + #[private] pub fn on_transfer_with_reference( &self, @@ -227,6 +301,15 @@ impl ConversionProxy { } } + /// This method refunds a payer, then logs an error message. + /// Used as a bandaid until we find a solution for refund.then(panic) + #[private] + pub fn refund_then_log(&mut self, payer: ValidAccountId, error_message: String) -> u128 { + Promise::new(payer.clone().to_string()).transfer(env::attached_deposit()); + log!(error_message); + return 0_u128; + } + #[private] #[payable] pub fn rate_callback( @@ -247,34 +330,55 @@ impl ConversionProxy { PromiseResult::Successful(value) => { match serde_json::from_slice::(&value) { Ok(value) => value, - Err(_e) => panic!("ERR_INVALID_ORACLE_RESPONSE"), + Err(_e) => { + return self.refund_then_log(payer, "ERR_INVALID_ORACLE_RESPONSE".into()) + } } } - PromiseResult::Failed => panic!("ERR_FAILED_ORACLE_FETCH"), + PromiseResult::Failed => { + return self.refund_then_log(payer, "ERR_FAILED_ORACLE_FETCH".into()); + } }; + // Check rate errors + if rate.num_error != 0 || rate.num_success < 1 { + return self.refund_then_log( + payer, + "Conversion errors:".to_string() + + &rate.num_error.to_string() + + &", successes: " + + &rate.num_success.to_string(), + ); + } // Check rate validity assert!( u64::from(max_rate_timespan) == 0 - || rate.last_update >= env::block_timestamp() - u64::from(max_rate_timespan), + || rate.round_open_timestamp + >= env::block_timestamp() - u64::from(max_rate_timespan), "Conversion rate too old (Last updated: {})", - rate.last_update, + rate.round_open_timestamp, ); - let conversion_rate = u128::from(rate.price); - let decimals = u32::from(rate.decimals); - let main_payment = - Balance::from(amount) * ONE_NEAR * 10u128.pow(decimals) / conversion_rate / ONE_FIAT; + let conversion_rate = 0_u128 + .checked_add_signed(rate.result.mantissa) + .expect("The conversion rate should be positive"); + let decimals = u32::from(rate.result.scale); + let main_payment = (Balance::from(amount) * ONE_NEAR * 10u128.pow(decimals) + / conversion_rate + / ONE_FIAT) as u128; let fee_payment = Balance::from(fee_amount) * ONE_NEAR * 10u128.pow(decimals) / conversion_rate / ONE_FIAT; let total_payment = main_payment + fee_payment; // Check deposit - assert!( - total_payment <= env::attached_deposit(), - "Deposit too small for payment (Supplied: {}. Demand (incl. fees): {})", - env::attached_deposit(), - total_payment - ); + if total_payment > env::attached_deposit() { + return self.refund_then_log( + payer, + "Deposit too small for payment. Supplied: ".to_string() + + &env::attached_deposit().to_string() + + &". Demand (incl. fees): " + + &total_payment.to_string(), + ); + } let change = env::attached_deposit() - (total_payment); @@ -331,7 +435,7 @@ mod tests { VMContext { current_account_id: predecessor_account_id.clone(), signer_account_id: predecessor_account_id.clone(), - signer_account_pk: vec![0, 1, 2], + signer_account_pk: (1..33).collect(), // Public key: Size 32 predecessor_account_id, input: vec![], block_index: 1, @@ -362,20 +466,27 @@ mod tests { ) } + pub(crate) const USD: &str = "USD"; + pub(crate) const PAYMENT_REF: &str = "0x1122334455667788"; + pub(crate) const FEED_ADDRESS: &str = "HeS3xrDqHA2CSHTmN9osstz8vbXfgh2mzzzzzzzzzzzz"; + #[test] #[should_panic(expected = r#"Incorrect payment reference length"#)] fn transfer_with_invalid_reference_length() { - let context = get_context(alice_account(), ntoy(100), 10u64.pow(14), false); - testing_env!(context); + testing_env!(get_context( + alice_account(), + ntoy(100), + 10u64.pow(14), + false + )); let mut contract = ConversionProxy::default(); let payment_reference = "0x11223344556677".to_string(); - let currency = "USD".to_string(); let (to, amount, fee_address, fee_amount, max_rate_timespan) = default_values(); contract.transfer_with_reference( payment_reference, to, amount, - currency, + USD.into(), fee_address, fee_amount, max_rate_timespan, @@ -385,16 +496,42 @@ mod tests { #[test] #[should_panic(expected = r#"Payment reference value error"#)] fn transfer_with_invalid_reference_value() { - let context = get_context(alice_account(), ntoy(100), 10u64.pow(14), false); - testing_env!(context); + testing_env!(get_context( + alice_account(), + ntoy(100), + 10u64.pow(14), + false + )); let mut contract = ConversionProxy::default(); let payment_reference = "0x123".to_string(); - let currency = "USD".to_string(); let (to, amount, fee_address, fee_amount, max_rate_timespan) = default_values(); contract.transfer_with_reference( payment_reference, to, amount, + USD.into(), + fee_address, + fee_amount, + max_rate_timespan, + ); + } + + #[test] + #[should_panic(expected = r#"Only payments denominated in USD are implemented for now"#)] + fn transfer_with_invalid_currency() { + testing_env!(get_context( + alice_account(), + ntoy(100), + 10u64.pow(14), + false + )); + let mut contract = ConversionProxy::default(); + let currency = "HKD".to_string(); + let (to, amount, fee_address, fee_amount, max_rate_timespan) = default_values(); + contract.transfer_with_reference( + PAYMENT_REF.into(), + to, + amount, currency, fee_address, fee_amount, @@ -405,17 +542,14 @@ mod tests { #[test] #[should_panic(expected = r#"Not enough attached Gas to call this method"#)] fn transfer_with_not_enough_gas() { - let context = get_context(alice_account(), ntoy(1), 10u64.pow(13), false); - testing_env!(context); + testing_env!(get_context(alice_account(), ntoy(1), 10u64.pow(13), false)); let mut contract = ConversionProxy::default(); - let payment_reference = "0x1122334455667788".to_string(); - let currency = "USD".to_string(); let (to, amount, fee_address, fee_amount, max_rate_timespan) = default_values(); contract.transfer_with_reference( - payment_reference, + PAYMENT_REF.into(), to, amount, - currency, + USD.into(), fee_address, fee_amount, max_rate_timespan, @@ -424,17 +558,14 @@ mod tests { #[test] fn transfer_with_reference() { - let context = get_context(alice_account(), ntoy(1), 10u64.pow(14), false); - testing_env!(context); + testing_env!(get_context(alice_account(), ntoy(1), 10u64.pow(14), false)); let mut contract = ConversionProxy::default(); - let payment_reference = "0x1122334455667788".to_string(); - let currency = "USD".to_string(); let (to, amount, fee_address, fee_amount, max_rate_timespan) = default_values(); contract.transfer_with_reference( - payment_reference, + PAYMENT_REF.into(), to, amount, - currency, + USD.into(), fee_address, fee_amount, max_rate_timespan, @@ -443,51 +574,66 @@ mod tests { #[test] #[should_panic(expected = r#"ERR_PERMISSION"#)] - fn admin_oracle_no_permission() { - let context = get_context(alice_account(), ntoy(1), 10u64.pow(14), false); - testing_env!(context); + fn admin_feed_address_no_permission() { + testing_env!(get_context(alice_account(), ntoy(1), 10u64.pow(14), false)); + let mut contract = ConversionProxy::default(); + contract.set_feed_address(&FEED_ADDRESS.into()); + } + + #[test] + fn admin_feed_address() { + let owner = ConversionProxy::default().owner_id; + testing_env!(get_context(owner, ntoy(1), 10u64.pow(14), false)); + let mut contract = ConversionProxy::default(); + contract.set_feed_address(&FEED_ADDRESS.into()); + assert_eq!( + contract.get_encoded_feed_address(), + FEED_ADDRESS.to_string() + ); + } + + #[test] + #[should_panic(expected = r#"ERR_PERMISSION"#)] + fn admin_feed_payer_no_permission() { + testing_env!(get_context(alice_account(), ntoy(1), 10u64.pow(14), false)); let mut contract = ConversionProxy::default(); - let (to, _amount, _fee_address, _fee_amount, _max_rate_timespan) = default_values(); - contract.set_oracle_account(to); + contract.set_feed_payer(); } #[test] - fn admin_oracle() { + fn admin_feed_payer() { let owner = ConversionProxy::default().owner_id; + testing_env!(get_context(owner, ntoy(1), 10u64.pow(14), false)); let mut contract = ConversionProxy::default(); - let context = get_context(owner, ntoy(1), 10u64.pow(14), false); - testing_env!(context); - let (to, _amount, _fee_address, _fee_amount, _max_rate_timespan) = default_values(); - contract.set_oracle_account(to); + contract.set_feed_payer(); + assert_eq!(contract.get_feed_payer().to_vec(), env::signer_account_pk()); } #[test] #[should_panic(expected = r#"ERR_PERMISSION"#)] - fn admin_provider_no_permission() { - let context = get_context(alice_account(), ntoy(1), 10u64.pow(14), false); - testing_env!(context); + fn admin_feed_parser_no_permission() { + testing_env!(get_context(alice_account(), ntoy(1), 10u64.pow(14), false)); let mut contract = ConversionProxy::default(); - let (to, _amount, _fee_address, _fee_amount, _max_rate_timespan) = default_values(); - contract.set_provider_account(to); + let (to, _, _, _, _) = default_values(); + contract.set_feed_parser(to.into()); } #[test] - fn admin_provider() { + fn admin_feed_parser() { let owner = ConversionProxy::default().owner_id; let mut contract = ConversionProxy::default(); - let context = get_context(owner, ntoy(1), 10u64.pow(14), false); - testing_env!(context); - let (to, _amount, _fee_address, _fee_amount, _max_rate_timespan) = default_values(); - contract.set_provider_account(to); + testing_env!(get_context(owner, ntoy(1), 10u64.pow(14), false)); + let (to, _, _, _, _) = default_values(); + contract.set_feed_parser(to.clone().into()); + assert_eq!(contract.get_feed_parser(), Into::::into(to)); } #[test] #[should_panic(expected = r#"ERR_PERMISSION"#)] fn admin_owner_no_permission() { - let context = get_context(alice_account(), ntoy(1), 10u64.pow(14), false); - testing_env!(context); + testing_env!(get_context(alice_account(), ntoy(1), 10u64.pow(14), false)); let mut contract = ConversionProxy::default(); - let (to, _amount, _fee_address, _fee_amount, _max_rate_timespan) = default_values(); + let (to, _, _, _, _) = default_values(); contract.set_owner(to); } @@ -495,9 +641,13 @@ mod tests { fn admin_owner() { let owner = ConversionProxy::default().owner_id; let mut contract = ConversionProxy::default(); - let context = get_context(owner, ntoy(1), 10u64.pow(14), false); - testing_env!(context); - let (to, _amount, _fee_address, _fee_amount, _max_rate_timespan) = default_values(); - contract.set_owner(to); + testing_env!(get_context(owner, ntoy(1), 10u64.pow(14), false)); + let (to, _, _, _, _) = default_values(); + contract.set_owner(to.clone()); + testing_env!(get_context(to.into(), ntoy(1), 10u64.pow(14), false)); + assert!(contract.owner_id == env::signer_account_id()); + assert!(contract.get_feed_payer().to_vec() != env::signer_account_pk()); + contract.set_feed_payer(); + assert_eq!(contract.get_feed_payer().to_vec(), env::signer_account_pk()); } } diff --git a/deploy.sh b/deploy.sh index 7937fd0..ead65ee 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,7 +3,7 @@ # Run with -h for documentation and help # testnet deployment and values (default) -NEAR_ENV="testnet" +NEAR_ENV="testnet"; oracle_account_id="fpo.opfilabs.testnet" provider_account_id="opfilabs.testnet" contract_name="conversion_proxy"; @@ -63,11 +63,25 @@ if [ "$contract_name" = "fungible_proxy" ]; then near deploy -f --wasmFile ./target/wasm32-unknown-unknown/release/$contract_name.wasm \ --accountId $ACCOUNT_ID else + + if [ "$contract_name" = "conversion_proxy" ]; then + if [ "$NEAR_ENV" = "mainnet" ]; then + feed_parser="switchboard-v2.mainnet"; + feed_address="C3p8SSWQS8j1nx7HrzBBphX5jZcS1EY28EJ5iwjzSix2"; + else + feed_parser="switchboard-v2.testnet"; + feed_address="7igqhpGQ8xPpyjQ4gMHhXRvtZcrKSGJkdKDJYBiPQgcb"; + fi + initArgs='{"feed_parser":"'$feed_parser'","feed_address_pk":"'$feed_address'"}'; + else + initArgs='{"oracle_account_id": "'$oracle_account_id'", "provider_account_id": "'$provider_account_id'"}'; + fi + echo $initArgs; initParams=""; if ! $patch ; then initParams=" --initFunction new \ - --initArgs '{"oracle_account_id": "'$oracle_account_id'", "provider_account_id": "'$provider_account_id'"}'"; + --initArgs $initArgs"; fi set -x near deploy -f --wasmFile ./target/wasm32-unknown-unknown/release/$contract_name.wasm \ diff --git a/mocks/src/lib.rs b/mocks/src/lib.rs index 1a14e5b..4c11b90 100644 --- a/mocks/src/lib.rs +++ b/mocks/src/lib.rs @@ -1,2 +1,3 @@ pub mod fpo_oracle_mock; pub mod fungible_token_mock; +pub mod switchboard_feed_parser_mock; diff --git a/mocks/src/switchboard_feed_parser_mock.rs b/mocks/src/switchboard_feed_parser_mock.rs new file mode 100644 index 0000000..b4d3171 --- /dev/null +++ b/mocks/src/switchboard_feed_parser_mock.rs @@ -0,0 +1,130 @@ +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::{bs58, env, near_bindgen, Timestamp}; +use std::str; + +/** + * Mocking the Switchboard feed parser contract for tests + */ + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +pub struct SwitchboardDecimal { + pub mantissa: i128, + pub scale: u32, +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +pub struct PriceEntry { + pub result: SwitchboardDecimal, + pub num_success: u32, + pub num_error: u32, + pub round_open_timestamp: Timestamp, +} + +pub type Uuid = [u8; 32]; + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +pub struct SwitchboardIx { + pub address: Uuid, + pub payer: Uuid, +} + +// For mocks: state of Switchboard feed parser +#[near_bindgen] +#[derive(Default, BorshDeserialize, BorshSerialize)] +pub struct SwitchboardFeedParser {} + +const VALID_FEED_ADDRESS: [u8; 32] = [0; 32]; + +pub fn valid_feed_key() -> String { + bs58::encode(&VALID_FEED_ADDRESS).into_string() +} + +#[near_bindgen] +impl SwitchboardFeedParser { + #[allow(unused_variables)] + pub fn aggregator_read(&self, ix: SwitchboardIx) -> Option { + match ix.address { + VALID_FEED_ADDRESS => Some(PriceEntry { + result: SwitchboardDecimal { + mantissa: i128::from(1234000), + scale: u8::from(6).into(), + }, + num_success: 1, + num_error: 0, + round_open_timestamp: env::block_timestamp() - 10, + }), + _ => { + panic!("InvalidAggregator") + } + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use near_sdk::{testing_env, AccountId, Balance, Gas, MockedBlockchain, VMContext}; + use near_sdk_sim::to_yocto; + + fn get_context( + predecessor_account_id: AccountId, + attached_deposit: Balance, + prepaid_gas: Gas, + is_view: bool, + ) -> VMContext { + VMContext { + current_account_id: predecessor_account_id.clone(), + signer_account_id: predecessor_account_id.clone(), + signer_account_pk: vec![0, 1, 2], + predecessor_account_id, + input: vec![], + block_index: 1, + block_timestamp: 10, + epoch_height: 1, + account_balance: 0, + account_locked_balance: 0, + storage_usage: 10u64.pow(6), + attached_deposit, + prepaid_gas, + random_seed: vec![0, 1, 2], + is_view, + output_data_receivers: vec![], + } + } + #[test] + fn aggregator_read() { + testing_env!(get_context( + "alice.near".to_string(), + to_yocto("1"), + 10u64.pow(14), + true + )); + let contract = SwitchboardFeedParser::default(); + if let Some(result) = contract.aggregator_read(SwitchboardIx { + address: [0; 32], + payer: [1; 32], + }) { + assert_eq!(result.result.mantissa, i128::from(1234000)); + assert_eq!(result.result.scale, 6); + } else { + panic!("NEAR/USD mock returned None") + } + } + #[test] + #[should_panic(expected = r#"InvalidAggregator"#)] + fn missing_aggregator_read() { + testing_env!(get_context( + "alice.near".to_string(), + to_yocto("1"), + 10u64.pow(14), + true + )); + let contract = SwitchboardFeedParser::default(); + contract.aggregator_read(SwitchboardIx { + address: [255; 32], + payer: [1; 32], + }); + } +} diff --git a/test.sh b/test.sh index 84d1849..aba5388 100755 --- a/test.sh +++ b/test.sh @@ -1,4 +1,3 @@ -cd mocks/ +./mocks/build.sh ./build.sh -cd .. -cargo test --all \ No newline at end of file +cargo test --all diff --git a/tests/sim/conversion_proxy.rs b/tests/sim/conversion_proxy.rs index 632a210..71ef30d 100644 --- a/tests/sim/conversion_proxy.rs +++ b/tests/sim/conversion_proxy.rs @@ -1,8 +1,7 @@ use crate::utils::*; use conversion_proxy::ConversionProxyContract; -use mocks::fpo_oracle_mock::FPOContractContract; +use mocks::switchboard_feed_parser_mock::{valid_feed_key, SwitchboardFeedParserContract}; use near_sdk::json_types::{U128, U64}; -use near_sdk::Balance; use near_sdk_sim::init_simulator; use near_sdk_sim::runtime::GenesisConfig; use near_sdk_sim::ContractAccount; @@ -15,27 +14,31 @@ near_sdk::setup_alloc!(); const PROXY_ID: &str = "conversion_proxy"; lazy_static_include::lazy_static_include_bytes! { - PROXY_BYTES => "target/wasm32-unknown-unknown/release/conversion_proxy.wasm" + pub PROXY_BYTES => "target/wasm32-unknown-unknown/release/conversion_proxy.wasm" } lazy_static_include::lazy_static_include_bytes! { - MOCKED_BYTES => "target/wasm32-unknown-unknown/debug/mocks.wasm" + pub MOCKED_BYTES => "target/wasm32-unknown-unknown/debug/mocks.wasm" } const DEFAULT_BALANCE: &str = "400000"; +const USD: &str = "USD"; +const PAYMENT_REF: &str = "0x1122334455667788"; -// Initialize test environment with 3 accounts (alice, bob, builder) and a conversion mock. +// Initialize test environment with 3 accounts (alice, bob, builder), a conversion mock, and its owner account. fn init() -> ( UserAccount, UserAccount, UserAccount, ContractAccount, + UserAccount, ) { - let genesis = GenesisConfig::default(); + let mut genesis = GenesisConfig::default(); + genesis.gas_price = 0; let root = init_simulator(Some(genesis)); deploy!( - contract: FPOContractContract, - contract_id: "mockedfpo".to_string(), + contract: SwitchboardFeedParserContract, + contract_id: "mockedswitchboard".to_string(), bytes: &MOCKED_BYTES, signer_account: root, deposit: to_yocto("7") @@ -52,41 +55,41 @@ fn init() -> ( contract_id: PROXY_ID, bytes: &PROXY_BYTES, signer_account: root, - deposit: to_yocto("10"), - init_method: new("mockedfpo".into(), "any".into()) + deposit: to_yocto("5"), + init_method: new("mockedswitchboard".into(), &valid_feed_key()) ); - let get_oracle_result = call!(root, proxy.get_oracle_account()); - get_oracle_result.assert_success(); + let set_feed_payer_result = call!(root, proxy.set_feed_payer()); + set_feed_payer_result.assert_success(); + let get_parser_result = call!(root, proxy.get_feed_parser()); + get_parser_result.assert_success(); debug_assert_eq!( - &get_oracle_result.unwrap_json_value(), - &"mockedfpo".to_string() + &get_parser_result.unwrap_json_value().to_owned(), + &"mockedswitchboard".to_string() ); - (account, empty_account_1, empty_account_2, proxy) + (account, empty_account_1, empty_account_2, proxy, root) } #[test] fn test_transfer() { - let (alice, bob, builder, proxy) = init(); + let (alice, bob, builder, proxy, _) = init(); let initial_alice_balance = alice.account().unwrap().amount; let initial_bob_balance = bob.account().unwrap().amount; let initial_builder_balance = builder.account().unwrap().amount; let transfer_amount = to_yocto("200000"); let payment_address = bob.account_id().try_into().unwrap(); let fee_address = builder.account_id().try_into().unwrap(); - const ONE_NEAR: Balance = 1_000_000_000_000_000_000_000_000; - // Token transfer failed let result = call!( alice, proxy.transfer_with_reference( - "0x1122334455667788".to_string(), + PAYMENT_REF.into(), payment_address, // 12000.00 USD (main) U128::from(1200000), - String::from("USD"), + USD.into(), fee_address, // 1.00 USD (fee) U128::from(100), @@ -96,21 +99,15 @@ fn test_transfer() { ); result.assert_success(); - println!( - "test_transfer_usd_near ==> TeraGas burnt: {}", - result.gas_burnt() as f64 / 1e12 - ); - let alice_balance = alice.account().unwrap().amount; assert!(alice_balance < initial_alice_balance); let spent_amount = initial_alice_balance - alice_balance; // 12'001.00 USD worth of NEAR / 1.234 let expected_spent = to_yocto("12001") * 1000 / 1234; assert!( - spent_amount - expected_spent < to_yocto("0.005"), - "Alice should spend 12'000 + 1 USD worth of NEAR (+ gas)", + yocto_almost_eq(spent_amount, expected_spent), + "Alice should spend 12'000 + 1 USD worth of NEAR. \nSpent: {spent_amount}. \nExpected: {expected_spent}.", ); - println!("diff: {}", (spent_amount - expected_spent) / ONE_NEAR); assert!(bob.account().unwrap().amount > initial_bob_balance); let received_amount = bob.account().unwrap().amount - initial_bob_balance; @@ -118,14 +115,14 @@ fn test_transfer() { received_amount, // 12'000 USD / rate mocked to_yocto("12000") * 1000 / 1234, - "Bob should receive exactly 12000 USD worth of NEAR" + "Bob should receive exactly 12'000 USD worth of NEAR." ); assert!(builder.account().unwrap().amount > initial_builder_balance); let received_amount = builder.account().unwrap().amount - initial_builder_balance; assert_eq!( received_amount, - // 1 USD + // 1 USD / rate mocked to_yocto("1") * 1000 / 1234, "Builder should receive exactly 1 USD worth of NEAR" ); @@ -135,7 +132,7 @@ fn test_transfer() { fn test_transfer_with_invalid_reference_length() { let transfer_amount = to_yocto("500"); - let (alice, bob, builder, proxy) = init(); + let (alice, bob, builder, proxy, _) = init(); let payment_address = bob.account_id().try_into().unwrap(); let fee_address = builder.account_id().try_into().unwrap(); @@ -146,30 +143,26 @@ fn test_transfer_with_invalid_reference_length() { "0x11223344556677".to_string(), payment_address, U128::from(12), - String::from("USD"), + USD.into(), fee_address, U128::from(1), U64::from(0) ), deposit = transfer_amount ); - // No successful outcome is expected - assert!(!result.is_ok()); - - println!( - "test_transfer_with_invalid_parameter_length > TeraGas burnt: {}", - result.gas_burnt() as f64 / 1e12 - ); - - assert_one_promise_error(result, "Incorrect payment reference length"); + assert_one_promise_error(result.clone(), "Incorrect payment reference length"); // Check Alice balance - assert_eq_with_gas(to_yocto(DEFAULT_BALANCE), alice.account().unwrap().amount); + assert_eq!( + to_yocto(DEFAULT_BALANCE), + alice.account().unwrap().amount, + "Alice should not spend NEAR on invalid payment.", + ); } #[test] fn test_transfer_with_wrong_currency() { - let (alice, bob, builder, proxy) = init(); + let (alice, bob, builder, proxy, _) = init(); let transfer_amount = to_yocto("100"); let payment_address = bob.account_id().try_into().unwrap(); let fee_address = builder.account_id().try_into().unwrap(); @@ -178,7 +171,7 @@ fn test_transfer_with_wrong_currency() { let result = call!( alice, proxy.transfer_with_reference( - "0x1122334455667788".to_string(), + PAYMENT_REF.into(), payment_address, U128::from(1200), String::from("WRONG"), @@ -188,12 +181,60 @@ fn test_transfer_with_wrong_currency() { ), deposit = transfer_amount ); - assert_one_promise_error(result, "ERR_INVALID_ORACLE_RESPONSE"); + assert_one_promise_error( + result, + "Only payments denominated in USD are implemented for now", + ); +} + +#[test] +fn test_transfer_with_low_deposit() { + let (alice, bob, builder, proxy, _) = init(); + let initial_alice_balance = alice.account().unwrap().amount; + let initial_bob_balance = bob.account().unwrap().amount; + let initial_contract_balance = proxy.account().unwrap().amount; + let transfer_amount = to_yocto("1000"); + let payment_address = bob.account_id().try_into().unwrap(); + let fee_address = builder.account_id().try_into().unwrap(); + + let result = call!( + alice, + proxy.transfer_with_reference( + PAYMENT_REF.into(), + payment_address, + U128::from(2000000), + USD.into(), + fee_address, + U128::from(0), + U64::from(0) + ), + deposit = transfer_amount + ); + result.assert_success(); + assert_eq!(result.logs().len(), 1, "Wrong number of logs"); + assert!(result.logs()[0].contains("Deposit too small for payment")); + + assert_eq!( + alice.account().unwrap().amount, + initial_alice_balance, + "Alice should not spend NEAR on a failed payment.", + ); + + assert_eq!( + proxy.account().unwrap().amount, + initial_contract_balance, + "Contract's balance should be unchanged" + ); + assert_eq!( + builder.account().unwrap().amount, + initial_bob_balance, + "Builder's balance should be unchanged" + ); } #[test] fn test_transfer_zero_usd() { - let (alice, bob, builder, proxy) = init(); + let (alice, bob, builder, proxy, _) = init(); let initial_alice_balance = alice.account().unwrap().amount; let initial_bob_balance = bob.account().unwrap().amount; let transfer_amount = to_yocto("100"); @@ -203,10 +244,10 @@ fn test_transfer_zero_usd() { let result = call!( alice, proxy.transfer_with_reference( - "0x1122334455667788".to_string(), + PAYMENT_REF.into(), payment_address, U128::from(0), - String::from("USD"), + USD.into(), fee_address, U128::from(0), U64::from(0) @@ -216,11 +257,9 @@ fn test_transfer_zero_usd() { result.assert_success(); let alice_balance = alice.account().unwrap().amount; - assert!(alice_balance < initial_alice_balance); - let spent_amount = initial_alice_balance - alice_balance; - assert!( - spent_amount < to_yocto("0.005"), - "Alice should not spend NEAR on a 0 USD payment", + assert_eq!( + initial_alice_balance, alice_balance, + "Alice should not spend NEAR on a 0 USD payment.", ); assert!( @@ -235,7 +274,7 @@ fn test_transfer_zero_usd() { #[test] fn test_outdated_rate() { - let (alice, bob, builder, proxy) = init(); + let (alice, bob, builder, proxy, _) = init(); let transfer_amount = to_yocto("100"); let payment_address = bob.account_id().try_into().unwrap(); let fee_address = builder.account_id().try_into().unwrap(); @@ -243,10 +282,10 @@ fn test_outdated_rate() { let result = call!( alice, proxy.transfer_with_reference( - "0x1122334455667788".to_string(), + PAYMENT_REF.into(), payment_address, U128::from(0), - String::from("USD"), + USD.into(), fee_address, U128::from(0), // The mocked rate is 10 nanoseconds old diff --git a/tests/sim/fungible_conversion_proxy.rs b/tests/sim/fungible_conversion_proxy.rs index 7b3b2eb..6d9b9a6 100644 --- a/tests/sim/fungible_conversion_proxy.rs +++ b/tests/sim/fungible_conversion_proxy.rs @@ -47,7 +47,7 @@ fn init_fungible() -> ( contract_id: "mockedft".to_string(), bytes: &MOCKED_BYTES, signer_account: root, - deposit: to_yocto("7") + deposit: to_yocto("8") ); let account = root.create_user("alice".to_string(), to_yocto(DEFAULT_BALANCE)); diff --git a/tests/sim/fungible_proxy.rs b/tests/sim/fungible_proxy.rs index ce11ab6..4fb1a07 100644 --- a/tests/sim/fungible_proxy.rs +++ b/tests/sim/fungible_proxy.rs @@ -41,7 +41,7 @@ fn init_fungible() -> ( contract_id: "mockedft".to_string(), bytes: &MOCKED_BYTES, signer_account: root, - deposit: to_yocto("7") + deposit: to_yocto("8") ); let account = root.create_user("alice".to_string(), to_yocto(DEFAULT_BALANCE)); diff --git a/tests/sim/utils.rs b/tests/sim/utils.rs index c350763..9cf8336 100644 --- a/tests/sim/utils.rs +++ b/tests/sim/utils.rs @@ -1,18 +1,11 @@ use mocks::fungible_token_mock::FungibleTokenContractContract; use near_sdk::json_types::U128; use near_sdk_sim::transaction::ExecutionStatus; -use near_sdk_sim::{call, to_yocto, ContractAccount, ExecutionResult, UserAccount}; +use near_sdk_sim::{call, ContractAccount, ExecutionResult, UserAccount}; -pub fn assert_almost_eq_with_max_delta(left: u128, right: u128, max_delta: u128) { - assert!( - std::cmp::max(left, right) - std::cmp::min(left, right) <= max_delta, - "{}", - format!("Left {left} is not even close to Right {right} within delta {max_delta}") - ); -} - -pub fn assert_eq_with_gas(left: u128, right: u128) { - assert_almost_eq_with_max_delta(left, right, to_yocto("0.005")); +/// Util to compare 2 numbers in yocto, +/- 1 yocto to ignore math precision issues +pub fn yocto_almost_eq(left: u128, right: u128) -> bool { + return std::cmp::max(left, right) - std::cmp::min(left, right) <= 1; } /// Util to check a balance is the same as in a previous state @@ -48,7 +41,7 @@ pub fn assert_spent( assert!(current_balance <= previous_balance, "Did not spend."); assert!( current_balance == previous_balance - expected_spent_amount, - "Spent {} instead of {}", + "Spent {}\ninstead of {}", previous_balance - current_balance, expected_spent_amount ); @@ -74,7 +67,16 @@ pub fn assert_received( } pub fn assert_one_promise_error(promise_result: ExecutionResult, expected_error_message: &str) { - assert_eq!(promise_result.promise_errors().len(), 1); + assert!( + !promise_result.is_ok(), + "Promise succeeded, expected to fail." + ); + assert_eq!( + promise_result.promise_errors().len(), + 1, + "Expected 1 error, got {}", + promise_result.promise_errors().len() + ); if let ExecutionStatus::Failure(execution_error) = &promise_result .promise_errors() @@ -83,7 +85,12 @@ pub fn assert_one_promise_error(promise_result: ExecutionResult, expected_error_ .outcome() .status { - assert!(execution_error.to_string().contains(expected_error_message)); + assert!( + execution_error.to_string().contains(expected_error_message), + "Expected error containing: '{}'. Got: '{}'", + expected_error_message, + execution_error.to_string() + ); } else { unreachable!(); }