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
46 changes: 46 additions & 0 deletions contracts/amm/src/anti_mev.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Anti-MEV sandwich protection module.
//!
//! Provides defense mechanisms against frontrunning and sandwich attacks on DEX liquidity pools:
//! - User-specified deadline validation (reverts if ledger timestamp exceeds designated deadline).
//! - Minimum output amount checks (strict slippage enforcement).
//! - Same-block bidirectional trade restriction (reverting immediate round-trip arbitrage from the same origin address).

use soroban_sdk::{panic_with_error, Address, Env};
use crate::{storage::DataKey, AmmError};

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SwapDirection {
AtoB = 1,
BtoA = 2,
}

/// Validates that transaction execution does not exceed the user's deadline.
pub fn check_deadline(env: &Env, deadline: u64) {
if deadline > 0 && env.ledger().timestamp() > deadline {
panic_with_error!(env, AmmError::DeadlineExceeded);
}
}

/// Enforces that output amount is at least the user's minimum expected output.
pub fn check_min_output(env: &Env, amount_out: i128, min_amount_out: i128) {
if amount_out < min_amount_out {
panic_with_error!(env, AmmError::SlippageExceeded);
}
}

/// Prevents the same origin address from executing bidirectional round-trip trades
/// (A -> B followed by B -> A or vice-versa) in the same block/ledger sequence.
pub fn check_and_record_trade(env: &Env, user: &Address, direction: SwapDirection) {
let current_ledger = env.ledger().sequence();
let key = DataKey::TradeRecord(user.clone());

let record: Option<(u32, u32)> = env.storage().temporary().get(&key);
if let Some((recorded_ledger, recorded_direction)) = record {
if recorded_ledger == current_ledger && recorded_direction != (direction as u32) {
panic_with_error!(env, AmmError::SameBlockBidirectionalTrade);
}
}

env.storage().temporary().set(&key, &(current_ledger, direction as u32));
env.storage().temporary().extend_ttl(&key, 100, 1000);
}
33 changes: 33 additions & 0 deletions contracts/amm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ pub enum AmmError {
SlippageExceeded = 403,
/// `404` — Contract has not been initialised.
NotInitialized = 404,
/// `405` — Deadline expired for trade execution.
DeadlineExceeded = 405,
/// `406` — Same-block bidirectional trades from the same origin address are prohibited.
SameBlockBidirectionalTrade = 406,
}

