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
1 change: 1 addition & 0 deletions contracts/escrow/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod oracle_validation;
mod pagination;
mod player_balance_history;
mod player_freeze;
mod player_freeze_permissions;
mod player_many_matches;
mod referral;
mod security;
Expand Down
208 changes: 208 additions & 0 deletions contracts/escrow/src/tests/player_freeze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,211 @@ fn test_create_match_tournament_rejects_frozen_player2() {
);
assert_eq!(result, Err(Ok(Error::ContractPaused)));
}

// ── create_match frozen-player guard ─────────────────────────────────────────

#[test]
fn test_create_match_rejects_frozen_player1() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

client.admin_freeze_player(&player1, &reason(&env, "cheating"));

let result = client.try_create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);
assert_eq!(
result,
Err(Ok(Error::ContractPaused)),
"frozen player1 must not be able to create a match"
);
}

#[test]
fn test_create_match_rejects_frozen_player2() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

client.admin_freeze_player(&player2, &reason(&env, "cheating"));

let result = client.try_create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);
assert_eq!(
result,
Err(Ok(Error::ContractPaused)),
"a frozen player must not be named as opponent"
);
}

// ── deposit frozen-player guard ──────────────────────────────────────────────

#[test]
fn test_deposit_rejects_frozen_player() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

let match_id = client.create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);

client.admin_freeze_player(&player1, &reason(&env, "cheating"));

let result = client.try_deposit(&match_id, &player1);
assert_eq!(
result,
Err(Ok(Error::ContractPaused)),
"frozen player must not be able to deposit"
);
}

// ── cancel_match allowed for frozen player ───────────────────────────────────

#[test]
fn test_cancel_match_allowed_for_frozen_player() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

let match_id = client.create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);

client.admin_freeze_player(&player1, &reason(&env, "cheating"));

client.cancel_match(&match_id, &player1);

let m = client.get_match(&match_id);
assert_eq!(
m.state,
MatchState::Cancelled,
"frozen player must still be able to cancel their own pending match"
);
}

// ── claim_vested_payout allowed for frozen player ────────────────────────────

#[test]
fn test_claim_vested_payout_allowed_for_frozen_player() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

let match_id = client.create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);

client.deposit(&match_id, &player1);
client.deposit(&match_id, &player2);

client.admin_freeze_player(&player1, &reason(&env, "cheating"));

// Freezing must not block a player from claiming an already-vested payout.
client.claim_vested_payout(&match_id, &player1);
}

// ── admin_unfreeze_player restores all permissions ───────────────────────────

#[test]
fn test_admin_unfreeze_player_restores_create_match() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

client.admin_freeze_player(&player1, &reason(&env, "temp"));
client.admin_unfreeze_player(&player1);

let result = client.try_create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);
assert!(
result.is_ok(),
"unfrozen player must be able to create a match again"
);
}

#[test]
fn test_admin_unfreeze_player_restores_deposit() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

let match_id = client.create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);

client.admin_freeze_player(&player1, &reason(&env, "temp"));
client.admin_unfreeze_player(&player1);

let result = client.try_deposit(&match_id, &player1);
assert!(
result.is_ok(),
"unfrozen player must be able to deposit again"
);
}

#[test]
fn test_admin_unfreeze_player_restores_cancel_and_claim() {
let (env, contract_id, _oracle, player1, player2, token, _admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

// Cancel path: freeze then unfreeze, then cancel must succeed.
let cancel_id = client.create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);
client.admin_freeze_player(&player1, &reason(&env, "temp"));
client.admin_unfreeze_player(&player1);
client.cancel_match(&cancel_id, &player1);
assert_eq!(client.get_match(&cancel_id).state, MatchState::Cancelled);

// Claim path: freeze then unfreeze, then claim must succeed.
let claim_id = client.create_match(
&player1,
&player2,
&100,
&token,
&String::from_str(&env, "match"),
&Platform::Lichess,
);
client.deposit(&claim_id, &player1);
client.deposit(&claim_id, &player2);
client.admin_freeze_player(&player1, &reason(&env, "temp"));
client.admin_unfreeze_player(&player1);
client.claim_vested_payout(&claim_id, &player1);
}
60 changes: 60 additions & 0 deletions contracts/escrow/src/tests/protocol_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,63 @@ fn test_set_protocol_config_rejected_for_non_admin() {
"non-admin must not be able to set ProtocolConfig"
);
}

