diff --git a/Cargo.lock b/Cargo.lock index a91b84ae..2031cdcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,13 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "key-staking-contract" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "launch-auction" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 65bcea18..b8ff1fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "multisig-admin", "nonce-registry", "launch-auction"] +members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "key-staking-contract", "multisig-admin", "nonce-registry", "launch-auction"] resolver = "2" [workspace.package] diff --git a/key-staking-contract/Cargo.toml b/key-staking-contract/Cargo.toml new file mode 100644 index 00000000..985f5559 --- /dev/null +++ b/key-staking-contract/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "key-staking-contract" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/key-staking-contract/src/lib.rs b/key-staking-contract/src/lib.rs new file mode 100644 index 00000000..a5892b73 --- /dev/null +++ b/key-staking-contract/src/lib.rs @@ -0,0 +1,430 @@ +#![no_std] + +//! Epoch-reward staking for creator key holders. +//! +//! Key holders lock an amount for a fixed number of epochs. The protocol fee +//! treasury funds a reward pool per epoch via [`KeyStakingContract::fund_epoch`]; +//! each epoch's pool is split pro-rata to the stake weight (`amount`) active in +//! that epoch. Like the other reward crates in this workspace the contract does +//! pure bookkeeping and never moves tokens itself. +//! +//! Epochs are derived from the ledger timestamp: epoch `n` covers +//! `[genesis + n * epoch_duration, genesis + (n + 1) * epoch_duration)`. +//! A stake created in epoch `s` for `k` epochs earns from epochs `s..s + k` and +//! may be unstaked once the current epoch reaches `s + k`. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, +}; + +#[cfg(test)] +mod test; + +/// Upper bound on a single lock, keeping per-stake epoch iteration bounded. +pub const MAX_LOCK_EPOCHS: u32 = 52; + +pub const STAKED_EVENT_NAME: Symbol = symbol_short!("staked"); +pub const UNSTAKED_EVENT_NAME: Symbol = symbol_short!("unstaked"); +pub const EPOCH_FUNDED_EVENT_NAME: Symbol = symbol_short!("ep_fund"); +pub const EPOCH_DISTRIBUTED_EVENT_NAME: Symbol = symbol_short!("ep_dist"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum KeyStakingError { + NotPositiveAmount = 1, + InvalidEpochs = 2, + StakeNotFound = 3, + /// The stake's lock period (or the epoch being distributed) has not ended. + EpochNotComplete = 4, + StakeAlreadyClosed = 5, + AlreadyInitialized = 6, + NotInitialized = 7, + /// The epoch being funded has already ended. + EpochEnded = 8, + EpochAlreadyDistributed = 9, + ArithmeticOverflow = 10, + InvalidEpochDuration = 11, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct StakePosition { + pub stake_id: u64, + pub key_id: Address, + pub staker: Address, + pub amount: i128, + pub start_epoch: u32, + /// First epoch in which the stake no longer earns; unstake opens here. + pub end_epoch: u32, + pub is_closed: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct EpochState { + pub funded: i128, + pub total_weight: i128, + pub distributed: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct StakeRewards { + /// Rewards from epochs that have already completed. + pub accrued: i128, + /// Rewards from epochs still in progress or upcoming, at current pools and weights. + pub projected: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct Staked { + pub stake_id: u64, + pub key_id: Address, + pub staker: Address, + pub amount: i128, + pub start_epoch: u32, + pub end_epoch: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct Unstaked { + pub stake_id: u64, + pub staker: Address, + pub principal: i128, + pub rewards: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct EpochFunded { + pub epoch_id: u32, + pub amount: i128, + pub total_funded: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct EpochRewardDistributed { + pub epoch_id: u32, + pub total_distributed: i128, +} + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + Treasury, + Genesis, + EpochDuration, + NextStakeId, + Stake(u64), + Epoch(u32), +} + +#[contract] +pub struct KeyStakingContract; + +fn read_treasury(env: &Env) -> Result { + env.storage() + .persistent() + .get(&DataKey::Treasury) + .ok_or(KeyStakingError::NotInitialized) +} + +fn current_epoch_of(env: &Env) -> Result { + let genesis: u64 = env + .storage() + .persistent() + .get(&DataKey::Genesis) + .ok_or(KeyStakingError::NotInitialized)?; + let duration: u64 = env + .storage() + .persistent() + .get(&DataKey::EpochDuration) + .ok_or(KeyStakingError::NotInitialized)?; + let elapsed = env.ledger().timestamp().saturating_sub(genesis); + u32::try_from(elapsed / duration).map_err(|_| KeyStakingError::ArithmeticOverflow) +} + +fn read_epoch(env: &Env, epoch_id: u32) -> EpochState { + env.storage() + .persistent() + .get(&DataKey::Epoch(epoch_id)) + .unwrap_or(EpochState { + funded: 0, + total_weight: 0, + distributed: false, + }) +} + +fn write_epoch(env: &Env, epoch_id: u32, state: &EpochState) { + env.storage() + .persistent() + .set(&DataKey::Epoch(epoch_id), state); +} + +fn read_stake(env: &Env, stake_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Stake(stake_id)) + .ok_or(KeyStakingError::StakeNotFound) +} + +fn write_stake(env: &Env, stake: &StakePosition) { + env.storage() + .persistent() + .set(&DataKey::Stake(stake.stake_id), stake); +} + +/// Pro-rata share of an epoch's pool for `amount` of weight (floor division). +fn epoch_share(epoch: &EpochState, amount: i128) -> Result { + if epoch.total_weight == 0 { + return Ok(0); + } + Ok(epoch + .funded + .checked_mul(amount) + .ok_or(KeyStakingError::ArithmeticOverflow)? + / epoch.total_weight) +} + +/// Splits a stake's rewards into accrued and projected at the `current` epoch. +fn compute_rewards( + env: &Env, + stake: &StakePosition, + current: u32, +) -> Result { + let mut accrued: i128 = 0; + let mut projected: i128 = 0; + for epoch_id in stake.start_epoch..stake.end_epoch { + let share = epoch_share(&read_epoch(env, epoch_id), stake.amount)?; + let bucket = if epoch_id < current { + &mut accrued + } else { + &mut projected + }; + *bucket = bucket + .checked_add(share) + .ok_or(KeyStakingError::ArithmeticOverflow)?; + } + Ok(StakeRewards { accrued, projected }) +} + +#[contractimpl] +impl KeyStakingContract { + /// One-time setup. `treasury` is the protocol fee treasury contract, the + /// only address allowed to fund epochs. Epoch 0 starts at the current + /// ledger timestamp. + pub fn initialize( + env: Env, + treasury: Address, + epoch_duration: u64, + ) -> Result<(), KeyStakingError> { + if env.storage().persistent().has(&DataKey::Treasury) { + return Err(KeyStakingError::AlreadyInitialized); + } + if epoch_duration == 0 { + return Err(KeyStakingError::InvalidEpochDuration); + } + let store = env.storage().persistent(); + store.set(&DataKey::Treasury, &treasury); + store.set(&DataKey::Genesis, &env.ledger().timestamp()); + store.set(&DataKey::EpochDuration, &epoch_duration); + Ok(()) + } + + /// Locks `amount` for `epochs` epochs starting with the current epoch. + /// Returns the new `stake_id`. + pub fn stake( + env: Env, + key_id: Address, + staker: Address, + amount: i128, + epochs: u32, + ) -> Result { + staker.require_auth(); + + if amount <= 0 { + return Err(KeyStakingError::NotPositiveAmount); + } + if epochs == 0 || epochs > MAX_LOCK_EPOCHS { + return Err(KeyStakingError::InvalidEpochs); + } + + let start_epoch = current_epoch_of(&env)?; + let end_epoch = start_epoch + .checked_add(epochs) + .ok_or(KeyStakingError::ArithmeticOverflow)?; + + for epoch_id in start_epoch..end_epoch { + let mut state = read_epoch(&env, epoch_id); + state.total_weight = state + .total_weight + .checked_add(amount) + .ok_or(KeyStakingError::ArithmeticOverflow)?; + write_epoch(&env, epoch_id, &state); + } + + let stake_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextStakeId) + .unwrap_or(1); + env.storage().persistent().set( + &DataKey::NextStakeId, + &stake_id + .checked_add(1) + .ok_or(KeyStakingError::ArithmeticOverflow)?, + ); + + write_stake( + &env, + &StakePosition { + stake_id, + key_id: key_id.clone(), + staker: staker.clone(), + amount, + start_epoch, + end_epoch, + is_closed: false, + }, + ); + + env.events().publish( + (STAKED_EVENT_NAME, key_id.clone(), staker.clone()), + Staked { + stake_id, + key_id, + staker, + amount, + start_epoch, + end_epoch, + }, + ); + + Ok(stake_id) + } + + /// Returns principal plus all epoch rewards once the lock has completed. + pub fn unstake(env: Env, stake_id: u64) -> Result { + let mut stake = read_stake(&env, stake_id)?; + stake.staker.require_auth(); + + if stake.is_closed { + return Err(KeyStakingError::StakeAlreadyClosed); + } + let current = current_epoch_of(&env)?; + if current < stake.end_epoch { + return Err(KeyStakingError::EpochNotComplete); + } + + let rewards = compute_rewards(&env, &stake, current)?.accrued; + let principal = stake.amount; + let total_return = principal + .checked_add(rewards) + .ok_or(KeyStakingError::ArithmeticOverflow)?; + + stake.is_closed = true; + write_stake(&env, &stake); + + env.events().publish( + (UNSTAKED_EVENT_NAME, stake.key_id, stake.staker.clone()), + Unstaked { + stake_id, + staker: stake.staker, + principal, + rewards, + }, + ); + + Ok(total_return) + } + + /// Adds `amount` to the reward pool of `epoch_id` (current or future). + /// Restricted to the protocol fee treasury contract. + pub fn fund_epoch(env: Env, epoch_id: u32, amount: i128) -> Result<(), KeyStakingError> { + read_treasury(&env)?.require_auth(); + + if amount <= 0 { + return Err(KeyStakingError::NotPositiveAmount); + } + if epoch_id < current_epoch_of(&env)? { + return Err(KeyStakingError::EpochEnded); + } + + let mut state = read_epoch(&env, epoch_id); + state.funded = state + .funded + .checked_add(amount) + .ok_or(KeyStakingError::ArithmeticOverflow)?; + write_epoch(&env, epoch_id, &state); + + env.events().publish( + (EPOCH_FUNDED_EVENT_NAME, epoch_id), + EpochFunded { + epoch_id, + amount, + total_funded: state.funded, + }, + ); + Ok(()) + } + + /// Finalises a completed epoch and emits `EpochRewardDistributed`. + /// Permissionless. An epoch with no stake weight distributes nothing; + /// otherwise the whole pool is allocated pro-rata (floor-division dust, + /// under one unit per stake, stays unclaimed). + pub fn distribute_epoch(env: Env, epoch_id: u32) -> Result { + if epoch_id >= current_epoch_of(&env)? { + return Err(KeyStakingError::EpochNotComplete); + } + let mut state = read_epoch(&env, epoch_id); + if state.distributed { + return Err(KeyStakingError::EpochAlreadyDistributed); + } + + let total_distributed = if state.total_weight > 0 { + state.funded + } else { + 0 + }; + state.distributed = true; + write_epoch(&env, epoch_id, &state); + + env.events().publish( + (EPOCH_DISTRIBUTED_EVENT_NAME, epoch_id), + EpochRewardDistributed { + epoch_id, + total_distributed, + }, + ); + Ok(total_distributed) + } + + /// Accrued (completed epochs) and projected (remaining epochs) rewards. + /// A closed stake reports zero for both. + pub fn get_stake_rewards(env: Env, stake_id: u64) -> Result { + let stake = read_stake(&env, stake_id)?; + if stake.is_closed { + return Ok(StakeRewards { + accrued: 0, + projected: 0, + }); + } + compute_rewards(&env, &stake, current_epoch_of(&env)?) + } + + pub fn get_stake(env: Env, stake_id: u64) -> Result { + read_stake(&env, stake_id) + } + + pub fn get_epoch(env: Env, epoch_id: u32) -> EpochState { + read_epoch(&env, epoch_id) + } + + pub fn current_epoch(env: Env) -> Result { + current_epoch_of(&env) + } +} diff --git a/key-staking-contract/src/test.rs b/key-staking-contract/src/test.rs new file mode 100644 index 00000000..2e6ed69e --- /dev/null +++ b/key-staking-contract/src/test.rs @@ -0,0 +1,336 @@ +#![cfg(test)] + +use crate::{ + EpochRewardDistributed, KeyStakingContract, KeyStakingContractClient, KeyStakingError, + StakeRewards, EPOCH_DISTRIBUTED_EVENT_NAME, MAX_LOCK_EPOCHS, +}; +use soroban_sdk::{ + testutils::{Address as _, Events, Ledger}, + Address, Env, IntoVal, Symbol, TryFromVal, Val, Vec, +}; + +const EPOCH: u64 = 100; +const T0: u64 = 1_000; + +struct Setup { + env: Env, + client: KeyStakingContractClient<'static>, + treasury: Address, + key_id: Address, +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(T0); + let contract_id = env.register(KeyStakingContract, ()); + let client = KeyStakingContractClient::new(&env, &contract_id); + let treasury = Address::generate(&env); + client.initialize(&treasury, &EPOCH); + let key_id = Address::generate(&env); + Setup { + env, + client, + treasury, + key_id, + } +} + +fn advance_to_epoch(env: &Env, epoch: u64) { + env.ledger().set_timestamp(T0 + epoch * EPOCH); +} + +fn get_events(env: &Env, topic_symbol: Symbol) -> Vec +where + T: IntoVal + TryFromVal, +{ + let mut matching = Vec::new(env); + for (_, topics, data) in env.events().all().iter() { + if let Some(first) = topics.get(0) { + if Symbol::try_from_val(env, &first) == Ok(topic_symbol.clone()) { + if let Ok(event) = T::try_from_val(env, &data) { + matching.push_back(event); + } + } + } + } + matching +} + +#[test] +fn unstake_before_epoch_completion_fails_with_epoch_not_complete() { + let s = setup(); + let staker = Address::generate(&s.env); + let id = s.client.stake(&s.key_id, &staker, &1_000, &3); + + // Immediately, mid-lock, and in the final epoch of the lock. + for epoch in [0, 1, 2] { + advance_to_epoch(&s.env, epoch); + assert_eq!( + s.client.try_unstake(&id), + Err(Ok(KeyStakingError::EpochNotComplete)) + ); + } + // The failed attempts left the position open. + assert!(!s.client.get_stake(&id).is_closed); + + advance_to_epoch(&s.env, 3); + assert_eq!(s.client.unstake(&id), 1_000); +} + +#[test] +fn rewards_are_split_pro_rata_to_stake_weight_within_epoch() { + let s = setup(); + let a = Address::generate(&s.env); + let b = Address::generate(&s.env); + let id_a = s.client.stake(&s.key_id, &a, &300, &1); + let id_b = s.client.stake(&s.key_id, &b, &100, &1); + + s.client.fund_epoch(&0, &1_000); + advance_to_epoch(&s.env, 1); + + // 300/400 and 100/400 of the 1_000 pool, on top of principal. + assert_eq!(s.client.unstake(&id_a), 300 + 750); + assert_eq!(s.client.unstake(&id_b), 100 + 250); +} + +#[test] +fn rewards_accumulate_only_over_the_epochs_a_stake_is_active() { + let s = setup(); + let long = Address::generate(&s.env); + let short = Address::generate(&s.env); + let id_long = s.client.stake(&s.key_id, &long, &100, &2); + let id_short = s.client.stake(&s.key_id, &short, &100, &1); + + s.client.fund_epoch(&0, &200); // split 100 / 100 + s.client.fund_epoch(&1, &200); // long stake alone + + advance_to_epoch(&s.env, 2); + assert_eq!(s.client.unstake(&id_long), 100 + 100 + 200); + assert_eq!(s.client.unstake(&id_short), 100 + 100); +} + +#[test] +fn late_staker_does_not_earn_from_epochs_before_it_joined() { + let s = setup(); + let early = Address::generate(&s.env); + let late = Address::generate(&s.env); + let id_early = s.client.stake(&s.key_id, &early, &100, &2); + s.client.fund_epoch(&0, &500); + s.client.fund_epoch(&1, &500); + + advance_to_epoch(&s.env, 1); + let id_late = s.client.stake(&s.key_id, &late, &100, &1); + + advance_to_epoch(&s.env, 2); + assert_eq!(s.client.unstake(&id_early), 100 + 500 + 250); + assert_eq!(s.client.unstake(&id_late), 100 + 250); +} + +#[test] +fn fund_epoch_requires_the_treasury_contract_auth() { + let env = Env::default(); + env.ledger().set_timestamp(T0); + let contract_id = env.register(KeyStakingContract, ()); + let client = KeyStakingContractClient::new(&env, &contract_id); + let treasury = Address::generate(&env); + client.initialize(&treasury, &EPOCH); + + // No authorisation at all, and an unrelated address, are both rejected. + assert!(client.try_fund_epoch(&0, &100).is_err()); + let stranger = Address::generate(&env); + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &stranger, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "fund_epoch", + args: (0u32, 100i128).into_val(&env), + sub_invokes: &[], + }, + }]); + assert!(client.try_fund_epoch(&0, &100).is_err()); + assert_eq!(client.get_epoch(&0).funded, 0); + + // The treasury succeeds. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &treasury, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "fund_epoch", + args: (0u32, 100i128).into_val(&env), + sub_invokes: &[], + }, + }]); + client.fund_epoch(&0, &100); + assert_eq!(client.get_epoch(&0).funded, 100); +} + +#[test] +fn fund_epoch_rejects_bad_amount_and_ended_epochs_and_accumulates() { + let s = setup(); + assert_eq!( + s.client.try_fund_epoch(&0, &0), + Err(Ok(KeyStakingError::NotPositiveAmount)) + ); + s.client.fund_epoch(&5, &70); + s.client.fund_epoch(&5, &30); + assert_eq!(s.client.get_epoch(&5).funded, 100); + + advance_to_epoch(&s.env, 2); + assert_eq!( + s.client.try_fund_epoch(&1, &10), + Err(Ok(KeyStakingError::EpochEnded)) + ); + // The current epoch is still fundable. + s.client.fund_epoch(&2, &10); +} + +#[test] +fn get_stake_rewards_reports_accrued_and_projected_values() { + let s = setup(); + let staker = Address::generate(&s.env); + let id = s.client.stake(&s.key_id, &staker, &100, &2); + s.client.fund_epoch(&0, &100); + s.client.fund_epoch(&1, &100); + + let rewards = |accrued, projected| StakeRewards { accrued, projected }; + assert_eq!(s.client.get_stake_rewards(&id), rewards(0, 200)); + + advance_to_epoch(&s.env, 1); + assert_eq!(s.client.get_stake_rewards(&id), rewards(100, 100)); + + advance_to_epoch(&s.env, 2); + assert_eq!(s.client.get_stake_rewards(&id), rewards(200, 0)); + + // Projection follows pool and weight changes: a second staker halves epoch 1. + let other = Address::generate(&s.env); + let id2 = s.client.stake(&s.key_id, &other, &100, &1); + assert_eq!(s.client.get_stake_rewards(&id2), rewards(0, 0)); + + s.client.unstake(&id); + assert_eq!(s.client.get_stake_rewards(&id), rewards(0, 0)); +} + +#[test] +fn projected_rewards_shrink_when_another_staker_joins_the_epoch() { + let s = setup(); + let a = Address::generate(&s.env); + let b = Address::generate(&s.env); + let id_a = s.client.stake(&s.key_id, &a, &100, &1); + s.client.fund_epoch(&0, &1_000); + assert_eq!(s.client.get_stake_rewards(&id_a).projected, 1_000); + + s.client.stake(&s.key_id, &b, &100, &1); + assert_eq!(s.client.get_stake_rewards(&id_a).projected, 500); +} + +#[test] +fn distribute_epoch_emits_event_with_epoch_and_total_distributed() { + let s = setup(); + let a = Address::generate(&s.env); + let b = Address::generate(&s.env); + s.client.stake(&s.key_id, &a, &300, &1); + s.client.stake(&s.key_id, &b, &100, &1); + s.client.fund_epoch(&0, &600); + s.client.fund_epoch(&0, &400); + + assert_eq!( + s.client.try_distribute_epoch(&0), + Err(Ok(KeyStakingError::EpochNotComplete)) + ); + assert_eq!( + get_events::(&s.env, EPOCH_DISTRIBUTED_EVENT_NAME).len(), + 0 + ); + + advance_to_epoch(&s.env, 1); + assert_eq!(s.client.distribute_epoch(&0), 1_000); + let events = get_events::(&s.env, EPOCH_DISTRIBUTED_EVENT_NAME); + assert_eq!(events.len(), 1); + assert_eq!( + events.get(0).unwrap(), + EpochRewardDistributed { + epoch_id: 0, + total_distributed: 1_000 + } + ); + + assert_eq!( + s.client.try_distribute_epoch(&0), + Err(Ok(KeyStakingError::EpochAlreadyDistributed)) + ); +} + +#[test] +fn distribute_epoch_without_stakers_distributes_nothing() { + let s = setup(); + s.client.fund_epoch(&0, &500); + advance_to_epoch(&s.env, 1); + assert_eq!(s.client.distribute_epoch(&0), 0); + let events = get_events::(&s.env, EPOCH_DISTRIBUTED_EVENT_NAME); + assert_eq!(events.get(0).unwrap().total_distributed, 0); +} + +#[test] +fn stake_validates_amount_and_epoch_count() { + let s = setup(); + let staker = Address::generate(&s.env); + assert_eq!( + s.client.try_stake(&s.key_id, &staker, &0, &1), + Err(Ok(KeyStakingError::NotPositiveAmount)) + ); + assert_eq!( + s.client.try_stake(&s.key_id, &staker, &10, &0), + Err(Ok(KeyStakingError::InvalidEpochs)) + ); + assert_eq!( + s.client + .try_stake(&s.key_id, &staker, &10, &(MAX_LOCK_EPOCHS + 1)), + Err(Ok(KeyStakingError::InvalidEpochs)) + ); + assert_eq!(s.client.stake(&s.key_id, &staker, &10, &MAX_LOCK_EPOCHS), 1); +} + +#[test] +fn unstake_twice_and_unknown_stake_are_rejected() { + let s = setup(); + let staker = Address::generate(&s.env); + let id = s.client.stake(&s.key_id, &staker, &10, &1); + advance_to_epoch(&s.env, 1); + s.client.unstake(&id); + assert_eq!( + s.client.try_unstake(&id), + Err(Ok(KeyStakingError::StakeAlreadyClosed)) + ); + assert_eq!( + s.client.try_unstake(&99), + Err(Ok(KeyStakingError::StakeNotFound)) + ); + assert_eq!( + s.client.try_get_stake_rewards(&99), + Err(Ok(KeyStakingError::StakeNotFound)) + ); +} + +#[test] +fn initialize_is_one_time_and_contract_requires_it() { + let s = setup(); + assert_eq!( + s.client.try_initialize(&s.treasury, &EPOCH), + Err(Ok(KeyStakingError::AlreadyInitialized)) + ); + + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(KeyStakingContract, ()); + let client = KeyStakingContractClient::new(&env, &id); + assert_eq!( + client.try_initialize(&Address::generate(&env), &0), + Err(Ok(KeyStakingError::InvalidEpochDuration)) + ); + let staker = Address::generate(&env); + assert_eq!( + client.try_stake(&Address::generate(&env), &staker, &1, &1), + Err(Ok(KeyStakingError::NotInitialized)) + ); +}