pub mod anti_mev;
/// `405` — AMM operations are paused by the circuit breaker.
ContractPaused = 405,
/// `406` — Caller is not authorized to manage the circuit breaker.
Expand Down Expand Up @@ -314,6 +321,7 @@ impl AMMContract {
if amount_in <= 0 {
panic_with_error!(&env, AmmError::InvalidAmount);
}
anti_mev::check_and_record_trade(&env, &user, anti_mev::SwapDirection::AtoB);

let token_a: Address = env.storage().instance().get(&DataKey::TokenA).unwrap();
let token_b: Address = env.storage().instance().get(&DataKey::TokenB).unwrap();
Expand Down Expand Up @@ -380,13 +388,26 @@ impl AMMContract {
amount_out
}

/// Swaps token A for token B with user-specified deadline and slippage protection.
pub fn swap_a_for_b_with_deadline(
env: Env,
user: Address,
amount_in: i128,
min_amount_out: i128,
deadline: u64,
) -> i128 {
anti_mev::check_deadline(&env, deadline);
Self::swap_a_for_b(env, user, amount_in, min_amount_out)
}

/// Swaps token B for token A with dynamic fee based on price impact.
pub fn swap_b_for_a(env: Env, user: Address, amount_in: i128, min_amount_out: i128) -> i128 {
user.require_auth();
circuit_breaker::assert_not_paused(&env);
if amount_in <= 0 {
panic_with_error!(&env, AmmError::InvalidAmount);
}
anti_mev::check_and_record_trade(&env, &user, anti_mev::SwapDirection::BtoA);

let token_a: Address = env.storage().instance().get(&DataKey::TokenA).unwrap();
let token_b: Address = env.storage().instance().get(&DataKey::TokenB).unwrap();
Expand Down Expand Up @@ -453,6 +474,18 @@ impl AMMContract {
amount_out
}

/// Swaps token B for token A with user-specified deadline and slippage protection.
pub fn swap_b_for_a_with_deadline(
env: Env,
user: Address,
amount_in: i128,
min_amount_out: i128,
deadline: u64,
) -> i128 {
anti_mev::check_deadline(&env, deadline);
Self::swap_b_for_a(env, user, amount_in, min_amount_out)
}

/// Returns current pool state for calculations and display.
pub fn get_pool_state(env: Env) -> (i128, i128, i128) {
let balance_a: i128 = env
Expand Down
2 changes: 2 additions & 0 deletions contracts/amm/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ pub enum DataKey {
LastBlockTimestamp,
SnapshotCount,
Snapshot(u32),
// ── Anti-MEV Storage Keys ─────────────────────────────────────────
TradeRecord(soroban_sdk::Address),
}
73 changes: 73 additions & 0 deletions contracts/amm/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,79 @@ fn test_twap_resistant_to_single_block_flash_manipulation() {
}

#[test]
fn test_swap_with_deadline_succeeds_before_deadline() {
let env = Env::default();
let (admin, client, token_a, _, _) = setup(&env);
client.add_liquidity(&admin, &100_000, &100_000, &0);

let user = Address::generate(&env);
let token_a_client = token::StellarAssetClient::new(&env, &token_a);
token_a_client.mint(&user, &1000);

env.ledger().with_mut(|li| li.timestamp = 100);
let out = client.swap_a_for_b_with_deadline(&user, &1000, &0, &200);
assert!(out > 0);
}

#[test]
#[should_panic(expected = "Error(Contract, #405)")]
fn test_swap_with_deadline_reverts_if_expired() {
let env = Env::default();
let (admin, client, token_a, _, _) = setup(&env);
client.add_liquidity(&admin, &100_000, &100_000, &0);

let user = Address::generate(&env);
let token_a_client = token::StellarAssetClient::new(&env, &token_a);
token_a_client.mint(&user, &1000);

env.ledger().with_mut(|li| li.timestamp = 201);
// User deadline is 200, current timestamp is 201 -> should panic with 405
client.swap_a_for_b_with_deadline(&user, &1000, &0, &200);
}

#[test]
#[should_panic(expected = "Error(Contract, #406)")]
fn test_same_block_bidirectional_trade_rejected() {
let env = Env::default();
let (admin, client, token_a, token_b, _) = setup(&env);
client.add_liquidity(&admin, &100_000, &100_000, &0);

let attacker = Address::generate(&env);
let token_a_client = token::StellarAssetClient::new(&env, &token_a);
let token_b_client = token::StellarAssetClient::new(&env, &token_b);
token_a_client.mint(&attacker, &5000);
token_b_client.mint(&attacker, &5000);

// Swap A -> B
client.swap_a_for_b(&attacker, &1000, &0);

// In the exact same block/sequence, attempt reverse swap B -> A (sandwich backrun) -> should panic with 406
client.swap_b_for_a(&attacker, &500, &0);
}

#[test]
fn test_bidirectional_trade_allowed_in_different_blocks() {
let env = Env::default();
let (admin, client, token_a, token_b, _) = setup(&env);
client.add_liquidity(&admin, &100_000, &100_000, &0);

let user = Address::generate(&env);
let token_a_client = token::StellarAssetClient::new(&env, &token_a);
let token_b_client = token::StellarAssetClient::new(&env, &token_b);
token_a_client.mint(&user, &5000);
token_b_client.mint(&user, &5000);

// Block 1: Swap A -> B
env.ledger().with_mut(|li| li.sequence_number = 10);
let out1 = client.swap_a_for_b(&user, &1000, &0);
assert!(out1 > 0);

// Block 2: Advance block sequence -> reverse swap B -> A is allowed
env.ledger().with_mut(|li| li.sequence_number = 11);
let out2 = client.swap_b_for_a(&user, &500, &0);
assert!(out2 > 0);
}

fn test_guardian_pause_blocks_amm_operations() {
let env = Env::default();
let (admin, client, token_a, _, _) = setup(&env);
Expand Down
3 changes: 2 additions & 1 deletion contracts/amm/tests/fuzz_amm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use amm::{AMMContract, AMMContractClient};
use proptest::prelude::*;
use soroban_sdk::{
testutils::Address as _,
testutils::{Address as _, Ledger as _},
token, Address, Env,
};

Expand Down Expand Up @@ -84,6 +84,7 @@ fn test_fuzz_amm_k_invariant_one_million_continuous_actions() {

// 1. In-contract trades to verify live state consistency and event emission
for _ in 0..100 {
env.ledger().with_mut(|li| li.sequence_number += 1);
let trade_a_to_b = (next_rand() % 2) == 0;
let amount_in = ((next_rand() % 50_000) + 1_000) as i128;

Expand Down
59 changes: 59 additions & 0 deletions contracts/content_management_system/src/drm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use crate::errors::Error;
use crate::storage::{get_content, is_instructor};
use crate::types::StorageKey;
use soroban_sdk::{contracttype, Address, BytesN, Env, symbol_short};

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyrightRecord {
pub content_id: u64,
pub author: Address,
pub copyright_hash: BytesN<32>,
pub registered_at: u64,
}

pub fn register_copyright(
env: &Env,
author: Address,
content_id: u64,
copyright_hash: BytesN<32>,
) -> Result<CopyrightRecord, Error> {
author.require_auth();

if !is_instructor(env, &author) {
return Err(Error::NotInstructor);
}

let content = get_content(env, content_id)?;
if content.instructor != author {
return Err(Error::Unauthorized);
}

let key = StorageKey::Copyright(content_id);
if env.storage().persistent().has(&key) {
return Err(Error::CopyrightAlreadyRegistered);
}

let record = CopyrightRecord {
content_id,
author: author.clone(),
copyright_hash,
registered_at: env.ledger().timestamp(),
};

env.storage().persistent().set(&key, &record);
env.events().publish(
(symbol_short!("copyright"), symbol_short!("register")),
(content_id, author, record.registered_at),
);

Ok(record)
}

pub fn get_copyright(env: &Env, content_id: u64) -> Result<CopyrightRecord, Error> {
let key = StorageKey::Copyright(content_id);
env.storage()
.persistent()
.get(&key)
.ok_or(Error::ContentNotFound)
}
7 changes: 7 additions & 0 deletions contracts/content_management_system/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@ pub enum Error {
EnrollmentNotAllowed = 15,
AlreadyEnrolled = 16,
NotEnrolled = 17,
CopyrightAlreadyRegistered = 18,
LicenseNotFound = 19,
LicenseExpired = 20,
LicenseTermsNotSet = 21,
InvalidLicenseDuration = 22,
InvalidLicensePrice = 23,
LicenseAlreadyRevoked = 24,
}
61 changes: 60 additions & 1 deletion contracts/content_management_system/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#![no_std]

pub mod drm;
mod errors;
mod events;
pub mod licensing;
mod storage;
mod types;

Expand All @@ -10,7 +12,7 @@ mod test;

use errors::Error;
use events::*;
use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec};
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec};
use storage::*;
use types::{AccessPolicy, ContentItem, ContentMetadata, ContentStatus};

Expand Down Expand Up @@ -391,4 +393,61 @@ impl ContentManagementSystem {
pub fn is_enrolled(env: Env, content_id: u64, student: Address) -> bool {
is_enrolled(&env, content_id, &student)
}

/// Register curriculum copyright hash for a content item
pub fn register_copyright(
env: Env,
author: Address,
content_id: u64,
copyright_hash: BytesN<32>,
) -> Result<drm::CopyrightRecord, Error> {
drm::register_copyright(&env, author, content_id, copyright_hash)
}

/// Get registered curriculum copyright record
pub fn get_copyright(env: Env, content_id: u64) -> Result<drm::CopyrightRecord, Error> {
drm::get_copyright(&env, content_id)
}

/// Set commercial licensing terms for a content item
pub fn set_license_terms(
env: Env,
author: Address,
content_id: u64,
payment_token: Address,
price: i128,
duration_seconds: u64,
) -> Result<licensing::LicenseTerms, Error> {
licensing::set_license_terms(&env, author, content_id, payment_token, price, duration_seconds)
}

/// Get licensing terms for a content item
pub fn get_license_terms(env: Env, content_id: u64) -> Result<licensing::LicenseTerms, Error> {
licensing::get_license_terms(&env, content_id)
}

/// Purchase a time-limited commercial license voucher, forwarding royalties to course author
pub fn purchase_license(
env: Env,
institution: Address,
content_id: u64,
signature: BytesN<64>,
) -> Result<u64, Error> {
licensing::purchase_license(&env, institution, content_id, signature)
}

/// Assert whether an institution holds an active valid license
pub fn is_license_valid(env: Env, institution: Address, content_id: u64) -> bool {
licensing::is_license_valid(&env, institution, content_id)
}

/// Get license voucher details
pub fn get_license(env: Env, license_id: u64) -> Result<licensing::LicenseVoucher, Error> {
licensing::get_license(&env, license_id)
}

/// Revoke an active license voucher (author only)
pub fn revoke_license(env: Env, author: Address, license_id: u64) -> Result<(), Error> {
licensing::revoke_license(&env, author, license_id)
}
}
Loading
Loading