diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index c039cef5..653b90b7 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v3 - name: Build - run: make build + run: make build-in-docker - name: Upload binary uses: actions/upload-artifact@v3 @@ -72,4 +72,4 @@ jobs: with: message: Updated binary branch: main - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.ACTIONS_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8668dd7..92c270a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,13 +16,7 @@ jobs: uses: actions/checkout@v3 - name: Build - run: make build-in-docker - - - name: Upload binary - uses: actions/upload-artifact@v3 - with: - name: sweat-jar - path: res/sweat_jar.wasm + run: make build lint: runs-on: ubuntu-latest diff --git a/contract/src/assert.rs b/contract/src/assert.rs index 12c06e34..0efcfc13 100644 --- a/contract/src/assert.rs +++ b/contract/src/assert.rs @@ -2,14 +2,10 @@ use near_sdk::{require, AccountId}; use crate::{ common::{Timestamp, TokenAmount}, - jar::model::{Jar, JarState}, + jar::model::Jar, product::model::Product, }; -pub(crate) fn assert_is_not_closed(jar: &Jar) { - assert_ne!(jar.state, JarState::Closed, "Jar is closed"); -} - pub(crate) fn assert_sufficient_balance(jar: &Jar, amount: TokenAmount) { require!(jar.principal >= amount, "Insufficient balance"); } diff --git a/contract/src/claim/api.rs b/contract/src/claim/api.rs index f05b2631..d4812687 100644 --- a/contract/src/claim/api.rs +++ b/contract/src/claim/api.rs @@ -1,12 +1,12 @@ use std::cmp; -use near_sdk::{env, ext_contract, is_promise_success, json_types::U128, near_bindgen, PromiseOrValue}; +use near_sdk::{env, ext_contract, is_promise_success, json_types::U128, near_bindgen, AccountId, PromiseOrValue}; use crate::{ common::{TokenAmount, GAS_FOR_AFTER_CLAIM}, event::{emit, ClaimEventItem, EventKind}, ft_interface::FungibleTokenInterface, - jar::model::{Jar, JarIndex}, + jar::model::{Jar, JarId}, Contract, ContractExt, Promise, }; @@ -20,11 +20,11 @@ pub trait ClaimApi { /// interest across all jars is zero, the returned value will also be zero. fn claim_total(&mut self) -> PromiseOrValue; - /// Claims interest from specific deposit jars with provided indices. + /// Claims interest from specific deposit jars with provided IDs. /// /// # Arguments /// - /// * `jar_indices` - A `Vec` containing the indices of the deposit jars from which interest is being claimed. + /// * `jar_ids` - A `Vec` containing the IDs of the deposit jars from which interest is being claimed. /// * `amount` - An optional `TokenAmount` specifying the desired amount of tokens to claim. If provided, the method /// will attempt to claim this specific amount of tokens. If not provided or if the specified amount /// is greater than the total available interest in the provided jars, the method will claim the maximum @@ -34,7 +34,7 @@ pub trait ClaimApi { /// /// A `PromiseOrValue` representing the amount of tokens claimed. If the total available interest /// across the specified jars is zero or the provided `amount` is zero, the returned value will also be zero. - fn claim_jars(&mut self, jar_indices: Vec, amount: Option) -> PromiseOrValue; + fn claim_jars(&mut self, jar_ids: Vec, amount: Option) -> PromiseOrValue; } #[ext_contract(ext_self)] @@ -46,18 +46,19 @@ pub trait ClaimCallbacks { impl ClaimApi for Contract { fn claim_total(&mut self) -> PromiseOrValue { let account_id = env::predecessor_account_id(); - let jar_indices = self.account_jar_ids(&account_id); - self.claim_jars(jar_indices, None) + let jar_ids = self.account_jars(&account_id).iter().map(|a| a.id).collect(); + self.claim_jars(jar_ids, None) } - fn claim_jars(&mut self, jar_indices: Vec, amount: Option) -> PromiseOrValue { + fn claim_jars(&mut self, jar_ids: Vec, amount: Option) -> PromiseOrValue { let account_id = env::predecessor_account_id(); let now = env::block_timestamp_ms(); - let unlocked_jars: Vec = jar_indices - .into_iter() - .map(|index| self.get_jar_internal(index)) - .filter(|jar| !jar.is_pending_withdraw && jar.account_id == account_id) + let unlocked_jars: Vec = self + .account_jars(&account_id) + .iter() + .filter(|jar| !jar.is_pending_withdraw && jar_ids.contains(&jar.id)) + .cloned() .collect(); let mut total_interest_to_claim: TokenAmount = 0; @@ -71,41 +72,77 @@ impl ClaimApi for Contract { cmp::min(available_interest, amount.0 - total_interest_to_claim) }); - let updated_jar = jar.claimed(available_interest, interest_to_claim, now).locked(); - self.jars.replace(jar.index, updated_jar); + self.get_jar_mut_internal(&jar.account_id, jar.id) + .claim(available_interest, interest_to_claim, now) + .lock(); if interest_to_claim > 0 { total_interest_to_claim += interest_to_claim; event_data.push(ClaimEventItem { - index: jar.index, + id: jar.id, interest_to_claim: U128(interest_to_claim), }); } } if total_interest_to_claim > 0 { - self.ft_contract() - .transfer(&account_id, total_interest_to_claim, "claim", &None) - .then(after_claim_call( - U128(total_interest_to_claim), - unlocked_jars, - EventKind::Claim(event_data), - )) - .into() + self.claim_interest( + &account_id, + U128(total_interest_to_claim), + unlocked_jars, + EventKind::Claim(event_data), + ) } else { PromiseOrValue::Value(U128(0)) } } } -#[near_bindgen] -impl ClaimCallbacks for Contract { - #[private] - fn after_claim(&mut self, claimed_amount: U128, jars_before_transfer: Vec, event: EventKind) -> U128 { - if is_promise_success() { +impl Contract { + #[cfg(test)] + fn claim_interest( + &mut self, + _account_id: &AccountId, + claimed_amount: U128, + jars_before_transfer: Vec, + event: EventKind, + ) -> PromiseOrValue { + PromiseOrValue::Value(self.after_claim_internal(claimed_amount, jars_before_transfer, event, true)) + } + + #[cfg(not(test))] + fn claim_interest( + &mut self, + account_id: &AccountId, + claimed_amount: U128, + jars_before_transfer: Vec, + event: EventKind, + ) -> PromiseOrValue { + self.ft_contract() + .transfer(account_id, claimed_amount.0, "claim", &None) + .then(after_claim_call(claimed_amount, jars_before_transfer, event)) + .into() + } + + fn after_claim_internal( + &mut self, + claimed_amount: U128, + jars_before_transfer: Vec, + event: EventKind, + is_promise_success: bool, + ) -> U128 { + if is_promise_success { for jar_before_transfer in jars_before_transfer { - self.get_jar_mut_internal(jar_before_transfer.index).unlock(); + let jar = self.get_jar_mut_internal(&jar_before_transfer.account_id, jar_before_transfer.id); + + jar.unlock(); + + if let Some(ref cache) = jar.cache { + if cache.interest == 0 && jar.principal == 0 { + self.delete_jar(jar_before_transfer); + } + } } emit(event); @@ -113,8 +150,10 @@ impl ClaimCallbacks for Contract { claimed_amount } else { for jar_before_transfer in jars_before_transfer { - self.jars - .replace(jar_before_transfer.index, jar_before_transfer.unlocked()); + let account_id = jar_before_transfer.account_id.clone(); + let jar_id = jar_before_transfer.id; + + *self.get_jar_mut_internal(&account_id, jar_id) = jar_before_transfer.unlocked(); } U128(0) @@ -122,6 +161,15 @@ impl ClaimCallbacks for Contract { } } +#[near_bindgen] +impl ClaimCallbacks for Contract { + #[private] + fn after_claim(&mut self, claimed_amount: U128, jars_before_transfer: Vec, event: EventKind) -> U128 { + self.after_claim_internal(claimed_amount, jars_before_transfer, event, is_promise_success()) + } +} + +#[cfg(not(test))] fn after_claim_call(claimed_amount: U128, jars_before_transfer: Vec, event: EventKind) -> Promise { ext_self::ext(env::current_account_id()) .with_static_gas(GAS_FOR_AFTER_CLAIM) diff --git a/contract/src/claim/mod.rs b/contract/src/claim/mod.rs index 27d5837b..a7bbbbc9 100644 --- a/contract/src/claim/mod.rs +++ b/contract/src/claim/mod.rs @@ -1,57 +1,2 @@ pub mod api; - -#[cfg(test)] -mod tests { - use near_sdk::{json_types::U128, test_utils::accounts, PromiseOrValue}; - - use crate::{ - claim::api::ClaimApi, - common::{tests::Context, u32::U32, udecimal::UDecimal, MS_IN_YEAR}, - jar::{api::JarApi, model::Jar}, - product::model::{Apy, Product}, - }; - - #[test] - fn claim_total_when_nothing_to_claim() { - let alice = accounts(0); - let admin = accounts(1); - - let product = generate_product(); - let jar = Jar::generate(0, &alice, &product.id).principal(100_000_000); - let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar]); - - context.switch_account(&alice); - let result = context.contract.claim_total(); - - if let PromiseOrValue::Value(value) = result { - assert_eq!(0, value.0); - } else { - panic!(); - } - } - - #[test] - fn claim_partially_when_having_tokens_to_claim() { - let alice = accounts(0); - let admin = accounts(1); - - let product = generate_product(); - let jar = Jar::generate(0, &alice, &product.id).principal(100_000_000_000); - let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar.clone()]); - - context.set_block_timestamp_in_days(365); - - context.switch_account(&alice); - context.contract.claim_jars(vec![jar.index], Some(U128(100))); - - let jar = context.contract.get_jar(U32(jar.index)); - assert_eq!(100, jar.claimed_balance.0); - } - - fn generate_product() -> Product { - Product::generate("product") - .enabled(true) - .lockup_term(MS_IN_YEAR) - .apy(Apy::Constant(UDecimal::new(12, 2))) - } -} +mod tests; diff --git a/contract/src/claim/tests.rs b/contract/src/claim/tests.rs new file mode 100644 index 00000000..d8098d0a --- /dev/null +++ b/contract/src/claim/tests.rs @@ -0,0 +1,157 @@ +#![cfg(test)] + +use near_sdk::{json_types::U128, test_utils::accounts, PromiseOrValue}; + +use crate::{ + claim::api::ClaimApi, + common::{tests::Context, u32::U32, udecimal::UDecimal, MS_IN_YEAR}, + jar::{api::JarApi, model::Jar}, + product::model::{Apy, Product}, + withdraw::api::WithdrawApi, +}; + +#[test] +fn claim_total_when_nothing_to_claim() { + let alice = accounts(0); + let admin = accounts(1); + + let product = generate_product(); + let jar = Jar::generate(0, &alice, &product.id).principal(100_000_000); + let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar]); + + context.switch_account(&alice); + let result = context.contract.claim_total(); + + let PromiseOrValue::Value(value) = result else { + panic!(); + }; + + assert_eq!(0, value.0); +} + +#[test] +fn claim_partially_when_having_tokens_to_claim() { + let alice = accounts(0); + let admin = accounts(1); + + let product = generate_product(); + let jar = Jar::generate(0, &alice, &product.id).principal(100_000_000_000); + let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar.clone()]); + + context.set_block_timestamp_in_days(365); + + context.switch_account(&alice); + let PromiseOrValue::Value(claimed) = context.contract.claim_jars(vec![jar.id], Some(U128(100))) else { + panic!() + }; + + dbg!(&claimed); + + let jar = context.contract.get_jar(alice, U32(jar.id)); + assert_eq!(100, jar.claimed_balance.0); +} + +#[test] +fn dont_delete_jar_on_all_interest_claim() { + let alice = accounts(0); + let admin = accounts(1); + + let product = generate_product().apy(Apy::Constant(UDecimal::new(2, 1))); + let jar = Jar::generate(0, &alice, &product.id).principal(1_000_000); + let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar.clone()]); + + context.set_block_timestamp_in_days(365); + + context.switch_account(&alice); + context.contract.claim_jars(vec![jar.id], Some(U128(200_000))); + + let jar = context.contract.get_jar_internal(&alice, jar.id); + assert_eq!(200_000, jar.claimed_balance); + + let Some(ref cache) = jar.cache else { panic!() }; + + assert_eq!(cache.interest, 0); + assert_eq!(jar.principal, 1_000_000); +} + +#[test] +#[should_panic(expected = "Jar with id: 0 doesn't exist")] +fn claim_all_withdraw_all_and_delete_jar() { + let alice = accounts(0); + let admin = accounts(1); + + let product = generate_product().apy(Apy::Constant(UDecimal::new(2, 1))); + let jar = Jar::generate(0, &alice, &product.id).principal(1_000_000); + + let jar_id = jar.id; + + let mut context = Context::new(admin) + .with_products(&[product.clone()]) + .with_jars(&[jar.clone()]); + + context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); + + context.switch_account(&alice); + context.contract.claim_jars(vec![jar_id], Some(U128(200_000))); + + let jar = context.contract.get_jar_internal(&alice, jar_id); + assert_eq!(200_000, jar.claimed_balance); + + let Some(ref cache) = jar.cache else { panic!() }; + + assert_eq!(cache.interest, 0); + assert_eq!(jar.principal, 1_000_000); + + let PromiseOrValue::Value(withdrawn) = context.contract.withdraw(U32(jar_id), None) else { + panic!() + }; + + assert_eq!(withdrawn.withdrawn_amount, U128(1_000_000)); + assert_eq!(withdrawn.fee, U128(0)); + + let _jar = context.contract.get_jar_internal(&alice, jar_id); +} + +#[test] +#[should_panic(expected = "Jar with id: 0 doesn't exist")] +fn withdraw_all_claim_all_and_delete_jar() { + let alice = accounts(0); + let admin = accounts(1); + + let product = generate_product().apy(Apy::Constant(UDecimal::new(2, 1))); + let jar = Jar::generate(0, &alice, &product.id).principal(1_000_000); + + let jar_id = jar.id; + + let mut context = Context::new(admin) + .with_products(&[product.clone()]) + .with_jars(&[jar.clone()]); + + context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); + + context.switch_account(&alice); + + let PromiseOrValue::Value(withdrawn) = context.contract.withdraw(U32(jar_id), None) else { + panic!() + }; + + assert_eq!(withdrawn.withdrawn_amount, U128(1_000_000)); + assert_eq!(withdrawn.fee, U128(0)); + + let jar = context.contract.get_jar_internal(&alice, jar_id); + + let PromiseOrValue::Value(claimed) = context.contract.claim_jars(vec![jar_id], Some(U128(200_000))) else { + panic!(); + }; + + assert_eq!(claimed, U128(200_000)); + + let _jar = context.contract.get_jar_internal(&alice, jar_id); +} + +fn generate_product() -> Product { + Product::generate("product") + .enabled(true) + .lockup_term(MS_IN_YEAR) + .apy(Apy::Constant(UDecimal::new(12, 2))) +} diff --git a/contract/src/common/mod.rs b/contract/src/common/mod.rs index 69e8a5bb..f39c579e 100644 --- a/contract/src/common/mod.rs +++ b/contract/src/common/mod.rs @@ -29,11 +29,11 @@ pub const fn tgas(val: u64) -> Gas { const INITIAL_GAS_FOR_AFTER_CLAIM: u64 = 4 * TERA; /// Cost of adding 1 additional jar in after claim call. Measured with `measure_after_claim_total_test` -const ADDITIONAL_AFTER_CLAIM_JAR_COST: u64 = 300 * GIGA; +const ADDITIONAL_AFTER_CLAIM_JAR_COST: u64 = 80 * GIGA; /// Values are measured with `measure_after_claim_total_test` /// For now number of jars is arbitrary -pub(crate) const GAS_FOR_AFTER_CLAIM: Gas = Gas(INITIAL_GAS_FOR_AFTER_CLAIM + ADDITIONAL_AFTER_CLAIM_JAR_COST * 50); +pub(crate) const GAS_FOR_AFTER_CLAIM: Gas = Gas(INITIAL_GAS_FOR_AFTER_CLAIM + ADDITIONAL_AFTER_CLAIM_JAR_COST * 200); /// Value is measured with `measure_withdraw_test` /// Average gas for this method call don't exceed 3.4 `TGas`. 4 here just in case. diff --git a/contract/src/common/tests.rs b/contract/src/common/tests.rs index 4e26e0a6..bdaa399f 100644 --- a/contract/src/common/tests.rs +++ b/contract/src/common/tests.rs @@ -48,13 +48,11 @@ impl Context { pub(crate) fn with_jars(mut self, jars: &[Jar]) -> Self { for jar in jars { - self.contract.jars.push(jar.clone()); - self.contract .account_jars .entry(jar.account_id.clone()) .or_default() - .insert(jar.index); + .push(jar.clone()); } self diff --git a/contract/src/event.rs b/contract/src/event.rs index a323ce9a..228dc2be 100644 --- a/contract/src/event.rs +++ b/contract/src/event.rs @@ -7,7 +7,7 @@ use near_sdk::{ use crate::{ env, - jar::model::{Jar, JarIndex}, + jar::model::{Jar, JarId}, product::model::{Product, ProductId}, PACKAGE_NAME, VERSION, }; @@ -44,35 +44,35 @@ struct SweatJarEvent { #[derive(Serialize, Deserialize, Debug)] #[serde(crate = "near_sdk::serde")] pub struct ClaimEventItem { - pub index: JarIndex, + pub id: JarId, pub interest_to_claim: U128, } #[derive(Serialize, Deserialize, Debug)] #[serde(crate = "near_sdk::serde")] pub struct WithdrawData { - pub index: JarIndex, + pub id: JarId, } #[derive(Serialize, Deserialize, Debug)] #[serde(crate = "near_sdk::serde")] pub struct MigrationEventItem { pub original_id: String, - pub index: JarIndex, + pub id: JarId, pub account_id: AccountId, } #[derive(Serialize, Deserialize, Debug)] #[serde(crate = "near_sdk::serde")] pub struct RestakeData { - pub old_index: JarIndex, - pub new_index: JarIndex, + pub old_id: JarId, + pub new_id: JarId, } #[derive(Serialize, Deserialize, Debug)] #[serde(crate = "near_sdk::serde")] pub struct PenaltyData { - pub index: JarIndex, + pub id: JarId, pub is_applied: bool, } @@ -93,7 +93,7 @@ pub struct ChangeProductPublicKeyData { #[derive(Serialize, Deserialize, Debug)] #[serde(crate = "near_sdk::serde")] pub struct TopUpData { - pub index: JarIndex, + pub id: JarId, pub amount: U128, } diff --git a/contract/src/ft_interface.rs b/contract/src/ft_interface.rs index f70a8224..b73e1adc 100644 --- a/contract/src/ft_interface.rs +++ b/contract/src/ft_interface.rs @@ -22,6 +22,7 @@ pub struct Fee { } impl FungibleTokenContract { + #[cfg(not(test))] fn new(address: AccountId) -> Self { Self { address } } @@ -29,6 +30,7 @@ impl FungibleTokenContract { #[near_bindgen] impl Contract { + #[cfg(not(test))] pub(crate) fn ft_contract(&self) -> impl FungibleTokenInterface { FungibleTokenContract::new(self.token_account_id.clone()) } diff --git a/contract/src/ft_receiver.rs b/contract/src/ft_receiver.rs index 076ec7d2..f3205e22 100644 --- a/contract/src/ft_receiver.rs +++ b/contract/src/ft_receiver.rs @@ -8,7 +8,7 @@ use near_sdk::{ use crate::{ jar::model::JarTicket, migration::model::CeFiJar, near_bindgen, AccountId, Base64VecU8, Contract, ContractExt, - JarIndex, + JarId, }; /// The `FtMessage` enum represents various commands for actions available via transferring tokens to an account @@ -22,8 +22,8 @@ pub enum FtMessage { /// Represents a request to create DeFi Jars from provided CeFi Jars. Migrate(Vec), - /// Represents a request to refill (top up) an existing jar using its `JarIndex`. - TopUp(JarIndex), + /// Represents a request to refill (top up) an existing jar using its `JarId`. + TopUp(JarId), } /// The `StakeMessage` struct represents a request to create a new jar for a corresponding product. @@ -57,8 +57,8 @@ impl FungibleTokenReceiver for Contract { self.migrate_jars(jars, amount); } - FtMessage::TopUp(jar_index) => { - self.top_up(jar_index, amount); + FtMessage::TopUp(jar_id) => { + self.top_up(&sender_id, jar_id, amount); } } @@ -102,10 +102,12 @@ mod tests { }); context.switch_account_to_ft_contract_account(); - context.contract.ft_on_transfer(alice, U128(1_000_000), msg.to_string()); + context + .contract + .ft_on_transfer(alice.clone(), U128(1_000_000), msg.to_string()); - let jar = context.contract.get_jar(U32(0)); - assert_eq!(jar.index.0, 0); + let jar = context.contract.get_jar(alice, U32(1)); + assert_eq!(jar.id.0, 1); } #[test] @@ -147,8 +149,8 @@ mod tests { .contract .ft_on_transfer(alice.clone(), U128(ticket_amount), msg.to_string()); - let jar = context.contract.get_jar(U32(0)); - assert_eq!(jar.index.0, 0); + let jar = context.contract.get_jar(alice.clone(), U32(1)); + assert_eq!(jar.id.0, 1); let result = catch_unwind(move || { context @@ -177,16 +179,16 @@ mod tests { let msg = json!({ "type": "top_up", - "data": reference_jar.index, + "data": reference_jar.id, }); context.switch_account_to_ft_contract_account(); let top_up_amount = 700; context .contract - .ft_on_transfer(alice, U128(top_up_amount), msg.to_string()); + .ft_on_transfer(alice.clone(), U128(top_up_amount), msg.to_string()); - let jar = context.contract.get_jar(U32(0)); + let jar = context.contract.get_jar(alice, U32(0)); assert_eq!(initial_jar_principal + top_up_amount, jar.principal.0); } @@ -209,7 +211,7 @@ mod tests { let msg = json!({ "type": "top_up", - "data": reference_jar.index, + "data": reference_jar.id, }); context.switch_account_to_ft_contract_account(); @@ -235,7 +237,7 @@ mod tests { let msg = json!({ "type": "top_up", - "data": reference_jar.index, + "data": reference_jar.id, }); context.switch_account_to_ft_contract_account(); @@ -243,9 +245,9 @@ mod tests { let top_up_amount = 1_000; context .contract - .ft_on_transfer(alice, U128(top_up_amount), msg.to_string()); + .ft_on_transfer(alice.clone(), U128(top_up_amount), msg.to_string()); - let jar = context.contract.get_jar(U32(0)); + let jar = context.contract.get_jar(alice, U32(0)); assert_eq!(initial_jar_principal + top_up_amount, jar.principal.0); } diff --git a/contract/src/internal.rs b/contract/src/internal.rs index 634966a5..9ca84e95 100644 --- a/contract/src/internal.rs +++ b/contract/src/internal.rs @@ -1,6 +1,10 @@ use near_sdk::require; -use crate::{env, AccountId, Contract, Jar, JarIndex, Product, ProductId}; +use crate::{ + env, + jar::{model::JarId, view::JarIdView}, + AccountId, Contract, Jar, Product, ProductId, +}; impl Contract { pub(crate) fn assert_manager(&self) { @@ -17,38 +21,47 @@ impl Contract { ); } + pub(crate) fn increment_and_get_last_jar_id(&mut self) -> JarId { + self.last_jar_id += 1; + self.last_jar_id + } + pub(crate) fn get_product(&self, product_id: &ProductId) -> &Product { self.products .get(product_id) - .unwrap_or_else(|| env::panic_str(&format!("Product {product_id} doesn't exist"))) + .unwrap_or_else(|| env::panic_str(&format!("Product '{product_id}' doesn't exist"))) } pub(crate) fn get_product_mut(&mut self, product_id: &ProductId) -> &mut Product { self.products .get_mut(product_id) - .unwrap_or_else(|| env::panic_str(&format!("Product {product_id} doesn't exist"))) + .unwrap_or_else(|| env::panic_str(&format!("Product '{product_id}' doesn't exist"))) } - pub(crate) fn account_jar_ids(&self, account_id: &AccountId) -> Vec { - self.account_jars - .get(account_id) - .map_or_else(Vec::new, |items| items.iter().copied().collect()) + pub(crate) fn account_jars(&self, account_id: &AccountId) -> &[Jar] { + self.account_jars.get(account_id).map_or(&[], |jars| jars.as_slice()) } - pub(crate) fn save_jar(&mut self, account_id: &AccountId, jar: Jar) { - let jar_index = jar.index; - self.insert_or_update_jar(jar); - self.account_jars - .entry(account_id.clone()) - .or_default() - .insert(jar_index); - } + pub(crate) fn account_jars_with_ids(&self, account_id: &AccountId, ids: &[JarIdView]) -> Vec<&Jar> { + let mut result: Vec<&Jar> = vec![]; + + let all_jars = self.account_jars(account_id); - fn insert_or_update_jar(&mut self, jar: Jar) { - if jar.index < self.jars.len() { - self.jars.replace(jar.index, jar); - } else { - self.jars.push(jar); + for id in ids { + result.push( + all_jars + .iter() + .find(|jar| jar.id == id.0) + .unwrap_or_else(|| env::panic_str(&format!("Jar with id: '{}' doesn't exist", id.0))), + ); } + + result + } + + pub(crate) fn add_new_jar(&mut self, account_id: &AccountId, jar: Jar) { + let jars = self.account_jars.entry(account_id.clone()).or_default(); + jars.last_id = jar.id; + jars.push(jar); } } diff --git a/contract/src/jar/api.rs b/contract/src/jar/api.rs index fe8333ea..cf26ece4 100644 --- a/contract/src/jar/api.rs +++ b/contract/src/jar/api.rs @@ -6,8 +6,8 @@ use crate::{ assert_ownership, common::{u32::U32, TokenAmount}, event::{emit, EventKind, RestakeData}, - jar::view::{AggregatedInterestView, AggregatedTokenAmountView, JarIndexView, JarView}, - Contract, ContractExt, Jar, JarIndex, + jar::view::{AggregatedInterestView, AggregatedTokenAmountView, JarIdView, JarView}, + Contract, ContractExt, Jar, }; /// The `JarApi` trait defines methods for managing deposit jars and their associated data within the smart contract. @@ -16,12 +16,12 @@ pub trait JarApi { /// /// # Arguments /// - /// * `jar_index` - The index of the deposit jar for which information is being retrieved. + /// * `jar_id` - The ID of the deposit jar for which information is being retrieved. /// /// # Returns /// /// A `JarView` struct containing details about the specified deposit jar. - fn get_jar(&self, jar_index: JarIndexView) -> JarView; + fn get_jar(&self, account_id: AccountId, jar_id: JarIdView) -> JarView; /// Retrieves information about all deposit jars associated with a given account. /// @@ -50,13 +50,15 @@ pub trait JarApi { /// /// # Arguments /// - /// * `jar_indices` - A `Vec` containing the indices of the deposit jars for which the + /// * `jar_ids` - A `Vec` containing the IDs of the deposit jars for which the /// principal is being retrieved. /// + /// * `account_id` - The `AccountId` of the account for which the principal is being retrieved. + /// /// # Returns /// /// An `U128` representing the sum of principal amounts for the specified deposit jars. - fn get_principal(&self, jar_indices: Vec) -> AggregatedTokenAmountView; + fn get_principal(&self, jar_ids: Vec, account_id: AccountId) -> AggregatedTokenAmountView; /// Retrieves the total interest amount across all deposit jars for a provided account. /// @@ -74,20 +76,20 @@ pub trait JarApi { /// /// # Arguments /// - /// * `jar_indices` - A `Vec` containing the indices of the deposit jars for which the + /// * `jar_ids` - A `Vec` containing the IDs of the deposit jars for which the /// interest is being retrieved. /// /// # Returns /// /// An `U128` representing the sum of interest amounts for the specified deposit jars. /// - fn get_interest(&self, jar_indices: Vec) -> AggregatedInterestView; + fn get_interest(&self, jar_ids: Vec, account_id: AccountId) -> AggregatedInterestView; /// Restakes the contents of a specified deposit jar into a new jar. /// /// # Arguments /// - /// * `jar_index` - The index of the deposit jar from which the restaking is being initiated. + /// * `jar_id` - The ID of the deposit jar from which the restaking is being initiated. /// /// # Returns /// @@ -99,37 +101,35 @@ pub trait JarApi { /// - If the product of the original jar does not support restaking. /// - If the function is called by an account other than the owner of the original jar. /// - If the original jar is not yet mature. - fn restake(&mut self, jar_index: JarIndexView) -> JarView; + fn restake(&mut self, jar_id: JarIdView) -> JarView; } #[near_bindgen] impl JarApi for Contract { - fn get_jar(&self, jar_index: JarIndexView) -> JarView { - self.get_jar_internal(jar_index.0).into() + fn get_jar(&self, account_id: AccountId, jar_id: JarIdView) -> JarView { + self.get_jar_internal(&account_id, jar_id.0).into() } fn get_jars_for_account(&self, account_id: AccountId) -> Vec { - self.account_jar_ids(&account_id) - .into_iter() - .map(|index| self.get_jar(U32(index))) - .collect() + self.account_jars(&account_id).iter().map(Into::into).collect() } fn get_total_principal(&self, account_id: AccountId) -> AggregatedTokenAmountView { - let jar_indices = self.account_jar_ids(&account_id).into_iter().map(U32).collect(); - - self.get_principal(jar_indices) + self.get_principal( + self.account_jars(&account_id).iter().map(|a| U32(a.id)).collect(), + account_id, + ) } - fn get_principal(&self, jar_indices: Vec) -> AggregatedTokenAmountView { - let mut detailed_amounts = HashMap::::new(); + fn get_principal(&self, jar_ids: Vec, account_id: AccountId) -> AggregatedTokenAmountView { + let mut detailed_amounts = HashMap::::new(); let mut total_amount: TokenAmount = 0; - for index in jar_indices { - let index = index.0; - let principal = self.get_jar_internal(index).principal; + for jar in self.account_jars_with_ids(&account_id, &jar_ids) { + let id = jar.id; + let principal = jar.principal; - detailed_amounts.insert(U32(index), U128(principal)); + detailed_amounts.insert(U32(id), U128(principal)); total_amount += principal; } @@ -140,23 +140,22 @@ impl JarApi for Contract { } fn get_total_interest(&self, account_id: AccountId) -> AggregatedInterestView { - let jar_indices = self.account_jar_ids(&account_id).into_iter().map(U32).collect(); - - self.get_interest(jar_indices) + self.get_interest( + self.account_jars(&account_id).iter().map(|a| U32(a.id)).collect(), + account_id, + ) } - fn get_interest(&self, jar_indices: Vec) -> AggregatedInterestView { + fn get_interest(&self, jar_ids: Vec, account_id: AccountId) -> AggregatedInterestView { let now = env::block_timestamp_ms(); - let mut detailed_amounts = HashMap::::new(); + let mut detailed_amounts = HashMap::::new(); let mut total_amount: TokenAmount = 0; - for index in jar_indices { - let index = index.0; - let jar = self.get_jar_internal(index); + for jar in self.account_jars_with_ids(&account_id, &jar_ids) { let interest = jar.get_interest(self.get_product(&jar.product_id), now); - detailed_amounts.insert(U32(index), U128(interest)); + detailed_amounts.insert(U32(jar.id), U128(interest)); total_amount += interest; } @@ -169,12 +168,15 @@ impl JarApi for Contract { } } - fn restake(&mut self, jar_index: JarIndexView) -> JarView { - let jar_index = jar_index.0; - let jar = self.get_jar_internal(jar_index); + fn restake(&mut self, jar_id: JarIdView) -> JarView { + let jar_id = jar_id.0; let account_id = env::predecessor_account_id(); - assert_ownership(&jar, &account_id); + let restaked_jar_id = self.increment_and_get_last_jar_id(); + + let jar = self.get_jar_internal(&account_id, jar_id); + + assert_ownership(jar, &account_id); let product = self.get_product(&jar.product_id); @@ -185,22 +187,30 @@ impl JarApi for Contract { require!(jar.is_liquidable(product, now), "The jar is not mature yet"); require!(!jar.is_empty(), "The jar is empty, nothing to restake"); - let index = self.jars.len() as JarIndex; + let principal = jar.principal; + let new_jar = Jar::create( - index, + restaked_jar_id, jar.account_id.clone(), jar.product_id.clone(), - jar.principal, + principal, now, ); - let withdraw_jar = jar.withdrawn(product, jar.principal, now); - self.save_jar(&account_id, withdraw_jar); - self.save_jar(&account_id, new_jar.clone()); + let (should_be_closed, withdraw_jar) = jar.withdrawn(product, principal, now); + + if should_be_closed { + self.delete_jar(withdraw_jar); + } else { + let jar_id = withdraw_jar.id; + *self.get_jar_mut_internal(&account_id, jar_id) = withdraw_jar; + } + + self.add_new_jar(&account_id, new_jar.clone()); emit(EventKind::Restake(RestakeData { - old_index: jar_index, - new_index: new_jar.index, + old_id: jar_id, + new_id: new_jar.id, })); new_jar.into() diff --git a/contract/src/jar/model.rs b/contract/src/jar/model.rs index b3e99041..7b745a3f 100644 --- a/contract/src/jar/model.rs +++ b/contract/src/jar/model.rs @@ -4,7 +4,7 @@ use ed25519_dalek::{VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH}; use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, env, - env::sha256, + env::{panic_str, sha256}, json_types::{U128, U64}, require, serde::{Deserialize, Serialize}, @@ -16,10 +16,10 @@ use crate::{ event::{emit, EventKind, TopUpData}, jar::view::JarView, product::model::{Apy, Product, ProductId, Terms}, - Base64VecU8, Contract, Signature, + Base64VecU8, Contract, JarsStorage, Signature, }; -pub type JarIndex = u32; +pub type JarId = u32; /// The `JarTicket` struct represents a request to create a deposit jar for a corresponding product. /// @@ -43,11 +43,13 @@ pub struct JarTicket { } /// The `Jar` struct represents a deposit jar within the smart contract. -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, Debug)] +#[derive( + BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, +)] #[serde(crate = "near_sdk::serde", rename_all = "snake_case")] pub struct Jar { - /// The index of the jar in the `Contracts.jars` vector. Also serves as the unique identifier for the jar. - pub index: JarIndex, + /// The unique identifier for the jar. + pub id: JarId, /// The account ID of the owner of the jar. pub account_id: AccountId, @@ -72,9 +74,6 @@ pub struct Jar { /// Indicates whether an operation involving cross-contract calls is in progress for this jar. pub is_pending_withdraw: bool, - /// The state of the jar, indicating whether it is active or closed. - pub state: JarState, - /// Indicates whether a penalty has been applied to the jar's owner due to violating product terms. pub is_penalty_applied: bool, } @@ -82,37 +81,25 @@ pub struct Jar { /// A cached value that stores calculated interest based on the current state of the jar. /// This cache is updated whenever properties that impact interest calculation change, /// allowing for efficient interest calculations between state changes. -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, Debug)] +#[derive( + BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, +)] #[serde(crate = "near_sdk::serde")] pub struct JarCache { pub updated_at: Timestamp, pub interest: TokenAmount, } -/// The state of a jar, indicating whether it is active or closed. -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, Eq, PartialEq, Debug)] -#[serde(crate = "near_sdk::serde", rename_all = "snake_case")] -pub enum JarState { - Active, - Closed, -} - -impl JarState { - pub(crate) fn is_active(&self) -> bool { - matches!(self, JarState::Active) - } -} - impl Jar { pub(crate) fn create( - index: JarIndex, + id: JarId, account_id: AccountId, product_id: ProductId, principal: TokenAmount, created_at: Timestamp, ) -> Self { Self { - index, + id, account_id, product_id, principal, @@ -120,7 +107,6 @@ impl Jar { cache: None, claimed_balance: 0, is_pending_withdraw: false, - state: JarState::Active, is_penalty_applied: false, } } @@ -132,6 +118,10 @@ impl Jar { } } + pub(crate) fn lock(&mut self) { + self.is_pending_withdraw = true; + } + pub(crate) fn unlocked(&self) -> Self { Self { is_pending_withdraw: false, @@ -143,49 +133,49 @@ impl Jar { self.is_pending_withdraw = false; } - pub(crate) fn with_penalty_applied(&self, is_applied: bool) -> Self { - Self { - is_penalty_applied: is_applied, - ..self.clone() - } + pub(crate) fn apply_penalty(&mut self, is_applied: bool) { + self.is_penalty_applied = is_applied; } - pub(crate) fn topped_up(&self, amount: TokenAmount, product: &Product, now: Timestamp) -> Self { + pub(crate) fn top_up(&mut self, amount: TokenAmount, product: &Product, now: Timestamp) -> &mut Self { let current_interest = self.get_interest(product, now); - Self { - principal: self.principal + amount, - cache: Some(JarCache { - updated_at: now, - interest: current_interest, - }), - ..self.clone() - } + + self.principal += amount; + self.cache = Some(JarCache { + updated_at: now, + interest: current_interest, + }); + self } - pub(crate) fn claimed(&self, available_yield: TokenAmount, claimed_amount: TokenAmount, now: Timestamp) -> Self { - Self { - claimed_balance: self.claimed_balance + claimed_amount, - cache: Some(JarCache { - updated_at: now, - interest: available_yield - claimed_amount, - }), - ..self.clone() - } + pub(crate) fn claim( + &mut self, + available_yield: TokenAmount, + claimed_amount: TokenAmount, + now: Timestamp, + ) -> &mut Self { + self.claimed_balance += claimed_amount; + self.cache = Some(JarCache { + updated_at: now, + interest: available_yield - claimed_amount, + }); + self } - pub(crate) fn withdrawn(&self, product: &Product, withdrawn_amount: TokenAmount, now: Timestamp) -> Self { + pub(crate) fn withdrawn(&self, product: &Product, withdrawn_amount: TokenAmount, now: Timestamp) -> (bool, Self) { let current_interest = self.get_interest(product, now); - let state = get_final_state(product, self, withdrawn_amount); - Self { - principal: self.principal - withdrawn_amount, - cache: Some(JarCache { - updated_at: now, - interest: current_interest, - }), - state, - ..self.clone() - } + ( + should_be_closed(product, self, withdrawn_amount) && current_interest == 0, + Self { + principal: self.principal - withdrawn_amount, + cache: Some(JarCache { + updated_at: now, + interest: current_interest, + }), + ..self.clone() + }, + ) } /// Indicates whether a user can withdraw tokens from the jar at the moment or not. @@ -245,12 +235,8 @@ impl Jar { } } -fn get_final_state(product: &Product, original_jar: &Jar, withdrawn_amount: TokenAmount) -> JarState { - if product.is_flexible() || original_jar.principal - withdrawn_amount > 0 { - JarState::Active - } else { - JarState::Closed - } +fn should_be_closed(product: &Product, original_jar: &Jar, withdrawn_amount: TokenAmount) -> bool { + !(product.is_flexible() || original_jar.principal - withdrawn_amount > 0) } impl Contract { @@ -269,76 +255,106 @@ impl Contract { product.assert_cap(amount); self.verify(&account_id, amount, &ticket, signature); - let index = self.jars.len() as JarIndex; + let id = self.increment_and_get_last_jar_id(); let now = env::block_timestamp_ms(); - let jar = Jar::create(index, account_id.clone(), product_id.clone(), amount, now); + let jar = Jar::create(id, account_id.clone(), product_id.clone(), amount, now); - self.save_jar(&account_id, jar.clone()); + self.add_new_jar(&account_id, jar.clone()); emit(EventKind::CreateJar(jar.clone())); jar.into() } - pub(crate) fn top_up(&mut self, jar_index: JarIndex, amount: U128) -> U128 { - let jar = self.get_jar_internal(jar_index); - - require!(jar.state.is_active(), "Closed jar doesn't allow top-ups"); - - let product = self.get_product(&jar.product_id); + pub(crate) fn top_up(&mut self, account: &AccountId, jar_id: JarId, amount: U128) -> U128 { + let jar = self.get_jar_internal(account, jar_id); + let product = self.get_product(&jar.product_id).clone(); require!(product.allows_top_up(), "The product doesn't allow top-ups"); product.assert_cap(jar.principal + amount.0); let now = env::block_timestamp_ms(); - let topped_up_jar = jar.topped_up(amount.0, product, now); - self.jars.replace(jar_index, topped_up_jar.clone()); + let principal = self + .get_jar_mut_internal(account, jar_id) + .top_up(amount.0, &product, now) + .principal; - emit(EventKind::TopUp(TopUpData { - index: jar_index, - amount, - })); + emit(EventKind::TopUp(TopUpData { id: jar_id, amount })); - U128(topped_up_jar.principal) + U128(principal) } - pub(crate) fn get_jar_mut_internal(&mut self, index: JarIndex) -> &mut Jar { - self.jars - .get_mut(index) - .unwrap_or_else(|| env::panic_str(&format!("Jar on index {index} doesn't exist"))) + pub(crate) fn delete_jar(&mut self, jar: Jar) { + let account = &jar.account_id; + + let jars = self + .account_jars + .get_mut(account) + .unwrap_or_else(|| env::panic_str(&format!("Account '{account}' doesn't exist"))); + + require!(!jars.is_empty(), "Trying to delete jar from empty account"); + + if jars.len() == 1 { + jars.clear(); + return; + } + + // On jar deletion, we move the last jar in the vector in the deleted jar's place. + // This way we don't need to shift all jars to fill empty space in the vector. + + let jar_position = jars + .iter() + .position(|j| j.id == jar.id) + .unwrap_or_else(|| env::panic_str(&format!("Jar with id {} doesn't exist", jar.id))); + + let last_jar = jars.pop().unwrap(); + + if jar_position != jars.len() { + jars[jar_position] = last_jar; + } } - pub(crate) fn get_jar_internal(&self, index: JarIndex) -> Jar { - self.jars.get(index).map_or_else( - || env::panic_str(&format!("Jar on index {index} doesn't exist")), - Clone::clone, - ) + pub(crate) fn get_jar_mut_internal(&mut self, account: &AccountId, id: JarId) -> &mut Jar { + self.account_jars + .get_mut(account) + .unwrap_or_else(|| env::panic_str(&format!("Account '{account}' doesn't exist"))) + .get_jar_mut(id) + } + + pub(crate) fn get_jar_internal(&self, account: &AccountId, id: JarId) -> &Jar { + self.account_jars + .get(account) + .unwrap_or_else(|| env::panic_str(&format!("Account '{account}' doesn't exist"))) + .get_jar(id) } pub(crate) fn verify( - &self, + &mut self, account_id: &AccountId, amount: TokenAmount, ticket: &JarTicket, signature: Option, ) { + let last_jar_id = self.account_jars.entry(account_id.clone()).or_default().last_id; let product = self.get_product(&ticket.product_id); + if let Some(pk) = &product.public_key { - let signature = signature.expect("Signature is required"); - let last_jar_index = self - .account_jars - .get(account_id) - .map(|jars| *jars.iter().max().unwrap_or_else(|| env::panic_str("Jar is empty."))); + let Some(signature) = signature else { + panic_str("Signature is required"); + }; - let hash = Self::get_ticket_hash(account_id, amount, ticket, last_jar_index); - let is_signature_valid = Self::verify_signature(&signature.0, pk, &hash); + let is_time_valid = env::block_timestamp_ms() <= ticket.valid_until.0; + require!(is_time_valid, "Ticket is outdated"); - require!(is_signature_valid, "Not matching signature"); + // If this is the first jar for this user ever, oracle will send empty string as nonce. + // Which is equivalent to `None` value here. + let last_jar_id = if last_jar_id == 0 { None } else { Some(last_jar_id) }; - let is_time_valid = env::block_timestamp_ms() <= ticket.valid_until.0; + let hash = Self::get_ticket_hash(account_id, amount, ticket, last_jar_id); + let is_signature_valid = Self::verify_signature(&signature.0, pk, &hash); - require!(is_time_valid, "Ticket is outdated"); + require!(is_signature_valid, "Not matching signature"); } } @@ -346,7 +362,7 @@ impl Contract { account_id: &AccountId, amount: TokenAmount, ticket: &JarTicket, - last_jar_index: Option, + last_jar_id: Option, ) -> Vec { sha256( Self::get_signature_material( @@ -355,7 +371,7 @@ impl Contract { &ticket.product_id, amount, ticket.valid_until.0, - last_jar_index, + last_jar_id, ) .as_bytes(), ) @@ -367,7 +383,7 @@ impl Contract { product_id: &ProductId, amount: TokenAmount, valid_until: Timestamp, - last_jar_index: Option, + last_jar_id: Option, ) -> String { format!( "{},{},{},{},{},{}", @@ -375,7 +391,7 @@ impl Contract { receiver_account_id, product_id, amount, - last_jar_index.map_or_else(String::new, |value| value.to_string(),), + last_jar_id.map_or_else(String::new, |value| value.to_string(),), valid_until, ) } diff --git a/contract/src/jar/tests.rs b/contract/src/jar/tests.rs index 92596e4b..774292bf 100644 --- a/contract/src/jar/tests.rs +++ b/contract/src/jar/tests.rs @@ -73,7 +73,7 @@ mod signature_tests { let signer = MessageSigner::new(); let reference_product = generate_premium_product("premium_product", &signer); - let context = Context::new(admin.clone()).with_products(&[reference_product.clone()]); + let mut context = Context::new(admin.clone()).with_products(&[reference_product.clone()]); let amount = 14_000_000; let ticket = JarTicket { @@ -96,7 +96,7 @@ mod signature_tests { let signer = MessageSigner::new(); let reference_product = generate_premium_product("premium_product", &signer); - let context = Context::new(admin).with_products(&[reference_product.clone()]); + let mut context = Context::new(admin).with_products(&[reference_product.clone()]); let amount = 1_000_000; let ticket = JarTicket { @@ -120,7 +120,7 @@ mod signature_tests { let product = generate_premium_product("premium_product", &signer); let another_product = generate_premium_product("another_premium_product", &MessageSigner::new()); - let context = Context::new(admin.clone()).with_products(&[product, another_product.clone()]); + let mut context = Context::new(admin.clone()).with_products(&[product, another_product.clone()]); let amount = 15_000_000; let ticket_for_another_product = JarTicket { @@ -169,7 +169,7 @@ mod signature_tests { } #[test] - #[should_panic(expected = "Product not_existing_product doesn't exist")] + #[should_panic(expected = "Product 'not_existing_product' doesn't exist")] fn verify_ticket_with_not_existing_product() { let admin = accounts(0); @@ -200,7 +200,7 @@ mod signature_tests { let signer = MessageSigner::new(); let product = generate_premium_product("not_existing_product", &signer); - let context = Context::new(admin.clone()).with_products(&[product.clone()]); + let mut context = Context::new(admin.clone()).with_products(&[product.clone()]); let amount = 3_000_000; let ticket = JarTicket { @@ -216,7 +216,7 @@ mod signature_tests { let admin = accounts(0); let product = generate_product("regular_product"); - let context = Context::new(admin.clone()).with_products(&[product.clone()]); + let mut context = Context::new(admin.clone()).with_products(&[product.clone()]); let amount = 4_000_000_000; let ticket = JarTicket { @@ -228,7 +228,7 @@ mod signature_tests { } #[test] - #[should_panic(expected = "Account doesn't own this jar")] + #[should_panic(expected = "Account 'bob' doesn't exist")] fn restake_by_not_owner() { let alice = accounts(0); let admin = accounts(1); @@ -240,7 +240,7 @@ mod signature_tests { .with_jars(&[alice_jar.clone()]); context.switch_account(&admin); - context.contract.restake(U32(alice_jar.index)); + context.contract.restake(U32(alice_jar.id)); } #[test] @@ -254,7 +254,7 @@ mod signature_tests { let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar.clone()]); context.switch_account(&alice); - context.contract.restake(U32(jar.index)); + context.contract.restake(U32(jar.id)); } #[test] @@ -268,7 +268,7 @@ mod signature_tests { let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar.clone()]); context.switch_account(&alice); - context.contract.restake(U32(jar.index)); + context.contract.restake(U32(jar.id)); } #[test] @@ -289,7 +289,7 @@ mod signature_tests { context.set_block_timestamp_in_days(366); context.switch_account(&alice); - context.contract.restake(U32(jar.index)); + context.contract.restake(U32(jar.id)); } #[test] @@ -307,7 +307,7 @@ mod signature_tests { context.set_block_timestamp_in_days(366); context.switch_account(&alice); - context.contract.restake(U32(jar.index)); + context.contract.restake(U32(jar.id)); } #[test] @@ -324,14 +324,15 @@ mod signature_tests { context.set_block_timestamp_in_days(366); context.switch_account(&alice); - context.contract.restake(U32(jar.index)); + context.contract.restake(U32(jar.id)); let alice_jars = context.contract.get_jars_for_account(alice); + assert_eq!(2, alice_jars.len()); - assert_eq!(0, alice_jars.iter().find(|item| item.index.0 == 0).unwrap().principal.0); + assert_eq!(0, alice_jars.iter().find(|item| item.id.0 == 0).unwrap().principal.0); assert_eq!( 1_000_000, - alice_jars.iter().find(|item| item.index.0 == 1).unwrap().principal.0 + alice_jars.iter().find(|item| item.id.0 == 1).unwrap().principal.0 ); } @@ -350,7 +351,7 @@ mod signature_tests { context.set_block_timestamp_in_days(366); context.switch_account(&alice); - context.contract.restake(U32(jar.index)); + context.contract.restake(U32(jar.id)); } #[test] @@ -391,16 +392,12 @@ mod signature_tests { mod helpers { use near_sdk::AccountId; - use crate::{ - common::TokenAmount, - jar::model::{Jar, JarState}, - product::model::ProductId, - }; + use crate::{common::TokenAmount, jar::model::Jar, product::model::ProductId}; impl Jar { - pub(crate) fn generate(index: u32, account_id: &AccountId, product_id: &ProductId) -> Jar { + pub(crate) fn generate(id: u32, account_id: &AccountId, product_id: &ProductId) -> Jar { Self { - index, + id, account_id: account_id.clone(), product_id: product_id.clone(), created_at: 0, @@ -408,7 +405,6 @@ mod helpers { cache: None, claimed_balance: 0, is_pending_withdraw: false, - state: JarState::Active, is_penalty_applied: false, } } diff --git a/contract/src/jar/view.rs b/contract/src/jar/view.rs index d8903e72..68041e0a 100644 --- a/contract/src/jar/view.rs +++ b/contract/src/jar/view.rs @@ -12,12 +12,12 @@ use crate::{ Jar, }; -pub type JarIndexView = U32; +pub type JarIdView = U32; #[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(crate = "near_sdk::serde")] pub struct JarView { - pub index: JarIndexView, + pub id: JarIdView, pub account_id: AccountId, pub product_id: ProductId, pub created_at: U64, @@ -29,7 +29,7 @@ pub struct JarView { impl From for JarView { fn from(value: Jar) -> Self { Self { - index: U32(value.index), + id: U32(value.id), account_id: value.account_id, product_id: value.product_id, created_at: U64(value.created_at), @@ -40,10 +40,24 @@ impl From for JarView { } } +impl From<&Jar> for JarView { + fn from(value: &Jar) -> Self { + Self { + id: U32(value.id), + account_id: value.account_id.clone(), + product_id: value.product_id.clone(), + created_at: U64(value.created_at), + principal: U128(value.principal), + claimed_balance: U128(value.claimed_balance), + is_penalty_applied: value.is_penalty_applied, + } + } +} + #[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(crate = "near_sdk::serde")] pub struct AggregatedTokenAmountView { - pub detailed: HashMap, + pub detailed: HashMap, pub total: U128, } diff --git a/contract/src/lib.rs b/contract/src/lib.rs index 576e5557..816341c6 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -1,19 +1,21 @@ +use std::ops::{Deref, DerefMut}; + use ed25519_dalek::Signature; use near_sdk::{ assert_one_yocto, - borsh::{self, maybestd::collections::HashSet, BorshDeserialize, BorshSerialize}, + borsh::{self, BorshDeserialize, BorshSerialize}, env, json_types::Base64VecU8, near_bindgen, - store::{LookupMap, UnorderedMap, Vector}, + store::{LookupMap, UnorderedMap}, AccountId, BorshStorageKey, Gas, PanicOnDefault, Promise, }; use near_self_update::SelfUpdate; use product::model::{Apy, Product, ProductId}; use crate::{ - assert::{assert_is_not_closed, assert_ownership}, - jar::model::{Jar, JarIndex, JarState}, + assert::assert_ownership, + jar::model::{Jar, JarId}, }; mod assert; @@ -27,6 +29,7 @@ mod jar; mod migration; mod penalty; mod product; +mod tests; mod withdraw; // TODO: document all the numbers @@ -51,17 +54,37 @@ pub struct Contract { /// A collection of products, each representing terms for specific deposit jars. pub products: UnorderedMap, - /// A vector containing information about all deposit jars. - pub jars: Vector, + /// The last jar ID. Is used as nonce in `get_ticket_hash` method. + pub last_jar_id: JarId, + + /// A lookup map that associates account IDs with sets of jars owned by each account. + pub account_jars: LookupMap, +} + +#[derive(Default, BorshDeserialize, BorshSerialize)] +pub struct AccountJars { + /// The last jar ID. Is used as nonce in `get_ticket_hash` method. + pub last_id: JarId, + pub jars: Vec, +} + +impl Deref for AccountJars { + type Target = Vec; - /// A lookup map that associates account IDs with sets of jar indexes owned by each account. - pub account_jars: LookupMap>, + fn deref(&self) -> &Self::Target { + &self.jars + } +} + +impl DerefMut for AccountJars { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.jars + } } #[derive(BorshStorageKey, BorshSerialize)] pub(crate) enum StorageKey { Products, - Jars, AccountJars, } @@ -76,298 +99,27 @@ impl Contract { fee_account_id, manager, products: UnorderedMap::new(StorageKey::Products), - jars: Vector::new(StorageKey::Jars), account_jars: LookupMap::new(StorageKey::AccountJars), + last_jar_id: 0, } } } -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use common::tests::Context; - use near_sdk::{json_types::U128, test_utils::accounts}; - - use super::*; - use crate::{ - claim::api::ClaimApi, - common::{u32::U32, udecimal::UDecimal, MS_IN_YEAR}, - jar::{ - api::JarApi, - view::{AggregatedTokenAmountView, JarView}, - }, - penalty::api::PenaltyApi, - product::{api::*, helpers::MessageSigner, model::DowngradableApy, tests::get_register_product_command}, - withdraw::api::WithdrawApi, - }; - - #[test] - fn add_product_to_list_by_admin() { - let admin = accounts(0); - let mut context = Context::new(admin.clone()); - - context.switch_account(&admin); - context.with_deposit_yocto(1, |context| { - context.contract.register_product(get_register_product_command()) - }); - - let products = context.contract.get_products(); - assert_eq!(products.len(), 1); - assert_eq!(products.first().unwrap().id, "product".to_string()); - } - - #[test] - #[should_panic(expected = "Can be performed only by admin")] - fn add_product_to_list_by_not_admin() { - let admin = accounts(0); - let mut context = Context::new(admin); - - context.with_deposit_yocto(1, |context| { - context.contract.register_product(get_register_product_command()) - }); - } - - #[test] - fn get_principle_with_no_jars() { - let alice = accounts(0); - let admin = accounts(1); - let context = Context::new(admin); - - let principal = context.contract.get_total_principal(alice); - assert_eq!(principal.total.0, 0); - } - - #[test] - fn get_principal_with_single_jar() { - let alice = accounts(0); - let admin = accounts(1); - - let reference_product = generate_product(); - let reference_jar = Jar::generate(0, &alice, &reference_product.id).principal(100); - let context = Context::new(admin) - .with_products(&[reference_product]) - .with_jars(&[reference_jar]); - - let principal = context.contract.get_total_principal(alice).total.0; - assert_eq!(principal, 100); - } - - #[test] - fn get_principal_with_multiple_jars() { - let alice = accounts(0); - let admin = accounts(1); - - let reference_product = generate_product(); - let jars = &[ - Jar::generate(0, &alice, &reference_product.id).principal(100), - Jar::generate(1, &alice, &reference_product.id).principal(200), - Jar::generate(2, &alice, &reference_product.id).principal(400), - ]; - - let context = Context::new(admin).with_products(&[reference_product]).with_jars(jars); - - let principal = context.contract.get_total_principal(alice).total.0; - assert_eq!(principal, 700); - } - - #[test] - fn get_total_interest_with_no_jars() { - let alice = accounts(0); - let admin = accounts(1); - - let context = Context::new(admin); - - let interest = context.contract.get_total_interest(alice); - - assert_eq!(interest.amount.total.0, 0); - assert_eq!(interest.amount.detailed, HashMap::new()); - } - - #[test] - fn get_total_interest_with_single_jar_after_30_minutes() { - let alice = accounts(0); - let admin = accounts(1); - - let reference_product = generate_product(); - - let jar_index = 0; - let jar = Jar::generate(jar_index, &alice, &reference_product.id).principal(100_000_000); - let mut context = Context::new(admin) - .with_products(&[reference_product]) - .with_jars(&[jar.clone()]); - - let contract_jar = JarView::from(context.contract.jars.get(jar_index).unwrap().clone()); - assert_eq!(JarView::from(jar), contract_jar); - - context.set_block_timestamp_in_minutes(30); - - let interest = context.contract.get_total_interest(alice); - - assert_eq!(interest.amount.total.0, 684); - assert_eq!(interest.amount.detailed, HashMap::from([(U32(0), U128(684))])) - } - - #[test] - fn get_total_interest_with_single_jar_on_maturity() { - let alice = accounts(0); - let admin = accounts(1); - - let reference_product = generate_product(); - - let jar_index = 0; - let jar = Jar::generate(jar_index, &alice, &reference_product.id).principal(100_000_000); - let mut context = Context::new(admin) - .with_products(&[reference_product]) - .with_jars(&[jar.clone()]); - - let contract_jar = JarView::from(context.contract.jars.get(jar_index).unwrap().clone()); - assert_eq!(JarView::from(jar), contract_jar); - - context.set_block_timestamp_in_days(365); - - let interest = context.contract.get_total_interest(alice); - - assert_eq!( - interest.amount, - AggregatedTokenAmountView { - detailed: [(U32(0), U128(12_000_000))].into(), - total: U128(12_000_000) - } - ) - } - - #[test] - fn get_total_interest_with_single_jar_after_maturity() { - let alice = accounts(0); - let admin = accounts(1); - - let reference_product = generate_product(); - - let jar_index = 0; - let jar = Jar::generate(jar_index, &alice, &reference_product.id).principal(100_000_000); - let mut context = Context::new(admin) - .with_products(&[reference_product]) - .with_jars(&[jar.clone()]); - - let contract_jar = JarView::from(context.contract.jars.get(jar_index).unwrap().clone()); - assert_eq!(JarView::from(jar), contract_jar); - - context.set_block_timestamp_in_days(400); - - let interest = context.contract.get_total_interest(alice).amount.total.0; - assert_eq!(interest, 12_000_000); - } - - #[test] - fn get_total_interest_with_single_jar_after_claim_on_half_term_and_maturity() { - let alice = accounts(0); - let admin = accounts(1); - - let reference_product = generate_product(); - - let jar_index = 0; - let jar = Jar::generate(jar_index, &alice, &reference_product.id).principal(100_000_000); - let mut context = Context::new(admin) - .with_products(&[reference_product]) - .with_jars(&[jar.clone()]); - - let contract_jar = JarView::from(context.contract.jars.get(jar_index).unwrap().clone()); - assert_eq!(JarView::from(jar), contract_jar); - - context.set_block_timestamp_in_days(182); - - let mut interest = context.contract.get_total_interest(alice.clone()).amount.total.0; - assert_eq!(interest, 5_983_561); - - context.switch_account(&alice); - context.contract.claim_total(); - - context.set_block_timestamp_in_days(365); - - interest = context.contract.get_total_interest(alice.clone()).amount.total.0; - assert_eq!(interest, 6_016_438); - } - - #[test] - #[should_panic(expected = "Penalty is not applicable for constant APY")] - fn penalty_is_not_applicable_for_constant_apy() { - let alice = accounts(0); - let admin = accounts(1); - - let signer = MessageSigner::new(); - let reference_product = Product::generate("premium_product") - .enabled(true) - .apy(Apy::Constant(UDecimal::new(20, 2))) - .public_key(signer.public_key()); - let reference_jar = Jar::generate(0, &alice, &reference_product.id).principal(100_000_000); - - let mut context = Context::new(admin.clone()) - .with_products(&[reference_product]) - .with_jars(&[reference_jar]); - - context.switch_account(&admin); - context.contract.set_penalty(0, true); - } - - #[test] - fn get_total_interest_for_premium_with_penalty_after_half_term() { - let alice = accounts(0); - let admin = accounts(1); - - let signer = MessageSigner::new(); - let reference_product = Product::generate("premium_product") - .enabled(true) - .apy(Apy::Downgradable(DowngradableApy { - default: UDecimal::new(20, 2), - fallback: UDecimal::new(10, 2), - })) - .public_key(signer.public_key()); - let reference_jar = Jar::generate(0, &alice, &reference_product.id).principal(100_000_000); - - let mut context = Context::new(admin.clone()) - .with_products(&[reference_product]) - .with_jars(&[reference_jar]); - - context.set_block_timestamp_in_days(182); - - let mut interest = context.contract.get_total_interest(alice.clone()).amount.total.0; - assert_eq!(interest, 9_972_602); - - context.switch_account(&admin); - context.contract.set_penalty(0, true); - - context.set_block_timestamp_in_days(365); - - interest = context.contract.get_total_interest(alice).amount.total.0; - assert_eq!(interest, 10_000_000); - } - - #[test] - fn get_interest_after_withdraw() { - let alice = accounts(0); - let admin = accounts(1); - - let reference_product = generate_product(); - let reference_jar = &Jar::generate(0, &alice, &reference_product.id).principal(100_000_000); - - let mut context = Context::new(admin) - .with_products(&[reference_product]) - .with_jars(&[reference_jar.clone()]); - - context.set_block_timestamp_in_days(400); - - context.switch_account(&alice); - context.contract.withdraw(U32(reference_jar.index), None); +pub(crate) trait JarsStorage { + fn get_jar(&self, id: JarId) -> &Jar; + fn get_jar_mut(&mut self, id: JarId) -> &mut Jar; +} - let interest = context.contract.get_total_interest(alice.clone()); - assert_eq!(12_000_000, interest.amount.total.0); +impl JarsStorage for Vec { + fn get_jar(&self, id: JarId) -> &Jar { + self.iter() + .find(|jar| jar.id == id) + .unwrap_or_else(|| env::panic_str(&format!("Jar with id: {id} doesn't exist"))) } - fn generate_product() -> Product { - Product::generate("product") - .enabled(true) - .lockup_term(MS_IN_YEAR) - .apy(Apy::Constant(UDecimal::new(12, 2))) + fn get_jar_mut(&mut self, id: JarId) -> &mut Jar { + self.iter_mut() + .find(|jar| jar.id == id) + .unwrap_or_else(|| env::panic_str(&format!("Jar with id: {id} doesn't exist"))) } } diff --git a/contract/src/migration/api.rs b/contract/src/migration/api.rs index 8665c64e..79be4ac6 100644 --- a/contract/src/migration/api.rs +++ b/contract/src/migration/api.rs @@ -5,7 +5,7 @@ use crate::{ event::{emit, EventKind, MigrationEventItem}, migration::model::CeFiJar, product::model::ProductId, - Contract, Jar, JarState, + Contract, Jar, }; impl Contract { @@ -51,10 +51,12 @@ impl Contract { format!("Product {} is not registered", ce_fi_jar.product_id), ); - let index = self.jars.len(); + let id = self.increment_and_get_last_jar_id(); + + let account_jars = self.account_jars.entry(ce_fi_jar.account_id.clone()).or_default(); let jar = Jar { - index, + id, account_id: ce_fi_jar.account_id, product_id: ce_fi_jar.product_id, created_at: ce_fi_jar.created_at.0, @@ -62,22 +64,16 @@ impl Contract { cache: None, claimed_balance: 0, is_pending_withdraw: false, - state: JarState::Active, is_penalty_applied: false, }; - self.jars.push(jar.clone()); - - self.account_jars - .entry(jar.account_id.clone()) - .or_default() - .insert(jar.index); + account_jars.push(jar.clone()); total_amount += jar.principal; event_data.push(MigrationEventItem { original_id: ce_fi_jar.id, - index: jar.index, + id: jar.id, account_id: jar.account_id, }); } diff --git a/contract/src/penalty/api.rs b/contract/src/penalty/api.rs index c87b64e0..9d64ece4 100644 --- a/contract/src/penalty/api.rs +++ b/contract/src/penalty/api.rs @@ -1,8 +1,8 @@ -use near_sdk::{env, near_bindgen}; +use near_sdk::{env, near_bindgen, AccountId}; use crate::{ event::{emit, EventKind::ApplyPenalty, PenaltyData}, - jar::model::JarIndex, + jar::model::JarId, product::model::Apy, Contract, ContractExt, }; @@ -17,33 +17,30 @@ pub trait PenaltyApi { /// /// # Arguments /// - /// * `jar_index` - The index of the jar for which the penalty status is being modified. + /// * `jar_id` - The ID of the jar for which the penalty status is being modified. /// * `value` - A boolean value indicating whether the penalty should be applied (`true`) or canceled (`false`). /// /// # Panics /// /// This method will panic if the jar's associated product has a constant APY rather than a downgradable APY. - fn set_penalty(&mut self, jar_index: JarIndex, value: bool); + fn set_penalty(&mut self, account_id: AccountId, jar_id: JarId, value: bool); } #[near_bindgen] impl PenaltyApi for Contract { - fn set_penalty(&mut self, jar_index: JarIndex, value: bool) { + fn set_penalty(&mut self, account_id: AccountId, jar_id: JarId, value: bool) { self.assert_manager(); - let jar = self.get_jar_internal(jar_index); + let jar = self.get_jar_internal(&account_id, jar_id); let product = self.get_product(&jar.product_id); match product.apy { - Apy::Downgradable(_) => { - let updated_jar = jar.with_penalty_applied(value); - self.jars.replace(jar.index, updated_jar); - } + Apy::Downgradable(_) => self.get_jar_mut_internal(&account_id, jar_id).apply_penalty(value), Apy::Constant(_) => env::panic_str("Penalty is not applicable for constant APY"), }; emit(ApplyPenalty(PenaltyData { - index: jar_index, + id: jar_id, is_applied: value, })); } diff --git a/contract/src/tests.rs b/contract/src/tests.rs new file mode 100644 index 00000000..f2ddda4c --- /dev/null +++ b/contract/src/tests.rs @@ -0,0 +1,286 @@ +#![cfg(test)] + +use std::collections::HashMap; + +use common::tests::Context; +use near_sdk::{json_types::U128, test_utils::accounts}; + +use super::*; +use crate::{ + claim::api::ClaimApi, + common::{u32::U32, udecimal::UDecimal, MS_IN_YEAR}, + jar::{ + api::JarApi, + view::{AggregatedTokenAmountView, JarView}, + }, + penalty::api::PenaltyApi, + product::{api::*, helpers::MessageSigner, model::DowngradableApy, tests::get_register_product_command}, + withdraw::api::WithdrawApi, +}; + +#[test] +fn add_product_to_list_by_admin() { + let admin = accounts(0); + let mut context = Context::new(admin.clone()); + + context.switch_account(&admin); + context.with_deposit_yocto(1, |context| { + context.contract.register_product(get_register_product_command()) + }); + + let products = context.contract.get_products(); + assert_eq!(products.len(), 1); + assert_eq!(products.first().unwrap().id, "product".to_string()); +} + +#[test] +#[should_panic(expected = "Can be performed only by admin")] +fn add_product_to_list_by_not_admin() { + let admin = accounts(0); + let mut context = Context::new(admin); + + context.with_deposit_yocto(1, |context| { + context.contract.register_product(get_register_product_command()) + }); +} + +#[test] +fn get_principle_with_no_jars() { + let alice = accounts(0); + let admin = accounts(1); + let context = Context::new(admin); + + let principal = context.contract.get_total_principal(alice); + assert_eq!(principal.total.0, 0); +} + +#[test] +fn get_principal_with_single_jar() { + let alice = accounts(0); + let admin = accounts(1); + + let reference_product = generate_product(); + let reference_jar = Jar::generate(0, &alice, &reference_product.id).principal(100); + let context = Context::new(admin) + .with_products(&[reference_product]) + .with_jars(&[reference_jar]); + + let principal = context.contract.get_total_principal(alice).total.0; + assert_eq!(principal, 100); +} + +#[test] +fn get_principal_with_multiple_jars() { + let alice = accounts(0); + let admin = accounts(1); + + let reference_product = generate_product(); + let jars = &[ + Jar::generate(0, &alice, &reference_product.id).principal(100), + Jar::generate(1, &alice, &reference_product.id).principal(200), + Jar::generate(2, &alice, &reference_product.id).principal(400), + ]; + + let context = Context::new(admin).with_products(&[reference_product]).with_jars(jars); + + let principal = context.contract.get_total_principal(alice).total.0; + assert_eq!(principal, 700); +} + +#[test] +fn get_total_interest_with_no_jars() { + let alice = accounts(0); + let admin = accounts(1); + + let context = Context::new(admin); + + let interest = context.contract.get_total_interest(alice); + + assert_eq!(interest.amount.total.0, 0); + assert_eq!(interest.amount.detailed, HashMap::new()); +} + +#[test] +fn get_total_interest_with_single_jar_after_30_minutes() { + let alice = accounts(0); + let admin = accounts(1); + + let reference_product = generate_product(); + + let jar_id = 0; + let jar = Jar::generate(jar_id, &alice, &reference_product.id).principal(100_000_000); + let mut context = Context::new(admin) + .with_products(&[reference_product]) + .with_jars(&[jar.clone()]); + + let contract_jar = JarView::from(context.contract.account_jars.get(&alice).unwrap().get_jar(jar_id)); + assert_eq!(JarView::from(jar), contract_jar); + + context.set_block_timestamp_in_minutes(30); + + let interest = context.contract.get_total_interest(alice); + + assert_eq!(interest.amount.total.0, 684); + assert_eq!(interest.amount.detailed, HashMap::from([(U32(0), U128(684))])) +} + +#[test] +fn get_total_interest_with_single_jar_on_maturity() { + let alice = accounts(0); + let admin = accounts(1); + + let reference_product = generate_product(); + + let jar_id = 0; + let jar = Jar::generate(jar_id, &alice, &reference_product.id).principal(100_000_000); + let mut context = Context::new(admin) + .with_products(&[reference_product]) + .with_jars(&[jar.clone()]); + + let contract_jar = JarView::from(context.contract.account_jars.get(&alice).unwrap().get_jar(jar_id)); + assert_eq!(JarView::from(jar), contract_jar); + + context.set_block_timestamp_in_days(365); + + let interest = context.contract.get_total_interest(alice); + + assert_eq!( + interest.amount, + AggregatedTokenAmountView { + detailed: [(U32(0), U128(12_000_000))].into(), + total: U128(12_000_000) + } + ) +} + +#[test] +fn get_total_interest_with_single_jar_after_maturity() { + let alice = accounts(0); + let admin = accounts(1); + + let reference_product = generate_product(); + + let jar_id = 0; + let jar = Jar::generate(jar_id, &alice, &reference_product.id).principal(100_000_000); + let mut context = Context::new(admin) + .with_products(&[reference_product]) + .with_jars(&[jar.clone()]); + + let contract_jar = JarView::from(context.contract.account_jars.get(&alice).unwrap().get_jar(jar_id)); + assert_eq!(JarView::from(jar), contract_jar); + + context.set_block_timestamp_in_days(400); + + let interest = context.contract.get_total_interest(alice).amount.total.0; + assert_eq!(interest, 12_000_000); +} + +#[test] +fn get_total_interest_with_single_jar_after_claim_on_half_term_and_maturity() { + let alice = accounts(0); + let admin = accounts(1); + + let reference_product = generate_product(); + + let jar_id = 0; + let jar = Jar::generate(jar_id, &alice, &reference_product.id).principal(100_000_000); + let mut context = Context::new(admin) + .with_products(&[reference_product]) + .with_jars(&[jar.clone()]); + + let contract_jar = JarView::from(context.contract.account_jars.get(&alice).unwrap().get_jar(jar_id)); + assert_eq!(JarView::from(jar), contract_jar); + + context.set_block_timestamp_in_days(182); + + let mut interest = context.contract.get_total_interest(alice.clone()).amount.total.0; + assert_eq!(interest, 5_983_561); + + context.switch_account(&alice); + context.contract.claim_total(); + + context.set_block_timestamp_in_days(365); + + interest = context.contract.get_total_interest(alice.clone()).amount.total.0; + assert_eq!(interest, 6_016_438); +} + +#[test] +#[should_panic(expected = "Penalty is not applicable for constant APY")] +fn penalty_is_not_applicable_for_constant_apy() { + let alice = accounts(0); + let admin = accounts(1); + + let signer = MessageSigner::new(); + let reference_product = Product::generate("premium_product") + .enabled(true) + .apy(Apy::Constant(UDecimal::new(20, 2))) + .public_key(signer.public_key()); + let reference_jar = Jar::generate(0, &alice, &reference_product.id).principal(100_000_000); + + let mut context = Context::new(admin.clone()) + .with_products(&[reference_product]) + .with_jars(&[reference_jar]); + + context.switch_account(&admin); + context.contract.set_penalty(alice, 0, true); +} + +#[test] +fn get_total_interest_for_premium_with_penalty_after_half_term() { + let alice = accounts(0); + let admin = accounts(1); + + let signer = MessageSigner::new(); + let reference_product = Product::generate("premium_product") + .enabled(true) + .apy(Apy::Downgradable(DowngradableApy { + default: UDecimal::new(20, 2), + fallback: UDecimal::new(10, 2), + })) + .public_key(signer.public_key()); + let reference_jar = Jar::generate(0, &alice, &reference_product.id).principal(100_000_000); + + let mut context = Context::new(admin.clone()) + .with_products(&[reference_product]) + .with_jars(&[reference_jar]); + + context.set_block_timestamp_in_days(182); + + let mut interest = context.contract.get_total_interest(alice.clone()).amount.total.0; + assert_eq!(interest, 9_972_602); + + context.switch_account(&admin); + context.contract.set_penalty(alice.clone(), 0, true); + + context.set_block_timestamp_in_days(365); + + interest = context.contract.get_total_interest(alice).amount.total.0; + assert_eq!(interest, 10_000_000); +} + +#[test] +fn get_interest_after_withdraw() { + let alice = accounts(0); + let admin = accounts(1); + + let product = generate_product(); + let jar = Jar::generate(0, &alice, &product.id).principal(100_000_000); + + let mut context = Context::new(admin).with_products(&[product]).with_jars(&[jar.clone()]); + + context.set_block_timestamp_in_days(400); + + context.switch_account(&alice); + context.contract.withdraw(U32(jar.id), None); + + let interest = context.contract.get_total_interest(alice.clone()); + assert_eq!(12_000_000, interest.amount.total.0); +} + +fn generate_product() -> Product { + Product::generate("product") + .enabled(true) + .lockup_term(MS_IN_YEAR) + .apy(Apy::Constant(UDecimal::new(12, 2))) +} diff --git a/contract/src/withdraw/api.rs b/contract/src/withdraw/api.rs index 4bcad61e..a2b91f61 100644 --- a/contract/src/withdraw/api.rs +++ b/contract/src/withdraw/api.rs @@ -2,12 +2,12 @@ use near_sdk::{ext_contract, is_promise_success, json_types::U128, near_bindgen, use crate::{ assert::{assert_is_liquidable, assert_sufficient_balance}, - assert_is_not_closed, assert_ownership, - common::{TokenAmount, GAS_FOR_AFTER_WITHDRAW}, + assert_ownership, + common::TokenAmount, env, event::{emit, EventKind, WithdrawData}, ft_interface::Fee, - jar::view::JarIndexView, + jar::view::JarIdView, product::model::WithdrawalFee, withdraw::view::WithdrawView, AccountId, Contract, ContractExt, Jar, Product, @@ -21,7 +21,7 @@ pub trait WithdrawApi { /// /// # Arguments /// - /// * `jar_index` - The index of the deposit jar from which the withdrawal is being made. + /// * `jar_id` - The ID of the deposit jar from which the withdrawal is being made. /// * `amount` - An optional `U128` value indicating the amount of tokens to withdraw. If `None` is provided, /// the entire balance of the jar will be withdrawn. /// @@ -38,7 +38,7 @@ pub trait WithdrawApi { /// - If the caller is not the owner of the specified jar. /// - If the withdrawal amount exceeds the available balance in the jar. /// - If attempting to withdraw from a Fixed jar that is not yet mature. - fn withdraw(&mut self, jar_index: JarIndexView, amount: Option) -> PromiseOrValue; + fn withdraw(&mut self, jar_id: JarIdView, amount: Option) -> PromiseOrValue; } #[ext_contract(ext_self)] @@ -53,15 +53,14 @@ pub trait WithdrawCallbacks { #[near_bindgen] impl WithdrawApi for Contract { - fn withdraw(&mut self, jar_index: JarIndexView, amount: Option) -> PromiseOrValue { - let jar = self.get_jar_internal(jar_index.0).locked(); + fn withdraw(&mut self, jar_id: JarIdView, amount: Option) -> PromiseOrValue { + let account_id = env::predecessor_account_id(); + let jar = self.get_jar_internal(&account_id, jar_id.0).locked(); let amount = amount.map_or(jar.principal, |value| value.0); - let account_id = env::predecessor_account_id(); assert_ownership(&jar, &account_id); assert_sufficient_balance(&jar, amount); - assert_is_not_closed(&jar); let now = env::block_timestamp_ms(); let product = self.get_product(&jar.product_id); @@ -83,24 +82,32 @@ impl Contract { if is_promise_success { let product = self.get_product(&jar_before_transfer.product_id); let now = env::block_timestamp_ms(); - let jar = jar_before_transfer.withdrawn(product, withdrawn_amount, now); + let (should_be_closed, jar) = jar_before_transfer.withdrawn(product, withdrawn_amount, now); + + let jar_id = jar.id; - self.jars.replace(jar_before_transfer.index, jar.unlocked()); + if should_be_closed { + self.delete_jar(jar); + } else { + let stored_jar = self.get_jar_mut_internal(&jar.account_id, jar_id); + *stored_jar = jar; + stored_jar.unlock(); + } - emit(EventKind::Withdraw(WithdrawData { index: jar.index })); + emit(EventKind::Withdraw(WithdrawData { id: jar_id })); WithdrawView::new(withdrawn_amount, fee) } else { - self.jars - .replace(jar_before_transfer.index, jar_before_transfer.unlocked()); + let stored_jar = self.get_jar_mut_internal(&jar_before_transfer.account_id, jar_before_transfer.id); + + *stored_jar = jar_before_transfer.unlocked(); WithdrawView::new(0, None) } } fn do_transfer(&mut self, account_id: &AccountId, jar: &Jar, amount: TokenAmount) -> PromiseOrValue { - self.jars.replace(jar.index, jar.locked()); - + self.get_jar_mut_internal(account_id, jar.id).lock(); self.transfer_withdraw(account_id, amount, jar) } @@ -138,7 +145,7 @@ impl Contract { fn after_withdraw_call(jar_before_transfer: Jar, withdrawn_balance: TokenAmount, fee: &Option) -> Promise { ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_FOR_AFTER_WITHDRAW) + .with_static_gas(crate::common::GAS_FOR_AFTER_WITHDRAW) .after_withdraw(jar_before_transfer, withdrawn_balance, fee.clone()) } } @@ -167,190 +174,3 @@ impl WithdrawCallbacks for Contract { self.after_withdraw_internal(jar_before_transfer, withdrawn_amount, fee, is_promise_success()) } } - -#[cfg(test)] -mod tests { - use near_sdk::{json_types::U128, test_utils::accounts, AccountId, PromiseOrValue}; - - use crate::{ - common::{tests::Context, u32::U32, udecimal::UDecimal}, - jar::{api::JarApi, model::Jar}, - product::model::{Product, WithdrawalFee}, - withdraw::api::WithdrawApi, - }; - - fn prepare_jar(product: &Product) -> (AccountId, Jar, Context) { - let alice = accounts(0); - let admin = accounts(1); - - let jar = Jar::generate(0, &alice, &product.id).principal(1_000_000); - let context = Context::new(admin) - .with_products(&[product.clone()]) - .with_jars(&[jar.clone()]); - - (alice, jar, context) - } - - #[test] - #[should_panic(expected = "Account doesn't own this jar")] - fn withdraw_locked_jar_before_maturity_by_not_owner() { - let (_, _, mut context) = prepare_jar(&generate_product()); - - context.contract.withdraw(U32(0), None); - } - - #[test] - #[should_panic(expected = "The jar is not mature yet")] - fn withdraw_locked_jar_before_maturity_by_owner() { - let (alice, jar, mut context) = prepare_jar(&generate_product()); - - context.switch_account(&alice); - context.contract.withdraw(U32(jar.index), None); - } - - #[test] - #[should_panic(expected = "Account doesn't own this jar")] - fn withdraw_locked_jar_after_maturity_by_not_owner() { - let product = generate_product(); - let (_, jar, mut context) = prepare_jar(&product); - - context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); - context.contract.withdraw(U32(jar.index), None); - } - - #[test] - fn withdraw_locked_jar_after_maturity_by_owner() { - let product = generate_product(); - let (alice, jar, mut context) = prepare_jar(&product); - - context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); - context.switch_account(&alice); - context.contract.withdraw(U32(jar.index), None); - } - - #[test] - #[should_panic(expected = "Account doesn't own this jar")] - fn withdraw_flexible_jar_by_not_owner() { - let product = generate_flexible_product(); - let (_, jar, mut context) = prepare_jar(&product); - - context.set_block_timestamp_in_days(1); - context.contract.withdraw(U32(jar.index), None); - } - - #[test] - fn withdraw_flexible_jar_by_owner_full() { - let product = generate_flexible_product(); - let (alice, reference_jar, mut context) = prepare_jar(&product); - - context.set_block_timestamp_in_days(1); - context.switch_account(&alice); - - context.contract.withdraw(U32(reference_jar.index), None); - let jar = context.contract.get_jar(U32(reference_jar.index)); - assert_eq!(0, jar.principal.0); - } - - #[test] - fn withdraw_flexible_jar_by_owner_with_sufficient_balance() { - let product = generate_flexible_product(); - let (alice, reference_jar, mut context) = prepare_jar(&product); - - context.set_block_timestamp_in_days(1); - context.switch_account(&alice); - - context.contract.withdraw(U32(0), Some(U128(100_000))); - let jar = context.contract.get_jar(U32(reference_jar.index)); - assert_eq!(900_000, jar.principal.0); - } - - #[test] - #[should_panic(expected = "Insufficient balance")] - fn withdraw_flexible_jar_by_owner_with_insufficient_balance() { - let product = generate_flexible_product(); - let (alice, jar, mut context) = prepare_jar(&product); - - context.set_block_timestamp_in_days(1); - context.switch_account(&alice); - context.contract.withdraw(U32(jar.index), Some(U128(2_000_000))); - } - - #[test] - fn product_with_fixed_fee() { - let fee = 10; - let product = generate_product_with_fee(&WithdrawalFee::Fix(fee)); - let (alice, reference_jar, mut context) = prepare_jar(&product); - - let initial_principal = reference_jar.principal; - - context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); - context.switch_account(&alice); - - let withdraw_amount = 100_000; - let PromiseOrValue::Value(withdraw) = context.contract.withdraw(U32(0), Some(U128(withdraw_amount))) else { - panic!("Invalid promise type"); - }; - - assert_eq!(withdraw.withdrawn_amount, U128(withdraw_amount - fee)); - assert_eq!(withdraw.fee, U128(fee)); - - let jar = context.contract.get_jar(U32(reference_jar.index)); - - assert_eq!(jar.principal, U128(initial_principal - withdraw_amount)); - } - - #[test] - fn product_with_percent_fee() { - let fee_value = UDecimal::new(5, 4); - let fee = WithdrawalFee::Percent(fee_value.clone()); - let product = generate_product_with_fee(&fee); - let (alice, reference_jar, mut context) = prepare_jar(&product); - - let initial_principal = reference_jar.principal; - - context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); - context.switch_account(&alice); - - let withdrawn_amount = 100_000; - let PromiseOrValue::Value(withdraw) = context.contract.withdraw(U32(0), Some(U128(withdrawn_amount))) else { - panic!("Invalid promise type"); - }; - - let reference_fee = fee_value * initial_principal; - assert_eq!(withdraw.withdrawn_amount, U128(withdrawn_amount - reference_fee)); - assert_eq!(withdraw.fee, U128(reference_fee)); - - let jar = context.contract.get_jar(U32(reference_jar.index)); - - assert_eq!(jar.principal, U128(initial_principal - withdrawn_amount)); - } - - #[test] - fn test_failed_withdraw() { - let product = generate_product(); - let (_, reference_jar, mut context) = prepare_jar(&product); - - let jar_view = context.contract.get_jar(U32(reference_jar.index)); - let jar = context.contract.jars[0].clone(); - let withdraw = context.contract.after_withdraw_internal(jar, 1234, None, false); - - assert_eq!(withdraw.withdrawn_amount, U128(0)); - assert_eq!(withdraw.fee, U128(0)); - - assert_eq!(jar_view, context.contract.get_jar(U32(0))); - } - - pub(crate) fn generate_product() -> Product { - Product::generate("product").enabled(true) - } - - pub(crate) fn generate_flexible_product() -> Product { - Product::generate("flexible_product").enabled(true).flexible() - } - - pub(crate) fn generate_product_with_fee(fee: &WithdrawalFee) -> Product { - Product::generate("product_with_fee") - .enabled(true) - .with_withdrawal_fee(fee.clone()) - } -} diff --git a/contract/src/withdraw/mod.rs b/contract/src/withdraw/mod.rs index 68c36638..78fb6ba1 100644 --- a/contract/src/withdraw/mod.rs +++ b/contract/src/withdraw/mod.rs @@ -1,2 +1,3 @@ pub mod api; +mod tests; pub mod view; diff --git a/contract/src/withdraw/tests.rs b/contract/src/withdraw/tests.rs new file mode 100644 index 00000000..4266e077 --- /dev/null +++ b/contract/src/withdraw/tests.rs @@ -0,0 +1,222 @@ +#![cfg(test)] + +use near_sdk::{json_types::U128, test_utils::accounts, AccountId, PromiseOrValue}; + +use crate::{ + common::{tests::Context, u32::U32, udecimal::UDecimal, MS_IN_YEAR}, + jar::{api::JarApi, model::Jar}, + product::model::{Apy, Product, WithdrawalFee}, + withdraw::api::WithdrawApi, +}; + +fn prepare_jar(product: &Product) -> (AccountId, Jar, Context) { + let alice = accounts(0); + let admin = accounts(1); + + let jar = Jar::generate(0, &alice, &product.id).principal(1_000_000); + let context = Context::new(admin) + .with_products(&[product.clone()]) + .with_jars(&[jar.clone()]); + + (alice, jar, context) +} + +#[test] +#[should_panic(expected = "Account 'owner' doesn't exist")] +fn withdraw_locked_jar_before_maturity_by_not_owner() { + let (_, _, mut context) = prepare_jar(&generate_product()); + + context.contract.withdraw(U32(0), None); +} + +#[test] +#[should_panic(expected = "The jar is not mature yet")] +fn withdraw_locked_jar_before_maturity_by_owner() { + let (alice, jar, mut context) = prepare_jar(&generate_product()); + + context.switch_account(&alice); + context.contract.withdraw(U32(jar.id), None); +} + +#[test] +#[should_panic(expected = "Account 'owner' doesn't exist")] +fn withdraw_locked_jar_after_maturity_by_not_owner() { + let product = generate_product(); + let (_, jar, mut context) = prepare_jar(&product); + + context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); + context.contract.withdraw(U32(jar.id), None); +} + +#[test] +fn withdraw_locked_jar_after_maturity_by_owner() { + let product = generate_product(); + let (alice, jar, mut context) = prepare_jar(&product); + + context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); + context.switch_account(&alice); + context.contract.withdraw(U32(jar.id), None); +} + +#[test] +#[should_panic(expected = "Account 'owner' doesn't exist")] +fn withdraw_flexible_jar_by_not_owner() { + let product = generate_flexible_product(); + let (_, jar, mut context) = prepare_jar(&product); + + context.set_block_timestamp_in_days(1); + context.contract.withdraw(U32(jar.id), None); +} + +#[test] +fn withdraw_flexible_jar_by_owner_full() { + let product = generate_flexible_product(); + let (alice, reference_jar, mut context) = prepare_jar(&product); + + context.set_block_timestamp_in_days(1); + context.switch_account(&alice); + + context.contract.withdraw(U32(reference_jar.id), None); + let jar = context.contract.get_jar(alice.clone(), U32(reference_jar.id)); + assert_eq!(0, jar.principal.0); +} + +#[test] +fn withdraw_flexible_jar_by_owner_with_sufficient_balance() { + let product = generate_flexible_product(); + let (alice, reference_jar, mut context) = prepare_jar(&product); + + context.set_block_timestamp_in_days(1); + context.switch_account(&alice); + + context.contract.withdraw(U32(0), Some(U128(100_000))); + let jar = context.contract.get_jar(alice.clone(), U32(reference_jar.id)); + assert_eq!(900_000, jar.principal.0); +} + +#[test] +#[should_panic(expected = "Insufficient balance")] +fn withdraw_flexible_jar_by_owner_with_insufficient_balance() { + let product = generate_flexible_product(); + let (alice, jar, mut context) = prepare_jar(&product); + + context.set_block_timestamp_in_days(1); + context.switch_account(&alice); + context.contract.withdraw(U32(jar.id), Some(U128(2_000_000))); +} + +#[test] +fn dont_delete_jar_after_withdraw_with_interest_left() { + let product = generate_product() + .lockup_term(MS_IN_YEAR) + .apy(Apy::Constant(UDecimal::new(2, 1))); + + let (alice, _, mut context) = prepare_jar(&product); + + context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); + context.switch_account(&alice); + + let jar = context.contract.get_jar_internal(&alice, 0); + + let PromiseOrValue::Value(withdrawn) = context.contract.withdraw(U32(jar.id), Some(U128(1_000_000))) else { + panic!(); + }; + + assert_eq!(withdrawn.withdrawn_amount, U128(1_000_000)); + assert_eq!(withdrawn.fee, U128(0)); + + let jar = context.contract.get_jar_internal(&alice, 0); + assert_eq!(jar.principal, 0); + + let Some(ref cache) = jar.cache else { + panic!(); + }; + + assert_eq!(cache.interest, 200_000); +} + +#[test] +fn product_with_fixed_fee() { + let fee = 10; + let product = generate_product_with_fee(&WithdrawalFee::Fix(fee)); + let (alice, reference_jar, mut context) = prepare_jar(&product); + + let initial_principal = reference_jar.principal; + + context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); + context.switch_account(&alice); + + let withdraw_amount = 100_000; + let PromiseOrValue::Value(withdraw) = context.contract.withdraw(U32(0), Some(U128(withdraw_amount))) else { + panic!("Invalid promise type"); + }; + + assert_eq!(withdraw.withdrawn_amount, U128(withdraw_amount - fee)); + assert_eq!(withdraw.fee, U128(fee)); + + let jar = context.contract.get_jar(alice, U32(reference_jar.id)); + + assert_eq!(jar.principal, U128(initial_principal - withdraw_amount)); +} + +#[test] +fn product_with_percent_fee() { + let fee_value = UDecimal::new(5, 4); + let fee = WithdrawalFee::Percent(fee_value.clone()); + let product = generate_product_with_fee(&fee); + let (alice, reference_jar, mut context) = prepare_jar(&product); + + let initial_principal = reference_jar.principal; + + context.set_block_timestamp_in_ms(product.get_lockup_term().unwrap() + 1); + context.switch_account(&alice); + + let withdrawn_amount = 100_000; + let PromiseOrValue::Value(withdraw) = context.contract.withdraw(U32(0), Some(U128(withdrawn_amount))) else { + panic!("Invalid promise type"); + }; + + let reference_fee = fee_value * initial_principal; + assert_eq!(withdraw.withdrawn_amount, U128(withdrawn_amount - reference_fee)); + assert_eq!(withdraw.fee, U128(reference_fee)); + + let jar = context.contract.get_jar(alice, U32(reference_jar.id)); + + assert_eq!(jar.principal, U128(initial_principal - withdrawn_amount)); +} + +#[test] +fn test_failed_withdraw() { + let product = generate_product(); + let (alice, reference_jar, mut context) = prepare_jar(&product); + + let jar_view = context.contract.get_jar(alice.clone(), U32(reference_jar.id)); + let jar = context + .contract + .account_jars + .get(&alice) + .unwrap() + .iter() + .next() + .unwrap(); + let withdraw = context.contract.after_withdraw_internal(jar.clone(), 1234, None, false); + + assert_eq!(withdraw.withdrawn_amount, U128(0)); + assert_eq!(withdraw.fee, U128(0)); + + assert_eq!(jar_view, context.contract.get_jar(alice, U32(0))); +} + +pub(crate) fn generate_product() -> Product { + Product::generate("product").enabled(true) +} + +pub(crate) fn generate_flexible_product() -> Product { + Product::generate("flexible_product").enabled(true).flexible() +} + +pub(crate) fn generate_product_with_fee(fee: &WithdrawalFee) -> Product { + Product::generate("product_with_fee") + .enabled(true) + .with_withdrawal_fee(fee.clone()) +} diff --git a/integration-tests/src/jar_contract_interface.rs b/integration-tests/src/jar_contract_interface.rs index 514672e6..d7c8b2ef 100644 --- a/integration-tests/src/jar_contract_interface.rs +++ b/integration-tests/src/jar_contract_interface.rs @@ -34,7 +34,7 @@ pub(crate) trait JarContractInterface { async fn get_jars_for_account(&self, user: &Account) -> anyhow::Result; - async fn withdraw(&self, user: &Account, jar_index: &str) -> anyhow::Result; + async fn withdraw(&self, user: &Account, jar_id: &str) -> anyhow::Result; async fn claim_total(&self, user: &Account) -> anyhow::Result; } @@ -189,11 +189,11 @@ impl JarContractInterface for Contract { Ok(result) } - async fn withdraw(&self, user: &Account, jar_index: &str) -> anyhow::Result { - println!("▶️ Withdraw jar #{jar_index}"); + async fn withdraw(&self, user: &Account, jar_id: &str) -> anyhow::Result { + println!("▶️ Withdraw jar #{jar_id}"); let args = json!({ - "jar_index": jar_index, + "jar_id": jar_id, }); let result = user diff --git a/integration-tests/src/measure/claim_total.rs b/integration-tests/src/measure/claim_total.rs index 03b26dec..44b418a4 100644 --- a/integration-tests/src/measure/claim_total.rs +++ b/integration-tests/src/measure/claim_total.rs @@ -1,5 +1,7 @@ #![cfg(test)] +use std::collections::HashMap; + use itertools::Itertools; use workspaces::{types::Gas, Account}; @@ -20,7 +22,7 @@ async fn measure_after_claim_total_test() -> anyhow::Result<()> { RegisterProductCommand::Locked6Months6Percents, RegisterProductCommand::Locked6Months6PercentsWithWithdrawFee, ], - &(1..10).collect_vec(), + &(1..5).collect_vec(), ), measure_after_claim_total, ) @@ -28,6 +30,29 @@ async fn measure_after_claim_total_test() -> anyhow::Result<()> { dbg!(&measured); + let mut map: HashMap> = HashMap::new(); + + for measure in measured { + map.entry(measure.0 .0).or_default().push(measure.1); + } + + dbg!(&map); + + let map: HashMap = map + .into_iter() + .map(|(key, gas_cost)| { + let mut differences: Vec = Vec::new(); + for i in 1..gas_cost.len() { + let diff = gas_cost[i] - gas_cost[i - 1]; + differences.push(diff); + } + + (key, (gas_cost, differences)) + }) + .collect(); + + dbg!(&map); + Ok(()) } diff --git a/integration-tests/src/migration.rs b/integration-tests/src/migration.rs index 37eb8411..f8afb6c0 100644 --- a/integration-tests/src/migration.rs +++ b/integration-tests/src/migration.rs @@ -81,11 +81,11 @@ async fn migration() -> anyhow::Result<()> { assert_eq!(2, alice_jars.len()); let alice_first_jar = alice_jars.get(0).unwrap(); - assert_eq!("0", alice_first_jar.get("index").unwrap().as_str().unwrap()); + assert_eq!("1", alice_first_jar.get("id").unwrap().as_str().unwrap()); assert_eq!("2000000", alice_first_jar.get("principal").unwrap().as_str().unwrap()); let alice_second_jar = alice_jars.get(1).unwrap(); - assert_eq!("1", alice_second_jar.get("index").unwrap().as_str().unwrap()); + assert_eq!("2", alice_second_jar.get("id").unwrap().as_str().unwrap()); assert_eq!("700000", alice_second_jar.get("principal").unwrap().as_str().unwrap()); let alice_principal = context.jar_contract.get_total_principal(alice).await?; diff --git a/integration-tests/src/product.rs b/integration-tests/src/product.rs index e117ad71..ddccd5a7 100644 --- a/integration-tests/src/product.rs +++ b/integration-tests/src/product.rs @@ -1,6 +1,6 @@ use serde_json::{json, Value}; -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub(crate) enum RegisterProductCommand { Locked12Months12Percents, Locked6Months6Percents, diff --git a/integration-tests/src/withdraw_fee.rs b/integration-tests/src/withdraw_fee.rs index dcc6e007..327bc18b 100644 --- a/integration-tests/src/withdraw_fee.rs +++ b/integration-tests/src/withdraw_fee.rs @@ -31,7 +31,7 @@ async fn test_fixed_withdraw_fee() -> anyhow::Result<()> { context.fast_forward_hours(1).await?; - let withdraw_result = context.jar_contract.withdraw(&alice, "0").await?; + let withdraw_result = context.jar_contract.withdraw(&alice, "1").await?; let withdrawn_amount = withdraw_result.get_u128("withdrawn_amount"); let fee_amount = withdraw_result.get_u128("fee"); @@ -75,7 +75,7 @@ async fn test_percent_withdraw_fee() -> anyhow::Result<()> { context.fast_forward_hours(1).await?; - let withdraw_result = context.jar_contract.withdraw(&alice, "0").await?; + let withdraw_result = context.jar_contract.withdraw(&alice, "1").await?; let withdrawn_amount = withdraw_result.get_u128("withdrawn_amount"); let fee_amount = withdraw_result.get_u128("fee"); diff --git a/res/sweat_jar.wasm b/res/sweat_jar.wasm index 9996898d..aece572e 100755 Binary files a/res/sweat_jar.wasm and b/res/sweat_jar.wasm differ