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/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions contracts/course-registry/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ doctest = false
[dependencies]
soroban-sdk = { workspace = true }
badge-nft = { path = "../badge-nft", default-features = false }
reward-pool = { path = "../reward-pool", default-features = false }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
badge-nft = { path = "../badge-nft", features = ["testutils"] }
reward-pool = { path = "../reward-pool", features = ["testutils"] }

52 changes: 52 additions & 0 deletions contracts/course-registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod types;
use types::{Course, DataKey};

use badge_nft::BadgeNFTClient;
use reward_pool::RewardPoolClient;

#[contract]
pub struct CourseRegistry;
Expand Down Expand Up @@ -52,6 +53,15 @@ pub struct ModuleCompleted {
pub new_progress: u32,
}

#[contractevent]
pub struct CourseCompleted {
#[topic]
pub learner: Address,
#[topic]
pub course_id: u32,
pub reward_amount: i128,
}

#[contractevent]
pub struct ContractUpgraded {
#[topic]
Expand All @@ -69,6 +79,26 @@ impl CourseRegistry {
env.storage().instance().set(&DataKey::Admin, &admin);
}

/// Registers the RewardPool contract address so the registry can trigger payouts on completion.
/// Only callable by the Protocol Admin.
pub fn set_reward_pool_address(env: Env, admin: Address, reward_pool_address: Address) {
admin.require_auth();

let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Contract not initialized");
assert!(
admin == stored_admin,
"Unauthorized: Caller is not the protocol admin"
);

env.storage()
.instance()
.set(&DataKey::RewardPoolAddress, &reward_pool_address);
}

/// Registers the BadgeNFT contract address so the registry can mint badges on completion.
/// Only callable by the Protocol Admin.
pub fn set_badge_nft_address(env: Env, admin: Address, badge_nft_address: Address) {
Expand Down Expand Up @@ -369,6 +399,28 @@ impl CourseRegistry {
let badge_nft = BadgeNFTClient::new(&env, &badge_nft_address);
badge_nft.mint_badge(&env.current_contract_address(), &learner, &id);
}

// 10. Trigger reward distribution if RewardPool address is configured
if let Some(reward_pool_address) = env
.storage()
.instance()
.get::<DataKey, Address>(&DataKey::RewardPoolAddress)
{
let reward_pool = RewardPoolClient::new(&env, &reward_pool_address);
let base_reward: i128 = 10_0000000; // 10 USDC (7 decimal places)
reward_pool.distribute_reward(
&env.current_contract_address(),
&learner,
&base_reward,
);

CourseCompleted {
learner: learner.clone(),
course_id: id,
reward_amount: base_reward,
}
.publish(&env);
}
}
}

Expand Down
177 changes: 177 additions & 0 deletions contracts/course-registry/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -774,3 +774,180 @@ fn test_transfer_ownership_updates_instructor_field() {
assert_eq!(after.instructor, new_instructor);
assert_ne!(after.instructor, instructor);
}

// ── Reward payout on course completion (Issue #53) ────────────────────────────

use reward_pool::{RewardPool, RewardPoolClient};
use soroban_sdk::token;

/// Deploys + initializes a RewardPool backed by a real SAC token.
/// Returns (reward_pool_client, token_admin, token_sac_client, token_address).
fn setup_reward_pool<'a>(
env: &Env,
token_admin: &Address,
) -> (
RewardPoolClient<'a>,
soroban_sdk::token::StellarAssetClient<'a>,
Address,
) {
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_id.address();
let token_sac = token::StellarAssetClient::new(env, &token_address);

let reward_pool_id = env.register(RewardPool, ());
let reward_pool_client = RewardPoolClient::new(env, &reward_pool_id);
reward_pool_client.initialize(token_admin, &token_address);

(reward_pool_client, token_sac, token_address)
}

/// Test 1 – Complete course triggers reward distribution (full happy path).
#[test]
fn test_complete_course_triggers_reward_distribution() {
let (env, client) = setup();
let admin = Address::generate(&env);
let instructor = Address::generate(&env);
let learner = Address::generate(&env);

// Initialise CourseRegistry and create a 2-module course
client.initialize(&admin);
let course_id = client.create_course(&admin, &instructor, &2, &dummy_hash(&env));

// Deploy RewardPool and fund it
let (reward_pool_client, token_sac, _) = setup_reward_pool(&env, &admin);
token_sac.mint(&reward_pool_client.address, &1_000_000_000); // 100 USDC

// Wire up: whitelist CourseRegistry in RewardPool, set RewardPool address in CourseRegistry
reward_pool_client.add_approved_spender(&admin, &client.address);
client.set_reward_pool_address(&admin, &reward_pool_client.address);

// Also wire up a badge NFT so we can confirm badge + reward both fire
let badge_client = setup_badge_nft(&env, &client.address);
client.set_badge_nft_address(&admin, &badge_client.address);

// Module 1 — no reward yet
client.complete_module(&admin, &learner, &course_id);
assert!(!badge_client.has_badge(&learner, &course_id));
assert_eq!(token_sac.balance(&learner), 0);

// Module 2 (final) — badge minted AND reward transferred
client.complete_module(&admin, &learner, &course_id);

// Verify CourseCompleted event was emitted immediately after contract call
// (subsequent client calls like has_badge or balance will clear the event log)
let all_events = env.events().all();
assert!(!all_events.is_empty());

assert!(badge_client.has_badge(&learner, &course_id));
assert_eq!(token_sac.balance(&learner), 10_0000000); // 10 USDC
}

