Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions contracts/dex/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ pub mod dex {
competition_scores: Mapping<(u64, AccountId), u128>,
competition_participants: Mapping<u64, Vec<AccountId>>,
competition_claimed: Mapping<(u64, AccountId), bool>,
/// Native value actually escrowed for each trading competition's reward
/// pool (competition_id -> amount). Claims may only draw from this
/// balance, never from nothing (Issue #1022).
competition_reward_escrow: Mapping<u64, u128>,
admin_timelock_delay: u64,
pending_admin_actions: Mapping<u64, PendingAdminAction>,
pending_admin_action_counter: u64,
Expand Down Expand Up @@ -249,6 +253,7 @@ pub mod dex {
competition_scores: Mapping::default(),
competition_participants: Mapping::default(),
competition_claimed: Mapping::default(),
competition_reward_escrow: Mapping::default(),
admin_timelock_delay: 0,
pending_admin_actions: Mapping::default(),
pending_admin_action_counter: 0,
Expand Down Expand Up @@ -997,8 +1002,17 @@ pub mod dex {
Ok(())
}

/// Create a new trading competition. Admin only. Sets start/end timestamps, reward pool, and participant cap.
#[ink(message)]
/// Creates a new trading competition. Only the admin may call.
///
/// # Reward escrow (Issue #1022)
///
/// The competition reward must be **funded up front**: the caller must
/// attach native value at least equal to `reward_amount`. The attached
/// value is escrowed in `competition_reward_escrow` and the governance
/// token supply is minted once, by exactly the escrowed amount, so
/// `claim_competition_reward` can never credit more than the value
/// actually held for the competition.
#[ink(message, payable)]
pub fn create_trading_competition(
&mut self,
pair_id: Option<u64>,
Expand All @@ -1016,6 +1030,9 @@ pub mod dex {
if title.is_empty() || start_block >= end_block || reward_amount == 0 {
return Err(Error::InvalidRequest);
}
if self.env().transferred_value() < reward_amount {
return Err(Error::InvalidRequest);
}
self.trade_competition_counter = self.trade_competition_counter.saturating_add(1);
let competition_id = self.trade_competition_counter;
let competition = TradingCompetition {
Expand All @@ -1034,6 +1051,15 @@ pub mod dex {
.insert(competition_id, &competition);
self.competition_participants
.insert(competition_id, &Vec::<AccountId>::new());

// Escrow the full announced reward and mint the governance supply
// once, backed by the attached value. Claims later redistribute
// this escrow without touching `total_supply` again.
self.competition_reward_escrow
.insert(competition_id, &reward_amount);
self.governance_config.total_supply =
self.governance_config.total_supply.saturating_add(reward_amount);

self.env().emit_event(TradingCompetitionCreated {
competition_id,
pair_id,
Expand All @@ -1057,7 +1083,10 @@ pub mod dex {
.unwrap_or(0)
}

/// Claim reward tokens for a completed competition. Requires the competition to be finished.
/// Claim reward tokens for a completed competition. Requires the
/// competition to be finished and its reward pool to have been funded
/// (escrowed) at creation. Claims are paid pro rata from the escrow;
/// an unfunded competition yields `RewardUnavailable` (Issue #1022).
#[ink(message)]
pub fn claim_competition_reward(&mut self, competition_id: u64) -> Result<u128, Error> {
let competition = self
Expand Down Expand Up @@ -1110,13 +1139,27 @@ pub mod dex {
return Err(Error::InvalidRequest);
}

// The reward pool must actually hold value: claims may only draw
// from the escrow funded at creation (Issue #1022). The pro-rata
// shares of all participants never exceed the escrowed amount, so
// the cap is purely defensive against partially-funded legacy
// competitions.
let escrowed = self.competition_reward_escrow.get(competition_id).unwrap_or(0);
if escrowed == 0 {
return Err(Error::RewardUnavailable);
}
let reward = reward.min(escrowed);

self.competition_reward_escrow
.insert(competition_id, &escrowed.saturating_sub(reward));
self.competition_claimed
.insert((competition_id, caller), &true);
let balance = self.governance_balances.get(caller).unwrap_or(0);
self.governance_balances
.insert(caller, &balance.saturating_add(reward));
self.governance_config.total_supply =
self.governance_config.total_supply.saturating_add(reward);
// `total_supply` is not increased here: it was minted once when
// the competition was funded, so supply grows only by the escrowed
// amount across the whole competition lifecycle.
self.env().emit_event(CompetitionRewardClaimed {
competition_id,
trader: caller,
Expand Down Expand Up @@ -1551,6 +1594,13 @@ pub mod dex {
self.governance_balances.get(trader).unwrap_or(0)
}

/// Return the amount of value still escrowed for a competition's reward
/// pool (0 if it was never funded or has been fully claimed).
#[ink(message)]
pub fn get_competition_reward_escrow(&self, competition_id: u64) -> u128 {
self.competition_reward_escrow.get(competition_id).unwrap_or(0)
}

/// Return the configurable settings for a competition.
#[ink(message)]
pub fn get_competition_settings(&self, competition_id: u64) -> Option<(u32, u128)> {
Expand Down
44 changes: 43 additions & 1 deletion contracts/dex/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,8 +776,12 @@ mod tests {
let accounts = test::default_accounts::<DefaultEnvironment>();

test::set_block_number::<DefaultEnvironment>(100);
let supply_before = dex.governance_config.total_supply;

// Admin creates a competition on pair 1 running blocks 100..110.
// Admin creates a competition on pair 1 running blocks 100..110. The
// reward must be funded up front (Issue #1022): the attached value is
// escrowed and the governance supply is minted once, by that amount.
test::set_value_transferred::<DefaultEnvironment>(10_000);
let competition_id = dex
.create_trading_competition(
Some(pair_id),
Expand All @@ -791,6 +795,13 @@ mod tests {
)
.expect("competition creation should work");

assert_eq!(dex.get_competition_reward_escrow(competition_id), 10_000);
assert_eq!(
dex.governance_config.total_supply,
supply_before + 10_000,
"supply is minted once, by exactly the escrowed amount"
);

let competition = dex.get_trading_competition(competition_id).unwrap();
assert!(competition.active);
assert_eq!(
Expand Down Expand Up @@ -832,12 +843,26 @@ mod tests {
assert!(expected > 0);
test::set_caller::<DefaultEnvironment>(accounts.bob);
let balance_before = dex.get_governance_balance(accounts.bob);
// Swap emissions mint governance tokens, so capture the supply after
// the trades; the claim itself must not mint anything further.
let supply_before_claim = dex.governance_config.total_supply;
let reward = dex.claim_competition_reward(competition_id).unwrap();
assert_eq!(reward, expected);
assert_eq!(
dex.get_governance_balance(accounts.bob),
balance_before + reward
);
// Claiming pays from the escrow: supply is unchanged at claim time and
// the escrow shrinks by exactly the claimed amount (Issue #1022).
assert_eq!(
dex.governance_config.total_supply,
supply_before_claim,
"total supply must not increase at claim time"
);
assert_eq!(
dex.get_competition_reward_escrow(competition_id),
10_000 - reward
);

// Double claims are rejected...
assert_eq!(
Expand Down Expand Up @@ -918,6 +943,23 @@ mod tests {
Err(Error::InvalidRequest)
);

// The reward must be funded up front: creating a competition without
// attaching the reward value is rejected (Issue #1022).
assert_eq!(
dex.create_trading_competition(
None,
String::from("Unfunded Race"),
5_000,
100,
110,
1,
10,
String::from("PCG")
),
Err(Error::InvalidRequest)
);

test::set_value_transferred::<DefaultEnvironment>(5_000);
let competition_id = dex
.create_trading_competition(
None,
Expand Down
Loading