/// `set_protocol_config` must reject a `cancellation_fee_basis_points` above
/// 10_000 (more than 100% of the stake) with `Error::InvalidAmount`, and the
/// previously stored config must remain unchanged.
#[test]
fn test_set_protocol_config_rejects_cancellation_fee_above_max() {
let (env, contract_id, _oracle, _p1, _p2, _token, admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

let cfg_before = client.get_protocol_config();

let mut cfg = custom_config(&env, &admin);
cfg.cancellation_fee_basis_points = 10_001;

let result = client.try_set_protocol_config(&cfg);
assert_eq!(
result,
Err(Ok(Error::InvalidAmount)),
"cancellation_fee_basis_points > 10_000 must be rejected"
);

let cfg_after = client.get_protocol_config();
assert_eq!(
cfg_before, cfg_after,
"rejected config must not be stored"
);
}

/// `set_protocol_config` must accept `cancellation_fee_basis_points` exactly
/// at the 10_000 boundary.
#[test]
fn test_set_protocol_config_accepts_cancellation_fee_at_max() {
let (env, contract_id, _oracle, _p1, _p2, _token, admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

let mut cfg = custom_config(&env, &admin);
cfg.cancellation_fee_basis_points = 10_000;

client.set_protocol_config(&cfg);

let actual = client.get_protocol_config();
assert_eq!(actual, cfg);
}

/// `set_protocol_config` must accept a valid `cancellation_fee_basis_points`
/// below the bound and `get_protocol_config` must reflect it.
#[test]
fn test_set_protocol_config_accepts_valid_cancellation_fee() {
let (env, contract_id, _oracle, _p1, _p2, _token, admin) = setup();
let client = EscrowContractClient::new(&env, &contract_id);

let mut cfg = custom_config(&env, &admin);
cfg.cancellation_fee_basis_points = 250;

client.set_protocol_config(&cfg);

let actual = client.get_protocol_config();
assert_eq!(actual.cancellation_fee_basis_points, 250);
assert_eq!(actual, cfg);
}
55 changes: 55 additions & 0 deletions contracts/oracle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1241,6 +1241,23 @@ impl OracleContract {
page
}

/// Return the list of all currently approved oracle addresses.
///
/// This is the authoritative on-chain view of the approved-oracle set,
/// maintained alongside the per-oracle approval records by
/// `add_approved_oracle` / `remove_approved_oracle`. Monitoring tools can
/// read it directly instead of reconstructing the set from
/// `admin/ora_add` and `admin/ora_rm` events.
///
/// Returns an empty `Vec` when no oracle has been approved.
pub fn get_approved_oracles(env: Env) -> Vec<Address> {
extend_instance_ttl(&env);
env.storage()
.instance()
.get(&DataKey::ApprovedOracles)
.unwrap_or(Vec::new(&env))
}

/// Returns performance metrics and SLA status for a registered oracle.
pub fn get_oracle_metrics(env: Env, oracle_address: Address) -> OracleMetrics {
env.storage()
Expand Down Expand Up @@ -1575,6 +1592,44 @@ impl OracleContract {
.set(&DataKey::OracleEligibilityVersion, &version);
}

/// Append `oracle` to the approved-oracle list if not already present.
/// Keeps `DataKey::ApprovedOracles` in sync with the per-oracle approval
/// records maintained by `add_approved_oracle`.
fn add_to_approved_list(env: &Env, oracle: &Address) {
let mut list: Vec<Address> = env
.storage()
.instance()
.get(&DataKey::ApprovedOracles)
.unwrap_or(Vec::new(env));
if !list.contains(oracle) {
list.push_back(oracle.clone());
env.storage()
.instance()
.set(&DataKey::ApprovedOracles, &list);
}
}

/// Remove `oracle` from the approved-oracle list, if present. Keeps
/// `DataKey::ApprovedOracles` in sync with the per-oracle approval
/// records maintained by `remove_approved_oracle`.
fn remove_from_approved_list(env: &Env, oracle: &Address) {
let list: Vec<Address> = env
.storage()
.instance()
.get(&DataKey::ApprovedOracles)
.unwrap_or(Vec::new(env));
let mut updated = Vec::new(env);
for i in 0..list.len() {
let addr = list.get(i).unwrap();
if addr != *oracle {
updated.push_back(addr);
}
}
env.storage()
.instance()
.set(&DataKey::ApprovedOracles, &updated);
}

/// Slash `bps` basis points of `oracle`'s remaining stake, transferring
/// the slashed amount to the admin (treasury). No-op if the oracle is not
/// registered or has no remaining stake. Returns the amount slashed.
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/match/CreateMatchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,24 @@ export function CreateMatchForm({ onSubmit }: CreateMatchFormProps) {

function handleSubmit(ev: React.FormEvent) {
ev.preventDefault();
if (validate()) onSubmit(form);
if (validate()) {
onSubmit({
...form,
player2: form.player2.trim(),
stakeAmount: form.stakeAmount.trim(),
token: form.token.trim(),
gameId: form.gameId.trim(),
});
}
}

const set = (field: keyof CreateMatchData) =>
(ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [field]: ev.target.value }));

const setGameId = (ev: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, gameId: ev.target.value.trim() }));

return (
<form onSubmit={handleSubmit} noValidate>
<div>
Expand Down
Loading