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
3 changes: 3 additions & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
[workspace]
members = [
"contracts/bounty",
"contracts/core",
"contracts/escrow",
"contracts/freelancer",
"contracts/governance",
"contracts/oracle",
"contracts/identity",
"contracts/stellar_insights",
"services/api",
"services/auth",
Expand Down
37 changes: 36 additions & 1 deletion backend/contracts/bounty/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub enum BountyStatus {
Completed = 2,
Disputed = 3,
Cancelled = 4,
PendingCompletion = 5, // #160: freelancer signalled work done, awaiting creator approval
}

/// Bounty Struct
Expand Down Expand Up @@ -171,6 +172,34 @@ impl BountyContract {
true
}

/// Called by the selected freelancer to signal work is done.
/// Transitions the bounty from InProgress → PendingCompletion. (#160)
/// The creator must then call complete_bounty to approve.
pub fn submit_completion(env: Env, bounty_id: u64, freelancer: Address) -> bool {
freelancer.require_auth();

let bounty_key = (Symbol::new(&env, "bounty"), bounty_id);
let mut bounty = env
.storage()
.persistent()
.get::<(Symbol, u64), Bounty>(&bounty_key)
.expect("Bounty not found");

assert!(
bounty.status == BountyStatus::InProgress,
"Bounty not in progress"
);
assert!(
bounty.selected_freelancer == Some(freelancer.clone()),
"Only the selected freelancer can submit completion"
);

bounty.status = BountyStatus::PendingCompletion;
env.storage().persistent().set(&bounty_key, &bounty);

true
}

pub fn complete_bounty(env: Env, bounty_id: u64) -> bool {
let bounty_key = (Symbol::new(&env, "bounty"), bounty_id);
let mut bounty = env
Expand All @@ -180,7 +209,13 @@ impl BountyContract {
.expect("Bounty not found");

bounty.creator.require_auth();
assert!(bounty.status == BountyStatus::InProgress, "Bounty not in progress");
// #160: Creator can only approve completion after the freelancer has
// signalled work is done via submit_completion (PendingCompletion).
// Direct completion from InProgress is no longer allowed.
assert!(
bounty.status == BountyStatus::PendingCompletion,
"Freelancer must submit completion before creator can approve"
);

bounty.status = BountyStatus::Completed;
bounty.completed_at = Some(env.ledger().timestamp());
Expand Down
16 changes: 16 additions & 0 deletions backend/contracts/core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "stellar-core-contract"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true

[lib]
crate-type = ["cdylib"]

[dependencies]
soroban-sdk.workspace = true

[dev-dependencies]
soroban-sdk = { version = "23.5.2", features = ["testutils"] }
17 changes: 17 additions & 0 deletions backend/contracts/core/src/fee.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
pub const MAX_FEE_BPS: u32 = 10_000;

pub fn assert_valid_fee_bps(fee_bps: u32) {
assert!(
fee_bps <= MAX_FEE_BPS,
"Fee exceeds maximum of 10000 basis points"
);
}

pub fn compute_fee(amount: i128, fee_bps: u32) -> i128 {
assert_valid_fee_bps(fee_bps);
amount * (fee_bps as i128) / 10_000
}

pub fn compute_net(amount: i128, fee_bps: u32) -> i128 {
amount - compute_fee(amount, fee_bps)
}
157 changes: 157 additions & 0 deletions backend/contracts/core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#![no_std]

pub mod fee;

use fee::{assert_valid_fee_bps, compute_fee, compute_net, MAX_FEE_BPS};
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol};

const FEE_KEY: Symbol = symbol_short!("fee_bps");
const ADMIN_KEY: Symbol = symbol_short!("admin");

#[contract]
pub struct CoreContract;

#[contractimpl]
impl CoreContract {
pub fn initialize(env: Env, admin: Address, initial_fee_bps: u32) {
admin.require_auth();
assert!(!env.storage().persistent().has(&ADMIN_KEY), "Already initialized");
assert_valid_fee_bps(initial_fee_bps);
env.storage().persistent().set(&ADMIN_KEY, &admin);
env.storage().persistent().set(&FEE_KEY, &initial_fee_bps);
}

/// Update the platform fee. Only the admin may call this.
/// Panics if `new_fee_bps > 10_000` (#517 basis-point limit guard).
pub fn set_fee(env: Env, caller: Address, new_fee_bps: u32) {
caller.require_auth();
let admin: Address = env.storage().persistent().get(&ADMIN_KEY).expect("Not initialized");
assert!(caller == admin, "Unauthorized");
assert_valid_fee_bps(new_fee_bps);
env.storage().persistent().set(&FEE_KEY, &new_fee_bps);
env.events().publish(
(symbol_short!("core"), symbol_short!("fee_set")),
(new_fee_bps,),
);
}

pub fn get_fee(env: Env) -> u32 {
env.storage().persistent().get(&FEE_KEY).unwrap_or(0)
}

pub fn max_fee_bps(_env: Env) -> u32 {
MAX_FEE_BPS
}

pub fn calculate_fee(env: Env, amount: i128) -> i128 {
compute_fee(amount, Self::get_fee(env))
}

pub fn calculate_net(env: Env, amount: i128) -> i128 {
compute_net(amount, Self::get_fee(env))
}
}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::{testutils::Address as _, Env};

fn deploy(env: &Env, fee_bps: u32) -> (CoreContractClient, Address) {
let id = env.register(CoreContract, ());
let client = CoreContractClient::new(env, &id);
let admin = Address::generate(env);
client.initialize(&admin, &fee_bps);
(client, admin)
}

#[test]
fn test_initialize_stores_fee() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = deploy(&env, 250);
assert_eq!(client.get_fee(), 250);
assert_eq!(client.max_fee_bps(), 10_000);
}

#[test]
#[should_panic(expected = "Already initialized")]
fn test_double_initialize_panics() {
let env = Env::default();
env.mock_all_auths();
let (client, admin) = deploy(&env, 250);
client.initialize(&admin, &100);
}

#[test]
#[should_panic(expected = "Fee exceeds maximum of 10000 basis points")]
fn test_initialize_above_max_panics() {
let env = Env::default();
env.mock_all_auths();
deploy(&env, 10_001);
}

#[test]
fn test_set_fee_valid() {
let env = Env::default();
env.mock_all_auths();
let (client, admin) = deploy(&env, 250);
client.set_fee(&admin, &500);
assert_eq!(client.get_fee(), 500);
}

#[test]
fn test_set_fee_exact_max_allowed() {
let env = Env::default();
env.mock_all_auths();
let (client, admin) = deploy(&env, 250);
client.set_fee(&admin, &10_000);
assert_eq!(client.get_fee(), 10_000);
}

#[test]
#[should_panic(expected = "Fee exceeds maximum of 10000 basis points")]
fn test_fee_limit_rejection_one_above_max() {
let env = Env::default();
env.mock_all_auths();
let (client, admin) = deploy(&env, 250);
client.set_fee(&admin, &10_001);
}

#[test]
#[should_panic(expected = "Fee exceeds maximum of 10000 basis points")]
fn test_fee_limit_rejection_large_value() {
let env = Env::default();
env.mock_all_auths();
let (client, admin) = deploy(&env, 250);
client.set_fee(&admin, &u32::MAX);
}

#[test]
#[should_panic(expected = "Unauthorized")]
fn test_set_fee_non_admin_panics() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = deploy(&env, 250);
client.set_fee(&Address::generate(&env), &100);
}

#[test]
fn test_calculate_fee_and_net() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = deploy(&env, 250);
assert_eq!(client.calculate_fee(&1_000), 25);
assert_eq!(client.calculate_net(&1_000), 975);
}

#[test]
fn test_calculate_fee_100_percent() {
let env = Env::default();
env.mock_all_auths();
let (client, admin) = deploy(&env, 250);
client.set_fee(&admin, &10_000);
assert_eq!(client.calculate_fee(&1_000), 1_000);
assert_eq!(client.calculate_net(&1_000), 0);
}
}
Loading
Loading