diff --git a/.github/workflows/contract-ci.yml b/.github/workflows/contract-ci.yml new file mode 100644 index 000000000..413525957 --- /dev/null +++ b/.github/workflows/contract-ci.yml @@ -0,0 +1,42 @@ +name: Contract CI + +on: + push: + paths: + - "contract/**" + - ".github/workflows/contract-ci.yml" + pull_request: + paths: + - "contract/**" + - ".github/workflows/contract-ci.yml" + +jobs: + build-and-test: + name: Build & Test Soroban Contract + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contract/target + key: ${{ runner.os }}-cargo-${{ hashFiles('contract/Cargo.toml') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build contract (WASM) + working-directory: contract + run: cargo build --target wasm32-unknown-unknown --release + + - name: Run tests + working-directory: contract + run: cargo test diff --git a/contract/Cargo.toml b/contract/Cargo.toml index 48d7f6b88..d5fe83f51 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -1,6 +1,23 @@ [package] -name = "contract" +name = "skillsync_contract" version = "0.1.0" -edition = "2024" +edition = "2021" + +[lib] +crate-type = ["cdylib"] [dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils", "alloc"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contract/src/lib.rs b/contract/src/lib.rs new file mode 100644 index 000000000..820ad0f7d --- /dev/null +++ b/contract/src/lib.rs @@ -0,0 +1,184 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env}; + +// ── Storage Keys ────────────────────────────────────────────────────────────── + +#[contracttype] +pub enum DataKey { + Admin, + Treasury, + PlatformFeeBps, + DisputeWindow, + Initialized, +} + +// ── Contract ────────────────────────────────────────────────────────────────── + +#[contract] +pub struct SkillSyncContract; + +#[contractimpl] +impl SkillSyncContract { + // ── Issue #748: initialize ──────────────────────────────────────────────── + + /// Sets up initial contract state. Can only be called once. + pub fn initialize(env: Env, admin: Address, treasury: Address) { + if env.storage().persistent().has(&DataKey::Initialized) { + panic!("already initialized"); + } + admin.require_auth(); + + env.storage().persistent().set(&DataKey::Admin, &admin); + env.storage().persistent().set(&DataKey::Treasury, &treasury); + env.storage().persistent().set(&DataKey::PlatformFeeBps, &0u32); + env.storage().persistent().set(&DataKey::DisputeWindow, &1000u32); + env.storage().persistent().set(&DataKey::Initialized, &true); + + env.events().publish( + (symbol_short!("init"),), + (admin, treasury), + ); + } + + // ── Issue #749: platform fee ────────────────────────────────────────────── + + /// Sets the platform fee in basis points (0–1000). Admin only. + pub fn set_platform_fee(env: Env, new_fee_bps: u32) { + Self::require_admin(&env); + assert!(new_fee_bps <= 1000, "fee exceeds 10%"); + env.storage().persistent().set(&DataKey::PlatformFeeBps, &new_fee_bps); + env.events().publish( + (symbol_short!("fee_upd"),), + new_fee_bps, + ); + } + + /// Returns the current platform fee in basis points. + pub fn get_platform_fee(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::PlatformFeeBps) + .unwrap_or(0) + } + + // ── Issue #750: treasury wallet ─────────────────────────────────────────── + + /// Updates the treasury wallet address. Admin only. + pub fn set_treasury(env: Env, new_treasury: Address) { + Self::require_admin(&env); + env.storage().persistent().set(&DataKey::Treasury, &new_treasury); + env.events().publish( + (symbol_short!("treas"),), + new_treasury, + ); + } + + /// Returns the current treasury wallet address. + pub fn get_treasury(env: Env) -> Address { + env.storage() + .persistent() + .get(&DataKey::Treasury) + .expect("treasury not set") + } + + // ── Issue #751: dispute window ──────────────────────────────────────────── + + /// Sets the dispute resolution window in ledgers. Admin only. + pub fn set_dispute_window(env: Env, window_ledgers: u32) { + Self::require_admin(&env); + env.storage().persistent().set(&DataKey::DisputeWindow, &window_ledgers); + env.events().publish( + (symbol_short!("disp_win"),), + window_ledgers, + ); + } + + /// Returns the current dispute window in ledgers (default: 1000). + pub fn get_dispute_window(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::DisputeWindow) + .unwrap_or(1000) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn require_admin(env: &Env) { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::Env; + + fn setup() -> (Env, Address, Address, SkillSyncContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(SkillSyncContract, ()); + let client = SkillSyncContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + (env, admin, treasury, client) + } + + #[test] + fn test_initialize() { + let (_, admin, treasury, client) = setup(); + client.initialize(&admin, &treasury); + assert_eq!(client.get_treasury(), treasury); + assert_eq!(client.get_platform_fee(), 0); + assert_eq!(client.get_dispute_window(), 1000); + } + + #[test] + #[should_panic(expected = "already initialized")] + fn test_initialize_once() { + let (_, admin, treasury, client) = setup(); + client.initialize(&admin, &treasury); + client.initialize(&admin, &treasury); + } + + #[test] + fn test_platform_fee() { + let (_, admin, treasury, client) = setup(); + client.initialize(&admin, &treasury); + client.set_platform_fee(&250); + assert_eq!(client.get_platform_fee(), 250); + } + + #[test] + #[should_panic(expected = "fee exceeds 10%")] + fn test_platform_fee_max() { + let (_, admin, treasury, client) = setup(); + client.initialize(&admin, &treasury); + client.set_platform_fee(&1001); + } + + #[test] + fn test_treasury() { + let (env, admin, treasury, client) = setup(); + client.initialize(&admin, &treasury); + let new_treasury = Address::generate(&env); + client.set_treasury(&new_treasury); + assert_eq!(client.get_treasury(), new_treasury); + } + + #[test] + fn test_dispute_window() { + let (_, admin, treasury, client) = setup(); + client.initialize(&admin, &treasury); + client.set_dispute_window(&2000); + assert_eq!(client.get_dispute_window(), 2000); + } +} diff --git a/contract/src/main.rs b/contract/src/main.rs deleted file mode 100644 index e7a11a969..000000000 --- a/contract/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -}