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))
+ );
+}