/// Test 2 – Reward NOT distributed when CourseRegistry is not whitelisted.
#[test]
#[should_panic(expected = "Caller is not an authorized spender")]
fn test_reward_not_distributed_without_whitelist() {
let (env, client) = setup();
let admin = Address::generate(&env);
let instructor = Address::generate(&env);
let learner = Address::generate(&env);

client.initialize(&admin);
let course_id = client.create_course(&admin, &instructor, &1, &dummy_hash(&env));

// Deploy RewardPool and fund it — but do NOT whitelist CourseRegistry
let (reward_pool_client, token_sac, _) = setup_reward_pool(&env, &admin);
token_sac.mint(&reward_pool_client.address, &1_000_000_000);

// Set the RewardPool address WITHOUT calling add_approved_spender
client.set_reward_pool_address(&admin, &reward_pool_client.address);

// Should panic: "Caller is not an authorized spender"
client.complete_module(&admin, &learner, &course_id);
}

/// Test 3 – No reward distributed if RewardPool address was never set (graceful degradation).
#[test]
fn test_reward_not_distributed_if_reward_pool_not_set() {
let (env, client) = setup();
let admin = Address::generate(&env);
let instructor = Address::generate(&env);
let learner = Address::generate(&env);

client.initialize(&admin);
let course_id = client.create_course(&admin, &instructor, &1, &dummy_hash(&env));

// Wire badge NFT but deliberately omit set_reward_pool_address
let badge_client = setup_badge_nft(&env, &client.address);
client.set_badge_nft_address(&admin, &badge_client.address);

// Completing the only module must NOT panic
client.complete_module(&admin, &learner, &course_id);

// Badge is still minted
assert!(badge_client.has_badge(&learner, &course_id));
// Progress reached total_modules
assert_eq!(client.get_progress(&learner, &course_id), 1);
}

/// Test 4 – Multiple learners each receive independent rewards.
#[test]
fn test_multiple_learners_get_independent_rewards() {
let (env, client) = setup();
let admin = Address::generate(&env);
let instructor = Address::generate(&env);
let learner_a = Address::generate(&env);
let learner_b = Address::generate(&env);

client.initialize(&admin);
let course_id = client.create_course(&admin, &instructor, &1, &dummy_hash(&env));

let (reward_pool_client, token_sac, _) = setup_reward_pool(&env, &admin);
token_sac.mint(&reward_pool_client.address, &1_000_000_000); // enough for both

reward_pool_client.add_approved_spender(&admin, &client.address);
client.set_reward_pool_address(&admin, &reward_pool_client.address);

// Learner A completes the course
client.complete_module(&admin, &learner_a, &course_id);
assert_eq!(token_sac.balance(&learner_a), 10_0000000);

// Learner B completes the course
client.complete_module(&admin, &learner_b, &course_id);
assert_eq!(token_sac.balance(&learner_b), 10_0000000);

// Pool balance decreased by 2 × 10 USDC
assert_eq!(
token_sac.balance(&reward_pool_client.address),
1_000_000_000 - 2 * 10_0000000
);
}

/// Test 5 – Reward is distributed ONLY on the final module (not intermediate ones).
#[test]
fn test_reward_distributed_only_on_final_module() {
let (env, client) = setup();
let admin = Address::generate(&env);
let instructor = Address::generate(&env);
let learner = Address::generate(&env);

client.initialize(&admin);
let course_id = client.create_course(&admin, &instructor, &3, &dummy_hash(&env));

let (reward_pool_client, token_sac, _) = setup_reward_pool(&env, &admin);
token_sac.mint(&reward_pool_client.address, &1_000_000_000);

reward_pool_client.add_approved_spender(&admin, &client.address);
client.set_reward_pool_address(&admin, &reward_pool_client.address);

// Module 1 — no reward
client.complete_module(&admin, &learner, &course_id);
assert_eq!(token_sac.balance(&learner), 0);

// Module 2 — no reward
client.complete_module(&admin, &learner, &course_id);
assert_eq!(token_sac.balance(&learner), 0);

// Module 3 (final) — reward paid out
client.complete_module(&admin, &learner, &course_id);
assert_eq!(token_sac.balance(&learner), 10_0000000);
}
1 change: 1 addition & 0 deletions contracts/course-registry/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ pub enum DataKey {
CourseCount,
Admin,
BadgeNftAddress,
RewardPoolAddress,
}
5 changes: 5 additions & 0 deletions contracts/reward-pool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }

[features]
default = ["contract"]
contract = []
testutils = ["soroban-sdk/testutils"]
Loading
Loading