Skip to content
Open
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
42 changes: 42 additions & 0 deletions .github/workflows/contract-ci.yml
Original file line number Diff line number Diff line change
@@ -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
176 changes: 175 additions & 1 deletion contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#![no_std]
use soroban_sdk::{
contract, contractimpl, contracttype, symbol_short, token, Address, Env, IntoVal, Symbol,
contract, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, IntoVal,
Symbol,
};

// ============================================================================
Expand Down Expand Up @@ -273,5 +274,178 @@ impl EscrowContract {
}
}

// ============================================================================
// SkillSync Escrow Contract — issues #521 #522 #523 #525
// ============================================================================

pub type Bytes32 = BytesN<32>;

/// Session status enum (#525)
#[contracttype]
#[derive(Clone, PartialEq, Debug)]
pub enum Status {
Locked,
Completed,
Approved,
Refunded,
Disputed,
Resolved,
}

/// Session struct (#525)
#[contracttype]
#[derive(Clone)]
pub struct SessionData {
pub buyer: Address,
pub seller: Address,
pub amount: i128,
pub status: Status,
pub created_at: u64,
pub completed_at: u64,
pub dispute_resolved_at: u64,
}

#[contracttype]
pub enum SkillSyncKey {
Session(Bytes32),
Admin,
DisputeWindow,
}

const DEFAULT_DISPUTE_WINDOW: u32 = 1000;

#[contract]
pub struct SkillSyncEscrow;

impl SkillSyncEscrow {
/// Helper: load session, panic if not found (#525)
fn get_session_internal(env: &Env, id: &Bytes32) -> SessionData {
env.storage()
.persistent()
.get(&SkillSyncKey::Session(id.clone()))
.expect("session not found")
}

/// Helper: persist session (#525)
fn save_session_internal(env: &Env, id: &Bytes32, session: &SessionData) {
env.storage()
.persistent()
.set(&SkillSyncKey::Session(id.clone()), session);
}
}

#[contractimpl]
impl SkillSyncEscrow {
/// Initialize with admin address
pub fn initialize(env: Env, admin: Address) {
if env.storage().persistent().has(&SkillSyncKey::Admin) {
panic!("already initialized");
}
env.storage().persistent().set(&SkillSyncKey::Admin, &admin);
}

// ── #525: session storage helpers (public view) ──────────────────────────

pub fn get_session(env: Env, id: Bytes32) -> SessionData {
Self::get_session_internal(&env, &id)
}

pub fn save_session(env: Env, id: Bytes32, session: SessionData) {
let admin: Address = env
.storage()
.persistent()
.get(&SkillSyncKey::Admin)
.expect("not initialized");
admin.require_auth();
Self::save_session_internal(&env, &id, &session);
}

// ── #523: lock_funds ─────────────────────────────────────────────────────

/// Lock funds into a new escrow session.
/// Caller is the buyer; transfers `amount` of `token_id` to the contract.
/// Emits FundsLocked event.
pub fn lock_funds(
env: Env,
session_id: Bytes32,
buyer: Address,
seller: Address,
amount: i128,
token_id: Address,
) {
buyer.require_auth();
assert!(amount > 0, "amount must be positive");
assert!(
!env.storage()
.persistent()
.has(&SkillSyncKey::Session(session_id.clone())),
"session already exists"
);

token::Client::new(&env, &token_id).transfer(
&buyer,
&env.current_contract_address(),
&amount,
);

let session = SessionData {
buyer,
seller,
amount,
status: Status::Locked,
created_at: env.ledger().sequence() as u64,
completed_at: 0,
dispute_resolved_at: 0,
};
Self::save_session_internal(&env, &session_id, &session);

env.events()
.publish((Symbol::new(&env, "FundsLocked"), session_id), amount);
}

// ── #521: dispute window ─────────────────────────────────────────────────

/// Set dispute window in ledgers (admin only). Emits DisputeWindowUpdated.
pub fn set_dispute_window(env: Env, window_ledgers: u32) {
let admin: Address = env
.storage()
.persistent()
.get(&SkillSyncKey::Admin)
.expect("not initialized");
admin.require_auth();
env.storage()
.persistent()
.set(&SkillSyncKey::DisputeWindow, &window_ledgers);
env.events().publish(
(Symbol::new(&env, "DisputeWindowUpdated"),),
window_ledgers,
);
}

/// Get dispute window in ledgers (default 1000).
pub fn get_dispute_window(env: Env) -> u32 {
env.storage()
.persistent()
.get(&SkillSyncKey::DisputeWindow)
.unwrap_or(DEFAULT_DISPUTE_WINDOW)
}

// ── #522: upgrade ────────────────────────────────────────────────────────

/// Upgrade contract WASM (admin only). Emits ContractUpgraded.
pub fn upgrade(env: Env, new_wasm_hash: Bytes32) {
let admin: Address = env
.storage()
.persistent()
.get(&SkillSyncKey::Admin)
.expect("not initialized");
admin.require_auth();
env.deployer()
.update_current_contract_wasm(new_wasm_hash.clone());
env.events()
.publish((Symbol::new(&env, "ContractUpgraded"),), new_wasm_hash);
}
}

