diff --git a/res/ft_lockup.wasm b/res/ft_lockup.wasm index be61ecb..87c113c 100755 Binary files a/res/ft_lockup.wasm and b/res/ft_lockup.wasm differ diff --git a/src/callbacks.rs b/src/callbacks.rs index 3a1067e..3e16169 100644 --- a/src/callbacks.rs +++ b/src/callbacks.rs @@ -26,6 +26,7 @@ impl SelfCallbacks for Contract { let mut total_balance = 0; if promise_success { let mut remove_indices = vec![]; + let mut events: Vec = vec![]; for LockupClaim { index, is_final, @@ -36,6 +37,11 @@ impl SelfCallbacks for Contract { remove_indices.push(index); } total_balance += claim_amount.0; + let event = FtLockupClaimLockup { + id: index, + amount: claim_amount, + }; + events.push(event); } if !remove_indices.is_empty() { let mut indices = self.account_lockups.get(&account_id).unwrap_or_default(); @@ -44,6 +50,7 @@ impl SelfCallbacks for Contract { } self.internal_save_account_lockups(&account_id, indices); } + emit(EventKind::FtLockupClaimLockup(events)); } else { log!("Token transfer has failed. Refunding."); let mut modified = false; @@ -79,14 +86,10 @@ impl SelfCallbacks for Contract { if !promise_success { log!("Lockup termination transfer has failed."); // There is no internal balance, so instead we create a new lockup. - let lockup = Lockup::new_unlocked(account_id, amount.0); + let lockup = Lockup::new_unlocked_since(account_id, amount.0, current_timestamp_sec()); let lockup_index = self.internal_add_lockup(&lockup); - log!( - "Generated a new lockup #{} as a refund of {} for account {}", - lockup_index, - amount.0, - lockup.account_id.as_ref(), - ); + let event: FtLockupCreateLockup = (lockup_index, lockup, None).into(); + emit(EventKind::FtLockupCreateLockup(vec![event])); 0.into() } else { amount diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..e122d32 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,594 @@ +use crate::*; + +/// Events to be generated by the contract according to NEP-297 + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupNew { + pub token_account_id: AccountId, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupAddToDepositWhitelist { + pub account_ids: Vec, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupRemoveFromDepositWhitelist { + pub account_ids: Vec, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupAddToDraftOperatorsWhitelist { + pub account_ids: Vec, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupRemoveFromDraftOperatorsWhitelist { + pub account_ids: Vec, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupCreateLockup { + pub id: LockupIndex, + pub account_id: AccountId, + pub balance: WrappedBalance, + pub start: TimestampSec, + pub finish: TimestampSec, + pub terminatable: bool, + pub draft_id: Option, +} + +impl From<(LockupIndex, Lockup, Option)> for FtLockupCreateLockup { + fn from(tuple: (LockupIndex, Lockup, Option)) -> Self { + let (id, lockup, draft_id) = tuple; + Self { + id, + account_id: lockup.account_id.to_string(), + balance: lockup.schedule.total_balance().into(), + start: lockup.schedule.0.first().unwrap().timestamp, + finish: lockup.schedule.0.last().unwrap().timestamp, + terminatable: lockup.termination_config.is_some(), + draft_id, + } + } +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupClaimLockup { + pub id: LockupIndex, + pub amount: WrappedBalance, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupTerminateLockup { + pub id: LockupIndex, + pub termination_timestamp: TimestampSec, + pub unvested_balance: WrappedBalance, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupCreateDraftGroup { + pub id: DraftGroupIndex, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupCreateDraft { + pub id: DraftIndex, + pub draft_group_id: DraftGroupIndex, + pub account_id: AccountId, + pub balance: WrappedBalance, + pub start: TimestampSec, + pub finish: TimestampSec, + pub terminatable: bool, +} + +impl From<(DraftIndex, Draft)> for FtLockupCreateDraft { + fn from(tuple: (DraftIndex, Draft)) -> Self { + let (id, draft) = tuple; + Self { + id, + draft_group_id: draft.draft_group_id, + account_id: draft.lockup_create.account_id.to_string(), + balance: draft.lockup_create.schedule.total_balance().into(), + start: draft.lockup_create.schedule.0.first().unwrap().timestamp, + finish: draft.lockup_create.schedule.0.last().unwrap().timestamp, + terminatable: draft.lockup_create.vesting_schedule.is_some(), + } + } +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupFundDraftGroup { + pub id: DraftGroupIndex, + pub amount: WrappedBalance, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupDiscardDraftGroup { + pub id: DraftGroupIndex, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct FtLockupDeleteDraft { + pub id: DraftIndex, +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +#[serde(tag = "event", content = "data")] +#[serde(rename_all = "snake_case")] +pub(crate) enum EventKind { + FtLockupNew(FtLockupNew), + FtLockupAddToDepositWhitelist(FtLockupAddToDepositWhitelist), + FtLockupRemoveFromDepositWhitelist(FtLockupRemoveFromDepositWhitelist), + FtLockupAddToDraftOperatorsWhitelist(FtLockupAddToDraftOperatorsWhitelist), + FtLockupRemoveFromDraftOperatorsWhitelist(FtLockupRemoveFromDraftOperatorsWhitelist), + FtLockupCreateLockup(Vec), + FtLockupClaimLockup(Vec), + FtLockupTerminateLockup(Vec), + FtLockupCreateDraftGroup(Vec), + FtLockupCreateDraft(Vec), + FtLockupFundDraftGroup(Vec), + FtLockupDiscardDraftGroup(Vec), + FtLockupDeleteDraft(Vec), +} + +#[derive(Serialize, Debug)] +#[serde(crate = "near_sdk::serde")] +#[serde(rename_all = "snake_case")] +pub(crate) struct NearEvent { + standard: String, + version: String, + #[serde(flatten)] + event_kind: EventKind, +} + +impl From for NearEvent { + fn from(event_kind: EventKind) -> Self { + Self { + standard: PACKAGE_NAME.into(), + version: VERSION.into(), + event_kind, + } + } +} + +impl NearEvent { + fn to_json_string(&self) -> String { + serde_json::to_string(self).unwrap() + } + + fn to_json_event_string(&self) -> String { + format!("EVENT_JSON:{}", self.to_json_string()) + } + + pub(crate) fn emit(self) { + log!("{}", &self.to_json_event_string()); + } +} + +pub(crate) fn emit(event_kind: EventKind) { + NearEvent::from(event_kind).emit(); +} + +#[cfg(test)] +mod tests { + use super::*; + use near_sdk::serde_json::json; + use near_sdk::test_utils::VMContextBuilder; + use near_sdk::{test_utils, testing_env, MockedBlockchain, VMContext}; + + pub fn get_context() -> VMContext { + VMContextBuilder::new().is_view(true).build() + } + + #[test] + fn test_ft_lockup_init() { + testing_env!(get_context()); + + let token_account_id = "token.near".into(); + emit(EventKind::FtLockupNew(FtLockupNew { token_account_id })); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_new", + "data": { "token_account_id": "token.near" }, + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_add_to_deposit_whitelist() { + testing_env!(get_context()); + + let account_ids: Vec = vec!["alice.near", "bob.near"] + .iter() + .map(|&x| x.into()) + .collect(); + emit(EventKind::FtLockupAddToDepositWhitelist( + FtLockupAddToDepositWhitelist { account_ids }, + )); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_add_to_deposit_whitelist", + "data": { "account_ids": ["alice.near", "bob.near"] }, + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_remove_from_deposit_whitelist() { + testing_env!(get_context()); + + let account_ids: Vec = vec!["alice.near", "bob.near"] + .iter() + .map(|&x| x.into()) + .collect(); + emit(EventKind::FtLockupRemoveFromDepositWhitelist( + FtLockupRemoveFromDepositWhitelist { account_ids }, + )); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_remove_from_deposit_whitelist", + "data": { "account_ids": ["alice.near", "bob.near"] }, + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_add_to_draft_operators_whitelist() { + testing_env!(get_context()); + + let account_ids: Vec = vec!["alice.near", "bob.near"] + .iter() + .map(|&x| x.into()) + .collect(); + emit(EventKind::FtLockupAddToDraftOperatorsWhitelist( + FtLockupAddToDraftOperatorsWhitelist { account_ids }, + )); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_add_to_draft_operators_whitelist", + "data": { "account_ids": ["alice.near", "bob.near"] }, + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_remove_from_draft_operators_whitelist() { + testing_env!(get_context()); + + let account_ids: Vec = vec!["alice.near", "bob.near"] + .iter() + .map(|&x| x.into()) + .collect(); + emit(EventKind::FtLockupRemoveFromDraftOperatorsWhitelist( + FtLockupRemoveFromDraftOperatorsWhitelist { account_ids }, + )); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_remove_from_draft_operators_whitelist", + "data": { "account_ids": ["alice.near", "bob.near"] }, + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_create_lockup() { + testing_env!(get_context()); + + let account_id: AccountId = "alice.near".into(); + let balance: WrappedBalance = 10_000.into(); + let timestamp: TimestampSec = 1_500_000_000; + let lockup = Lockup::new_unlocked_since(account_id.clone(), balance.0, timestamp); + let lockup_id: LockupIndex = 100; + let draft_id: DraftIndex = 33; + + let event: FtLockupCreateLockup = (100, lockup, Some(draft_id)).into(); + + emit(EventKind::FtLockupCreateLockup(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_create_lockup", + "data": [ + { + "id": lockup_id, + "account_id": account_id, + "balance": balance, + "start": timestamp - 1, + "finish": timestamp, + "terminatable": false, + "draft_id": Some(draft_id), + }, + ], + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_claim_lockup() { + testing_env!(get_context()); + + let lockup_id: LockupIndex = 100; + let amount: WrappedBalance = 10000.into(); + + let event = FtLockupClaimLockup { + id: lockup_id, + amount, + }; + + emit(EventKind::FtLockupClaimLockup(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_claim_lockup", + "data": [ + { + "id": lockup_id, + "amount": amount, + }, + ], + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_terminate_lockup() { + testing_env!(get_context()); + + let lockup_id: LockupIndex = 100; + let termination_timestamp: TimestampSec = 1_800_000_000; + let unvested_balance: WrappedBalance = 10000.into(); + + let event = FtLockupTerminateLockup { + id: lockup_id, + termination_timestamp, + unvested_balance, + }; + + emit(EventKind::FtLockupTerminateLockup(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_terminate_lockup", + "data": [ + { + "id": lockup_id, + "termination_timestamp": termination_timestamp, + "unvested_balance": unvested_balance, + }, + ], + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_create_draft_group() { + testing_env!(get_context()); + + let draft_group_id: DraftGroupIndex = 22; + + let event = FtLockupCreateDraftGroup { id: draft_group_id }; + + emit(EventKind::FtLockupCreateDraftGroup(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_create_draft_group", + "data": [ + { + "id": draft_group_id, + }, + ], + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_create_draft() { + testing_env!(get_context()); + + let account_id: ValidAccountId = "alice.near".try_into().unwrap(); + let balance: WrappedBalance = 10_000.into(); + let timestamp: TimestampSec = 1_500_000_000; + let lockup_create = LockupCreate { + account_id: account_id.clone(), + schedule: Schedule::new_unlocked_since(balance.0, timestamp), + vesting_schedule: None, + }; + let draft_group_id: DraftGroupIndex = 123; + let draft = Draft { + draft_group_id, + lockup_create, + }; + let draft_id: DraftIndex = 33; + + let event: FtLockupCreateDraft = (draft_id, draft).into(); + + emit(EventKind::FtLockupCreateDraft(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_create_draft", + "data": [ + { + "id": draft_id, + "draft_group_id": draft_group_id, + "account_id": account_id.to_string(), + "balance": balance, + "start": timestamp - 1, + "finish": timestamp, + "terminatable": false, + }, + ], + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_fund_draft_group() { + testing_env!(get_context()); + + let draft_group_id: DraftGroupIndex = 22; + let amount: WrappedBalance = 10000.into(); + + let event = FtLockupFundDraftGroup { + id: draft_group_id, + amount, + }; + + emit(EventKind::FtLockupFundDraftGroup(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_fund_draft_group", + "data": [ + { + "id": draft_group_id, + "amount": amount, + }, + ], + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_discard_draft_group() { + testing_env!(get_context()); + + let draft_group_id: DraftGroupIndex = 22; + + let event = FtLockupDiscardDraftGroup { id: draft_group_id }; + + emit(EventKind::FtLockupDiscardDraftGroup(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_discard_draft_group", + "data": [ + { + "id": draft_group_id, + }, + ], + }) + .to_string(), + ) + ); + } + + #[test] + fn test_ft_lockup_delete_draft() { + testing_env!(get_context()); + + let draft_id: DraftIndex = 22; + + let event = FtLockupDeleteDraft { id: draft_id }; + + emit(EventKind::FtLockupDeleteDraft(vec![event])); + assert_eq!( + test_utils::get_logs()[0], + format!( + r"EVENT_JSON:{}", + json!({ + "standard": PACKAGE_NAME, + "version": VERSION, + "event": "ft_lockup_delete_draft", + "data": [ + { + "id": draft_id, + }, + ], + }) + .to_string(), + ) + ); + } +} diff --git a/src/ft_token_receiver.rs b/src/ft_token_receiver.rs index 467d364..ff2c63d 100644 --- a/src/ft_token_receiver.rs +++ b/src/ft_token_receiver.rs @@ -4,6 +4,8 @@ use crate::*; #[serde(crate = "near_sdk::serde")] pub struct DraftGroupFunding { pub draft_group_id: DraftGroupIndex, + // use remaining gas to try converting drafts + pub try_convert: Option, } #[derive(Serialize, Deserialize)] @@ -41,6 +43,8 @@ impl FungibleTokenReceiver for Contract { lockup.account_id.as_ref(), index ); + let event: FtLockupCreateLockup = (index, lockup, None).into(); + emit(EventKind::FtLockupCreateLockup(vec![event])); } FtMessage::DraftGroupFunding(funding) => { let draft_group_id = funding.draft_group_id; @@ -55,6 +59,27 @@ impl FungibleTokenReceiver for Contract { draft_group.fund(&sender_id); self.draft_groups.insert(&draft_group_id as _, &draft_group); log!("Funded draft group {}", draft_group_id); + + if funding.try_convert.unwrap_or(false) { + // Using remaining gas to try convert drafts, not waiting for results + if let Some(remaining_gas) = + env::prepaid_gas().checked_sub(env::used_gas() + GAS_EXT_CALL_COST) + { + if remaining_gas > GAS_MIN_FOR_CONVERT { + ext_self::convert_drafts( + draft_group.draft_indices.into_iter().collect(), + &env::current_account_id(), + NO_DEPOSIT, + remaining_gas, + ); + } + } + } + let event = FtLockupFundDraftGroup { + id: draft_group_id, + amount: amount.into(), + }; + emit(EventKind::FtLockupFundDraftGroup(vec![event])); } } diff --git a/src/lib.rs b/src/lib.rs index bc11696..441fae4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ use near_sdk::{ pub mod callbacks; pub mod draft; +pub mod event; pub mod ft_token_receiver; pub mod internal; pub mod lockup; @@ -24,6 +25,7 @@ pub mod util; pub mod view; use crate::draft::*; +use crate::event::*; use crate::lockup::*; use crate::schedule::*; use crate::termination::*; @@ -34,8 +36,13 @@ near_sdk::setup_alloc!(); pub type TimestampSec = u32; pub type TokenAccountId = AccountId; +pub const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + const GAS_FOR_FT_TRANSFER: Gas = 15_000_000_000_000; const GAS_FOR_AFTER_FT_TRANSFER: Gas = 20_000_000_000_000; +const GAS_EXT_CALL_COST: Gas = 10_000_000_000_000; +const GAS_MIN_FOR_CONVERT: Gas = 15_000_000_000_000; const ONE_YOCTO: Balance = 1; const NO_DEPOSIT: Balance = 0; @@ -57,6 +64,8 @@ pub trait SelfCallbacks { account_id: AccountId, amount: WrappedBalance, ) -> WrappedBalance; + + fn convert_drafts(&mut self, draft_ids: Vec) -> Vec; } #[near_bindgen] @@ -103,15 +112,33 @@ impl Contract { draft_operators_whitelist: Option>, ) -> Self { let mut deposit_whitelist_set = UnorderedSet::new(StorageKey::DepositWhitelist); - deposit_whitelist_set.extend(deposit_whitelist.into_iter().map(|a| a.into())); + deposit_whitelist_set.extend(deposit_whitelist.clone().into_iter().map(|a| a.into())); let mut draft_operators_whitelist_set = UnorderedSet::new(StorageKey::DraftOperatorsWhitelist); draft_operators_whitelist_set.extend( draft_operators_whitelist + .clone() .unwrap_or(vec![]) .into_iter() .map(|a| a.into()), ); + emit(EventKind::FtLockupNew(FtLockupNew { + token_account_id: token_account_id.clone().into(), + })); + emit(EventKind::FtLockupAddToDepositWhitelist( + FtLockupAddToDepositWhitelist { + account_ids: deposit_whitelist.into_iter().map(|x| x.into()).collect(), + }, + )); + emit(EventKind::FtLockupAddToDraftOperatorsWhitelist( + FtLockupAddToDraftOperatorsWhitelist { + account_ids: draft_operators_whitelist + .unwrap_or(vec![]) + .into_iter() + .map(|x| x.into()) + .collect(), + }, + )); Self { lockups: Vector::new(StorageKey::Lockups), account_lockups: LookupMap::new(StorageKey::AccountLockups), @@ -256,6 +283,13 @@ impl Contract { self.internal_save_account_lockups(&lockup_account_id, indices); } + let event = FtLockupTerminateLockup { + id: lockup_index, + termination_timestamp, + unvested_balance: unvested_balance.into(), + }; + emit(EventKind::FtLockupTerminateLockup(vec![event])); + if unvested_balance > 0 { ext_fungible_token::ft_transfer( beneficiary_id.clone(), @@ -292,9 +326,14 @@ impl Contract { } else { vec![account_id.expect("expected either account_id or account_ids")] }; - for account_id in account_ids { + for account_id in &account_ids { self.deposit_whitelist.insert(account_id.as_ref()); } + emit(EventKind::FtLockupAddToDepositWhitelist( + FtLockupAddToDepositWhitelist { + account_ids: account_ids.into_iter().map(|x| x.into()).collect(), + }, + )); } // preserving both options for API compatibility @@ -311,27 +350,46 @@ impl Contract { } else { vec![account_id.expect("expected either account_id or account_ids")] }; - for account_id in account_ids { - self.deposit_whitelist.remove(&account_id.into()); + for account_id in &account_ids { + self.deposit_whitelist.remove(&account_id.to_string()); } + assert!( + !self.deposit_whitelist.is_empty(), + "cannot remove all accounts from deposit whitelist", + ); + emit(EventKind::FtLockupRemoveFromDepositWhitelist( + FtLockupRemoveFromDepositWhitelist { + account_ids: account_ids.into_iter().map(|x| x.into()).collect(), + }, + )); } #[payable] pub fn add_to_draft_operators_whitelist(&mut self, account_ids: Vec) { assert_one_yocto(); self.assert_deposit_whitelist(&env::predecessor_account_id()); - for account_id in account_ids { + for account_id in &account_ids { self.draft_operators_whitelist.insert(account_id.as_ref()); } + emit(EventKind::FtLockupAddToDraftOperatorsWhitelist( + FtLockupAddToDraftOperatorsWhitelist { + account_ids: account_ids.into_iter().map(|x| x.into()).collect(), + }, + )); } #[payable] pub fn remove_from_draft_operators_whitelist(&mut self, account_ids: Vec) { assert_one_yocto(); self.assert_deposit_whitelist(&env::predecessor_account_id()); - for account_id in account_ids { + for account_id in &account_ids { self.draft_operators_whitelist.remove(account_id.as_ref()); } + emit(EventKind::FtLockupRemoveFromDraftOperatorsWhitelist( + FtLockupRemoveFromDraftOperatorsWhitelist { + account_ids: account_ids.into_iter().map(|x| x.into()).collect(), + }, + )); } pub fn create_draft_group(&mut self) -> DraftGroupIndex { @@ -345,6 +403,9 @@ impl Contract { .is_none(), "Invariant" ); + emit(EventKind::FtLockupCreateDraftGroup(vec![ + FtLockupCreateDraftGroup { id: index }, + ])); index } @@ -356,8 +417,9 @@ impl Contract { pub fn create_drafts(&mut self, drafts: Vec) -> Vec { self.assert_draft_operators_whitelist(&env::predecessor_account_id()); let mut draft_group_lookup: HashMap = HashMap::new(); + let mut events: Vec = vec![]; let draft_ids: Vec = drafts - .iter() + .into_iter() .map(|draft| { let draft_group = draft_group_lookup .entry(draft.draft_group_id) @@ -377,11 +439,14 @@ impl Contract { .checked_add(draft.total_balance()) .expect("attempt to add with overflow"); draft_group.draft_indices.insert(index); + let event: FtLockupCreateDraft = (index, draft).into(); + events.push(event); index }) .collect(); + emit(EventKind::FtLockupCreateDraft(events)); draft_group_lookup .iter() .for_each(|(draft_group_id, draft_group)| { @@ -397,6 +462,7 @@ impl Contract { pub fn convert_drafts(&mut self, draft_ids: Vec) -> Vec { let mut draft_group_lookup: HashMap = HashMap::new(); + let mut events: Vec = vec![]; let lockup_ids: Vec = draft_ids .iter() .map(|draft_id| { @@ -421,17 +487,16 @@ impl Contract { let lockup = draft.lockup_create.into_lockup(&payer_id); let index = self.internal_add_lockup(&lockup); - log!( - "Created new lockup for {} with index {} from draft {}", - lockup.account_id.as_ref(), - index, - draft_id, - ); + + let event: FtLockupCreateLockup = (index, lockup, Some(draft_id.clone())).into(); + events.push(event); index }) .collect(); + emit(EventKind::FtLockupCreateLockup(events)); + draft_group_lookup .iter() .for_each(|(draft_group_id, draft_group)| { @@ -459,11 +524,16 @@ impl Contract { } else { self.draft_groups.insert(&draft_group_id as _, &draft_group); } + + emit(EventKind::FtLockupDiscardDraftGroup(vec![ + FtLockupDiscardDraftGroup { id: draft_group_id }, + ])); } pub fn delete_drafts(&mut self, draft_ids: Vec) { // no authorization required here since the draft group discard has been authorized let mut draft_group_lookup: HashMap = HashMap::new(); + let mut events: Vec = vec![]; for draft_id in &draft_ids { let draft = self.drafts.remove(&draft_id as _).expect("draft not found"); let draft_group = draft_group_lookup @@ -480,8 +550,15 @@ impl Contract { draft_group.total_amount -= amount; assert!(draft_group.draft_indices.remove(draft_id), "Invariant"); + + let event = FtLockupDeleteDraft { + id: draft_id.clone(), + }; + events.push(event); } + emit(EventKind::FtLockupDeleteDraft(events)); + for (draft_group_id, draft_group) in &draft_group_lookup { if draft_group.draft_indices.is_empty() { self.draft_groups.remove(&draft_group_id as _); diff --git a/src/lockup.rs b/src/lockup.rs index eb0843f..bf8932c 100644 --- a/src/lockup.rs +++ b/src/lockup.rs @@ -27,15 +27,23 @@ pub struct Lockup { } impl Lockup { - pub fn new_unlocked(account_id: AccountId, total_balance: Balance) -> Self { + pub fn new_unlocked_since( + account_id: AccountId, + total_balance: Balance, + timestamp: TimestampSec, + ) -> Self { Self { account_id: account_id.try_into().unwrap(), - schedule: Schedule::new_unlocked(total_balance), + schedule: Schedule::new_unlocked_since(total_balance, timestamp), claimed_balance: 0, termination_config: None, } } + pub fn new_unlocked(account_id: AccountId, total_balance: Balance) -> Self { + Self::new_unlocked_since(account_id, total_balance, 1) + } + pub fn claim(&mut self, index: LockupIndex, claim_amount: Balance) -> LockupClaim { let unlocked_balance = self.schedule.unlocked_balance(current_timestamp_sec()); let balance_claimed_new = self diff --git a/src/schedule.rs b/src/schedule.rs index 8a176b8..0f4bed0 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -16,19 +16,43 @@ pub struct Checkpoint { pub struct Schedule(pub Vec); impl Schedule { - pub fn new_unlocked(total_balance: Balance) -> Self { + pub fn new_zero_balance_from_to( + start_timestamp: TimestampSec, + finish_timestamp: TimestampSec, + ) -> Self { + assert!(finish_timestamp > start_timestamp, "Invariant"); + Self(vec![ Checkpoint { - timestamp: 0, + timestamp: start_timestamp, balance: 0, }, Checkpoint { - timestamp: 1, + timestamp: finish_timestamp, + balance: 0, + }, + ]) + } + + pub fn new_unlocked_since(total_balance: Balance, timestamp: TimestampSec) -> Self { + assert!(timestamp > 0, "Invariant"); + Self(vec![ + Checkpoint { + timestamp: timestamp - 1, + balance: 0, + }, + Checkpoint { + timestamp: timestamp, balance: total_balance, }, ]) } + #[cfg(not(target_arch = "wasm32"))] + pub fn new_unlocked(total_balance: Balance) -> Self { + Self::new_unlocked_since(total_balance, 1) + } + pub fn assert_valid(&self, total_balance: Balance) { assert!(self.0.len() >= 2, "At least two checkpoints is required"); assert_eq!( @@ -109,9 +133,18 @@ impl Schedule { /// Terminates the lockup schedule earlier. /// Assumes new_total_balance is not greater than the current total balance. - pub fn terminate(&mut self, new_total_balance: Balance) { + pub fn terminate(&mut self, new_total_balance: Balance, finish_timestamp: TimestampSec) { if new_total_balance == 0 { - self.0 = Self::new_unlocked(0).0; + // finish_timestamp is a hint, only used for fully unvested schedules + // can be overwritten to preserve schedule invariants + // used to preserve part of the schedule before the termination happens + let start_timestamp = self.0[0].timestamp; + let finish_timestamp = if finish_timestamp > start_timestamp { + finish_timestamp + } else { + start_timestamp + 1 + }; + self.0 = Self::new_zero_balance_from_to(start_timestamp, finish_timestamp).0; return; } assert!( diff --git a/src/termination.rs b/src/termination.rs index 624d847..d638dd9 100644 --- a/src/termination.rs +++ b/src/termination.rs @@ -52,7 +52,8 @@ impl Lockup { .unlocked_balance(termination_timestamp); let unvested_balance = total_balance - vested_balance; if unvested_balance > 0 { - self.schedule.terminate(vested_balance); + self.schedule + .terminate(vested_balance, termination_timestamp); } (unvested_balance, termination_config.beneficiary_id.into()) } diff --git a/src/view.rs b/src/view.rs index 4e59403..6b8b756 100644 --- a/src/view.rs +++ b/src/view.rs @@ -241,4 +241,8 @@ impl Contract { .filter_map(|index| self.get_draft(index).map(|draft| (index, draft))) .collect() } + + pub fn get_version(&self) -> String { + VERSION.into() + } } diff --git a/tests/deposit_whitelist.rs b/tests/deposit_whitelist.rs index deed1f1..45fffde 100644 --- a/tests/deposit_whitelist.rs +++ b/tests/deposit_whitelist.rs @@ -100,9 +100,10 @@ fn test_deposit_whitelist_get() { // not increased assert_eq!(lockups.len(), 1); - // user from whiltelist can remove itself from the list, even if it's the last user + // try remove last user from the list, should fail let res = e.remove_from_deposit_whitelist(&users.eve, &users.eve.valid_account_id()); - assert!(res.is_ok()); - let deposit_whitelist = e.get_deposit_whitelist(); - assert!(deposit_whitelist.is_empty()); + assert!(!res.is_ok()); + assert!( + format!("{:?}", res.status()).contains("cannot remove all accounts from deposit whitelist") + ); } diff --git a/tests/draft.rs b/tests/draft.rs index c9b7e88..6bec572 100644 --- a/tests/draft.rs +++ b/tests/draft.rs @@ -222,6 +222,88 @@ fn test_fund_draft_group() { assert!(format!("{:?}", res.status()).contains("group already funded")); } +#[test] +fn test_fund_draft_group_with_convert() { + let e = Env::init(None); + let users = Users::init(&e); + e.set_time_sec(GENESIS_TIMESTAMP_SEC); + + let amount = d(60000, TOKEN_DECIMALS); + let draft_group_id = 0; + let draft = Draft { + draft_group_id, + lockup_create: LockupCreate::new_unlocked(users.alice.valid_account_id(), amount), + }; + + e.create_draft_group(&e.owner); + + // create draft 0 + let res = e.create_draft(&e.owner, &draft); + assert!(res.is_ok()); + + // fund draft group + let res = e.fund_draft_group_with_convert(&e.owner, amount, 0); + let balance: WrappedBalance = res.unwrap_json(); + assert_eq!(balance.0, amount); + + let res = e.get_draft_group(0); + assert!(res.is_none(), "expected draft group to be removed"); + + // draft should have been converted to lockup + let res = e.get_lockups_paged(None, None); + assert_eq!(res.len(), 1); + // draft should have been converted to lockup + let res = e.get_draft(0); + assert!(res.is_none(), "expected draft to be converted"); + let res = e.get_draft_groups_paged(None, None); + assert_eq!(res.len(), 0, "expected draft group to be removed"); +} + +#[test] +fn test_fund_draft_group_with_convert_too_big_group() { + let e = Env::init(None); + let users = Users::init(&e); + e.set_time_sec(GENESIS_TIMESTAMP_SEC); + + let amount = d(600, TOKEN_DECIMALS); + let draft_group_id = 0; + let draft = Draft { + draft_group_id, + lockup_create: LockupCreate::new_unlocked(users.alice.valid_account_id(), amount), + }; + + e.create_draft_group(&e.owner); + + let n_drafts = 100; + // intentionally create too big draft group to convert with restricted gas + let drafts: Vec = iter::repeat(draft).take(n_drafts).collect(); + + // create draft 0 + let res = e.create_drafts(&e.owner, &drafts); + assert!(res.is_ok()); + + // fund draft group + let res = e.fund_draft_group_with_convert(&e.owner, amount * (n_drafts as Balance), 0); + let balance: WrappedBalance = res.unwrap_json(); + // draft group has been converted since ft_transfer_call succeeds + assert_eq!(balance.0, amount * (n_drafts as Balance)); + + // but the draft group tried to be converted and failed + let res = e.get_draft_group(0); + assert!(res.is_some(), "expected draft group to not be removed"); + let res: DraftGroupView = res.unwrap(); + assert!(res.funded, "expected draft group to be funded"); + + // lockups should not have been created + let res = e.get_lockups_paged(None, None); + assert_eq!(res.len(), 0); + // draft should not have been converted to lockup + let res = e.get_draft(0); + assert!(res.is_some(), "expected draft not to be converted"); + let res = e.get_draft_groups_paged(None, None); + assert_eq!(res.len(), 1, "expected draft group not to be removed"); +} + #[test] fn test_convert_draft() { let e = Env::init(None); @@ -808,12 +890,15 @@ fn test_draft_operator_permission_updates() { let balance: WrappedBalance = res.unwrap_json(); assert_eq!(balance.0, amount); + // adding new draft operator, it's not allowed to remove every deposit_whitelist + let res = e.add_to_deposit_whitelist(&e.owner, &users.charlie.valid_account_id()); + assert!(res.is_ok()); // removing deposit role, draft operator role must be retained let res = e.remove_from_deposit_whitelist(&e.owner, &e.owner.valid_account_id()); assert!(res.is_ok()); // deposit role is removed let res: Vec = e.get_deposit_whitelist(); - assert_eq!(res, vec![] as Vec); + assert_eq!(res, vec![users.charlie.account_id()] as Vec); // draft operator role is still present let res: Vec = e.get_draft_operators_whitelist(); assert_eq!(res, vec![users.eve.account_id(), e.owner.account_id()]); diff --git a/tests/setup.rs b/tests/setup.rs index 147ab9b..31c4561 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] +pub use std::iter; + use near_contract_standards::fungible_token::metadata::{FungibleTokenMetadata, FT_METADATA_SPEC}; pub use near_sdk::json_types::{Base58CryptoHash, ValidAccountId, WrappedBalance}; use near_sdk::serde_json::json; @@ -14,7 +16,7 @@ use ft_lockup::ft_token_receiver::DraftGroupFunding; pub use ft_lockup::lockup::{Lockup, LockupCreate, LockupIndex}; pub use ft_lockup::schedule::{Checkpoint, Schedule}; pub use ft_lockup::termination::{TerminationConfig, VestingConditions}; -use ft_lockup::view::{DraftGroupView, DraftView, LockupView}; +pub use ft_lockup::view::{DraftGroupView, DraftView, LockupView}; pub use ft_lockup::{ContractContract as FtLockupContract, TimestampSec}; near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { @@ -36,6 +38,7 @@ pub const DRAFT_OPERATOR_ID: &str = "draft_operator.near"; pub const T_GAS: Gas = 10u64.pow(12); pub const DEFAULT_GAS: Gas = 15 * T_GAS; pub const MAX_GAS: Gas = 300 * T_GAS; +pub const FT_TRANSFER_CALL_GAS: Gas = 60 * T_GAS; pub const CLAIM_GAS: Gas = 100 * T_GAS; pub const TERMINATE_GAS: Gas = 100 * T_GAS; @@ -61,6 +64,10 @@ pub struct Users { pub fn lockup_vesting_schedule(amount: u128) -> (Schedule, Schedule) { let lockup_schedule = Schedule(vec![ + Checkpoint { + timestamp: GENESIS_TIMESTAMP_SEC, + balance: 0, + }, Checkpoint { timestamp: GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC * 2, balance: 0, @@ -75,6 +82,10 @@ pub fn lockup_vesting_schedule(amount: u128) -> (Schedule, Schedule) { }, ]); let vesting_schedule = Schedule(vec![ + Checkpoint { + timestamp: GENESIS_TIMESTAMP_SEC, + balance: 0, + }, Checkpoint { timestamp: GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC - 1, balance: 0, @@ -257,7 +268,7 @@ impl Env { }) .to_string() .into_bytes(), - MAX_GAS, + FT_TRANSFER_CALL_GAS, 1, ) } @@ -277,7 +288,23 @@ impl Env { amount: Balance, draft_group_id: DraftGroupIndex, ) -> ExecutionResult { - let funding = DraftGroupFunding { draft_group_id }; + let funding = DraftGroupFunding { + draft_group_id, + try_convert: None, + }; + self.ft_transfer_call(user, amount, &serde_json::to_string(&funding).unwrap()) + } + + pub fn fund_draft_group_with_convert( + &self, + user: &UserAccount, + amount: Balance, + draft_group_id: DraftGroupIndex, + ) -> ExecutionResult { + let funding = DraftGroupFunding { + draft_group_id, + try_convert: Some(true), + }; self.ft_transfer_call(user, amount, &serde_json::to_string(&funding).unwrap()) } @@ -438,7 +465,7 @@ impl Env { pub fn create_drafts(&self, user: &UserAccount, drafts: &Vec) -> ExecutionResult { user.function_call( self.contract.contract.create_drafts(drafts.clone()), - DEFAULT_GAS, + MAX_GAS, 0, ) } @@ -543,6 +570,12 @@ impl Env { .unwrap_json() } + pub fn get_version(&self) -> String { + self.near + .view_method_call(self.contract.contract.get_version()) + .unwrap_json() + } + pub fn get_next_draft_group_id(&self) -> DraftGroupIndex { self.near .view_method_call(self.contract.contract.get_next_draft_group_id()) diff --git a/tests/terminate.rs b/tests/terminate.rs index 65f6d14..7524454 100644 --- a/tests/terminate.rs +++ b/tests/terminate.rs @@ -81,29 +81,79 @@ fn test_terminate_basic_payer_logic() { let res = e.terminate(&e.owner, lockup_index); assert!(!res.is_ok()); assert!(format!("{:?}", res.status()).contains("No termination config")); +} + +#[test] +fn test_terminate_when_payer_doesnt_have_storage_deposit() { + let e = Env::init(None); + let users = Users::init(&e); + let amount = d(60000, TOKEN_DECIMALS); + e.set_time_sec(GENESIS_TIMESTAMP_SEC); + let lockups = e.get_account_lockups(&users.alice); + assert!(lockups.is_empty()); + + // adding another owner + let res = e.add_to_deposit_whitelist(&e.owner, &users.eve.valid_account_id()); + assert!(res.is_ok()); + ft_storage_deposit(&e.owner, TOKEN_ID, &users.eve.account_id); + e.ft_transfer(&e.owner, amount, &users.eve); + + let schedule = Schedule(vec![ + Checkpoint { + timestamp: GENESIS_TIMESTAMP_SEC, + balance: 0, + }, + Checkpoint { + timestamp: GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC, + balance: amount, + }, + ]); let lockup_create = LockupCreate { - account_id: users.bob.valid_account_id(), + account_id: users.alice.valid_account_id(), schedule: schedule.clone(), vesting_schedule: Some(VestingConditions::Schedule(schedule.clone())), }; - // creating lockup for user without storage deposit - let res = e.add_lockup(&e.owner, amount, &lockup_create); + + // create lockup succeeds + let res = e.add_lockup(&users.eve, amount, &lockup_create); let balance: WrappedBalance = res.unwrap_json(); assert_eq!(balance.0, amount); - let lockups = e.get_account_lockups(&users.bob); + + let lockups = e.get_account_lockups(&users.alice); assert_eq!(lockups.len(), 1); let lockup_index = lockups[0].0; - storage_force_unregister(&e.owner, TOKEN_ID); + storage_force_unregister(&users.eve, TOKEN_ID); + // terminate with no storage deposit creates unlocked lockup - let res: WrappedBalance = e.terminate(&e.owner, lockup_index).unwrap_json(); + let termination_timestamp: TimestampSec = GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC / 3; + e.set_time_sec(termination_timestamp); + let res: WrappedBalance = e.terminate(&users.eve, lockup_index).unwrap_json(); assert_eq!(res.0, 0); - let lockups = e.get_account_lockups(&e.owner); + let lockups = e.get_account_lockups(&users.eve); assert_eq!(lockups.len(), 1); - assert_eq!(lockups[0].1.unclaimed_balance, amount); + let lockup = &lockups[0].1; + assert_eq!(lockup.unclaimed_balance, amount * 2 / 3); + assert_eq!(lockup.total_balance, amount * 2 / 3); let balance = e.ft_balance_of(&users.alice); assert_eq!(balance, 0); + + // checking schedule, must be unlocked since the moment of termination + // starting checkpoint is preserved + assert_eq!(lockup.schedule.0[0].balance, 0); + assert_eq!( + lockup.schedule.0[0].timestamp, + termination_timestamp - 1, + "expected refund finish first timestamp one second before the termination" + ); + // finish checkpoint is termination timestamp + assert_eq!(lockup.schedule.0[1].balance, amount * 2 / 3); + assert_eq!( + lockup.schedule.0[1].timestamp, // trimmed schedule + termination_timestamp, + "expected refund finish to be at termination timestamp" + ); } #[test] @@ -386,6 +436,93 @@ fn test_lockup_terminate_custom_vesting_incompatible_vesting_schedule_by_hash() assert!(format!("{:?}", res.status()).contains("The lockup schedule is ahead of")); } +#[test] +fn test_lockup_terminate_custom_vesting_terminate_before_schedule_start() { + let e = Env::init(None); + let users = Users::init(&e); + let amount = d(60000, TOKEN_DECIMALS); + e.set_time_sec(GENESIS_TIMESTAMP_SEC); + let lockups = e.get_account_lockups(&users.alice); + assert!(lockups.is_empty()); + + let res = e.add_to_deposit_whitelist(&e.owner, &users.eve.valid_account_id()); + assert!(res.is_ok()); + ft_storage_deposit(&e.owner, TOKEN_ID, &users.eve.account_id); + e.ft_transfer(&e.owner, amount, &users.eve); + + let (lockup_schedule, vesting_schedule) = lockup_vesting_schedule(amount); + let lockup_create = LockupCreate { + account_id: users.alice.valid_account_id(), + schedule: lockup_schedule.clone(), + vesting_schedule: Some(VestingConditions::Schedule(vesting_schedule)), + }; + + e.set_time_sec(GENESIS_TIMESTAMP_SEC - ONE_YEAR_SEC); + let balance: WrappedBalance = e + .add_lockup(&users.eve, amount, &lockup_create) + .unwrap_json(); + assert_eq!(balance.0, amount); + let lockups = e.get_account_lockups(&users.alice); + assert_eq!(lockups.len(), 1); + let lockup_index = lockups[0].0; + + // 1 second before lockup schedule start + let termination_timestamp: TimestampSec = GENESIS_TIMESTAMP_SEC - 1; + e.set_time_sec(termination_timestamp); + let lockups = e.get_account_lockups(&users.alice); + assert_eq!(lockups[0].1.total_balance, amount); + assert_eq!(lockups[0].1.claimed_balance, 0); + assert_eq!(lockups[0].1.unclaimed_balance, 0); + + // TERMINATE + let res: WrappedBalance = e.terminate(&users.eve, lockup_index).unwrap_json(); + assert_eq!(res.0, amount); + + let terminator_balance = e.ft_balance_of(&users.eve); + assert_eq!(terminator_balance, amount); + + // Checking lockup + + // after ALL the schedules have finished + + let lockups = e.get_account_lockups(&users.alice); + assert!(lockups.is_empty()); + + let lockup = e.get_lockup(lockup_index); + assert_eq!(lockup.total_balance, 0); + assert_eq!(lockup.claimed_balance, 0); + assert_eq!(lockup.unclaimed_balance, 0); + + println!("{:#?}", lockup); + assert_eq!( + lockup.schedule.0.len(), + 2, + "expected terminated schedule to have two checkpoints" + ); + // starting checkpoint is preserved + assert_eq!(lockup.schedule.0[0].balance, 0); + assert_eq!( + lockup.schedule.0[0].timestamp, // trimmed schedule + lockup_schedule.0[0].timestamp, // original schedule + "expected terminate schedule start to be preserved" + ); + // finish checkpoint is termination timestamp + assert_eq!(lockup.schedule.0[1].balance, 0); + assert_eq!( + lockup.schedule.0[1].timestamp, // trimmed schedule + lockup_schedule.0[0].timestamp + 1, // right after schedule start + "expected terminate schedule finish right after start" + ); + + e.set_time_sec(GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC * 4); + // Trying to claim + let res: WrappedBalance = e.claim(&users.alice).unwrap_json(); + assert_eq!(res.0, 0); + + let balance = e.ft_balance_of(&users.alice); + assert_eq!(balance, 0); +} + #[test] fn test_lockup_terminate_custom_vesting_terminate_before_cliff() { let e = Env::init(None); @@ -403,7 +540,7 @@ fn test_lockup_terminate_custom_vesting_terminate_before_cliff() { let (lockup_schedule, vesting_schedule) = lockup_vesting_schedule(amount); let lockup_create = LockupCreate { account_id: users.alice.valid_account_id(), - schedule: lockup_schedule, + schedule: lockup_schedule.clone(), vesting_schedule: Some(VestingConditions::Schedule(vesting_schedule)), }; @@ -416,7 +553,8 @@ fn test_lockup_terminate_custom_vesting_terminate_before_cliff() { let lockup_index = lockups[0].0; // 1Y - 1 before cliff termination - e.set_time_sec(GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC - 1); + let termination_timestamp: TimestampSec = GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC - 1; + e.set_time_sec(termination_timestamp); let lockups = e.get_account_lockups(&users.alice); assert_eq!(lockups[0].1.total_balance, amount); assert_eq!(lockups[0].1.claimed_balance, 0); @@ -441,6 +579,28 @@ fn test_lockup_terminate_custom_vesting_terminate_before_cliff() { assert_eq!(lockup.claimed_balance, 0); assert_eq!(lockup.unclaimed_balance, 0); + println!("{:#?}", lockup); + assert_eq!( + lockup.schedule.0.len(), + 2, + "expected terminated schedule to have two checkpoints" + ); + // starting checkpoint is preserved + assert_eq!(lockup.schedule.0[0].balance, 0); + assert_eq!( + lockup.schedule.0[0].timestamp, // trimmed schedule + lockup_schedule.0[0].timestamp, // original schedule + "expected terminate schedule start to be preserved" + ); + // finish checkpoint is termination timestamp + assert_eq!(lockup.schedule.0[1].balance, 0); + assert_eq!( + lockup.schedule.0[1].timestamp, // trimmed schedule + termination_timestamp, + "expected terminate schedule finish to be at termination timestamp" + ); + + e.set_time_sec(GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC * 4); // Trying to claim let res: WrappedBalance = e.claim(&users.alice).unwrap_json(); assert_eq!(res.0, 0); diff --git a/tests/terminate_in_future.rs b/tests/terminate_in_future.rs index 37f986f..af5a1f0 100644 --- a/tests/terminate_in_future.rs +++ b/tests/terminate_in_future.rs @@ -85,3 +85,79 @@ fn test_lockup_terminate_with_timestamp_in_future() { assert_eq!(lockup.claimed_balance, amount / 2); assert_eq!(lockup.unclaimed_balance, 0); } + +#[test] +fn test_lockup_terminate_with_timestamp_in_future_no_storage_deposit() { + let e = Env::init(None); + let users = Users::init(&e); + let amount = d(60000, TOKEN_DECIMALS); + e.set_time_sec(GENESIS_TIMESTAMP_SEC); + let lockups = e.get_account_lockups(&users.alice); + assert!(lockups.is_empty()); + + // adding another owner + let res = e.add_to_deposit_whitelist(&e.owner, &users.eve.valid_account_id()); + assert!(res.is_ok()); + ft_storage_deposit(&e.owner, TOKEN_ID, &users.eve.account_id); + e.ft_transfer(&e.owner, amount, &users.eve); + + let schedule = Schedule(vec![ + Checkpoint { + timestamp: GENESIS_TIMESTAMP_SEC, + balance: 0, + }, + Checkpoint { + timestamp: GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC, + balance: amount, + }, + ]); + + let lockup_create = LockupCreate { + account_id: users.alice.valid_account_id(), + schedule: schedule.clone(), + vesting_schedule: Some(VestingConditions::Schedule(schedule.clone())), + }; + + // create lockup succeeds + let res = e.add_lockup(&users.eve, amount, &lockup_create); + let balance: WrappedBalance = res.unwrap_json(); + assert_eq!(balance.0, amount); + + let lockups = e.get_account_lockups(&users.alice); + assert_eq!(lockups.len(), 1); + let lockup_index = lockups[0].0; + + storage_force_unregister(&users.eve, TOKEN_ID); + + // terminate with no storage deposit creates unlocked lockup + let termination_call_timestamp = GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC * 1 / 3; + let termination_effective_timestamp = GENESIS_TIMESTAMP_SEC + ONE_YEAR_SEC * 2 / 3; + e.set_time_sec(termination_call_timestamp); + let res: WrappedBalance = e + .terminate_with_timestamp(&users.eve, lockup_index, termination_effective_timestamp) + .unwrap_json(); + assert_eq!(res.0, 0); + let lockups = e.get_account_lockups(&users.eve); + assert_eq!(lockups.len(), 1); + let lockup = &lockups[0].1; + assert_eq!(lockup.unclaimed_balance, amount / 3); + assert_eq!(lockup.total_balance, amount / 3); + let balance = e.ft_balance_of(&users.alice); + assert_eq!(balance, 0); + + // checking schedule, must be unlocked since the moment of termination + // starting checkpoint is preserved + assert_eq!(lockup.schedule.0[0].balance, 0); + assert_eq!( + lockup.schedule.0[0].timestamp, + termination_call_timestamp - 1, + "expected refund finish first timestamp one second before the termination" + ); + // finish checkpoint is termination timestamp + assert_eq!(lockup.schedule.0[1].balance, amount / 3); + assert_eq!( + lockup.schedule.0[1].timestamp, // trimmed schedule + termination_call_timestamp, + "expected refund finish to be at termination timestamp" + ); +} diff --git a/tests/view.rs b/tests/view.rs index e847951..80f33ab 100644 --- a/tests/view.rs +++ b/tests/view.rs @@ -113,3 +113,11 @@ fn test_get_token_account_id() { let result = e.get_token_account_id(); assert_eq!(result, e.token.valid_account_id()); } + +#[test] +fn test_get_version() { + let e = Env::init(None); + + let result = e.get_version(); + assert_eq!(result, env!("CARGO_PKG_VERSION").to_string()); +}