#[cfg(test)]
mod test;
132 changes: 132 additions & 0 deletions contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,135 @@ mod test_multi_session {
assert_eq!(last_event.2, new_treasury.into_val(&env));
}
}

// ============================================================================
// SkillSync Escrow Contract Tests — issues #521 #522 #523 #525
// ============================================================================

mod test_skillsync_escrow {
use super::super::{SkillSyncEscrow, SkillSyncEscrowClient, Status};
use soroban_sdk::{
testutils::Address as _,
token::{Client as TokenClient, StellarAssetClient},
Address, BytesN, Env, IntoVal, Symbol,
};

fn make_id(env: &Env, n: u8) -> BytesN<32> {
BytesN::from_array(env, &[n; 32])
}

fn setup() -> (Env, Address, Address, Address, Address, Address) {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let buyer = Address::generate(&env);
let seller = Address::generate(&env);
let token_id = env
.register_stellar_asset_contract_v2(admin.clone())
.address();
StellarAssetClient::new(&env, &token_id).mint(&buyer, &1000);
let cid = env.register(SkillSyncEscrow, ());
(env, admin, buyer, seller, token_id, cid)
}

// ── #525: session struct & helpers ───────────────────────────────────────

#[test]
fn test_lock_funds_stores_session_with_locked_status() {
let (env, admin, buyer, seller, token_id, cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
let id = make_id(&env, 1);
client.lock_funds(&id, &buyer, &seller, &500, &token_id);
let s = client.get_session(&id);
assert!(matches!(s.status, Status::Locked));
assert_eq!(s.buyer, buyer);
assert_eq!(s.seller, seller);
assert_eq!(s.amount, 500);
}

// ── #523: lock_funds ─────────────────────────────────────────────────────

#[test]
fn test_lock_funds_transfers_tokens_to_contract() {
let (env, admin, buyer, seller, token_id, cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
let id = make_id(&env, 2);
client.lock_funds(&id, &buyer, &seller, &300, &token_id);
assert_eq!(TokenClient::new(&env, &token_id).balance(&cid), 300);
assert_eq!(TokenClient::new(&env, &token_id).balance(&buyer), 700);
}

#[test]
fn test_lock_funds_emits_funds_locked_event() {
let (env, admin, buyer, seller, token_id, cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
let id = make_id(&env, 3);
client.lock_funds(&id, &buyer, &seller, &100, &token_id);
let events = env.events().all();
let last = events.last().unwrap();
assert_eq!(last.0, cid);
assert_eq!(
last.1,
(Symbol::new(&env, "FundsLocked"), id.clone()).into_val(&env)
);
assert_eq!(last.2, 100_i128.into_val(&env));
}

#[test]
#[should_panic(expected = "amount must be positive")]
fn test_lock_funds_zero_amount_reverts() {
let (env, admin, buyer, seller, token_id, cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
client.lock_funds(&make_id(&env, 4), &buyer, &seller, &0, &token_id);
}

#[test]
#[should_panic(expected = "session already exists")]
fn test_lock_funds_duplicate_reverts() {
let (env, admin, buyer, seller, token_id, cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
let id = make_id(&env, 5);
client.lock_funds(&id, &buyer, &seller, &100, &token_id);
client.lock_funds(&id, &buyer, &seller, &100, &token_id);
}

// ── #521: dispute window ─────────────────────────────────────────────────

#[test]
fn test_get_dispute_window_default_is_1000() {
let (env, admin, .., cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
assert_eq!(client.get_dispute_window(), 1000);
}

#[test]
fn test_set_dispute_window_updates_value() {
let (env, admin, .., cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
client.set_dispute_window(&2000);
assert_eq!(client.get_dispute_window(), 2000);
}

#[test]
fn test_set_dispute_window_emits_event() {
let (env, admin, .., cid) = setup();
let client = SkillSyncEscrowClient::new(&env, &cid);
client.initialize(&admin);
client.set_dispute_window(&500);
let events = env.events().all();
let last = events.last().unwrap();
assert_eq!(last.0, cid);
assert_eq!(
last.1,
(Symbol::new(&env, "DisputeWindowUpdated"),).into_val(&env)
);
assert_eq!(last.2, 500_u32.into_val(&env));
}
}