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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,68 @@ stellar contract build
cd contracts
cargo test
```

## Deployment

Deploy all six contracts to a Stellar network with a single command.

### Prerequisites

- [Stellar CLI](https://developers.stellar.org/docs/smart-contracts/deployment) v23+
- `STELLAR_SECRET_KEY` — deployer secret key
- `STELLAR_TOKEN_ADDRESS` — USDC token contract address on the target network
- (Optional) `STELLAR_RPC_URL` and `STELLAR_NETWORK_PASSPHRASE` if not using defaults

### Deploy

```bash
# Standalone (local) network
./tools/deploy.sh standalone

# Testnet
STELLAR_SECRET_KEY=S... STELLAR_TOKEN_ADDRESS=C... ./tools/deploy.sh testnet

# Futurenet
STELLAR_SECRET_KEY=S... STELLAR_TOKEN_ADDRESS=C... ./tools/deploy.sh futurenet
```

The script is **idempotent** — re-running on a fully-deployed state skips
already-deployed contracts and only performs missing wiring steps.

Deployment artifacts are saved to `deployments/<network>.json`.

### Deployment order

Contracts are deployed in dependency order:

| Step | Contract | Dependencies |
|------|----------|--------------|
| 1 | `badge-nft` | — |
| 2 | `reward-pool` | — |
| 3 | `stake-vault` | — |
| 4 | `course-registry` | `badge-nft`, `reward-pool` (post-init wiring) |
| 5 | `quest-engine` | `reward-pool`, `stake-vault` (init args) |
| 6 | `governance` | `badge-nft` (init arg) |

### Status check

Query each contract and print a health report:

```bash
./tools/status.sh standalone
./tools/status.sh testnet
```

### Directory structure

```
tools/
├── Cargo.toml # Workspace for tooling crates
├── deploy.sh # Deploy all contracts
└── status.sh # Health report

deployments/
├── standalone.json # Deployment config (local)
├── testnet.json # Deployment config (testnet)
└── futurenet.json # Deployment config (futurenet)
```
10 changes: 9 additions & 1 deletion contracts/badge-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ pub struct ContractUpgraded {
pub new_wasm_hash: soroban_sdk::BytesN<32>,
}

#[contractevent]
pub struct ContractInitialized {
#[topic]
pub admin: Address,
}

// The actual contract struct and implementation are only compiled when building
// the badge-nft wasm itself (default feature). Dependents disable this feature
// to avoid duplicate symbol errors at link time.
Expand All @@ -62,7 +68,7 @@ mod contract_impl {
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec};

use crate::types::{Badge, DataKey};
use crate::{BadgeMinted, BadgeRevoked, ContractUpgraded};
use crate::{BadgeMinted, BadgeRevoked, ContractInitialized, ContractUpgraded};

#[contract]
pub struct BadgeNFT;
Expand All @@ -82,6 +88,8 @@ mod contract_impl {
panic!("Already initialized");
}
env.storage().instance().set(&DataKey::Admin, &admin);

ContractInitialized { admin }.publish(&env);
}

/// Mints a Soulbound Token (badge) directly to the learner's address.
Expand Down
4 changes: 2 additions & 2 deletions contracts/badge-nft/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ fn test_initialize_success() {
// Initialize the contract
client.initialize(&registry);

// Verify event was not emitted (initialize doesn't emit events in this pattern)
assert_eq!(env.events().all().len(), 0);
// Verify ContractInitialized event was emitted
assert_eq!(env.events().all().len(), 1);
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,29 @@
]
]
},
"events": []
"events": [
{
"event": {
"ext": "v0",
"contract_id": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"type_": "contract",
"body": {
"v0": {
"topics": [
{
"symbol": "contract_initialized"
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"
}
],
"data": {
"map": []
}
}
}
},
"failed_call": false
}
]
}
8 changes: 8 additions & 0 deletions contracts/course-registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ pub struct ContractUpgraded {
pub new_wasm_hash: BytesN<32>,
}

#[contractevent]
pub struct ContractInitialized {
#[topic]
pub admin: Address,
}

#[contractimpl]
impl CourseRegistry {
/// Sets the official Protocol Admin. Must be called once upon deployment.
Expand All @@ -98,6 +104,8 @@ impl CourseRegistry {
panic!("Already initialized");
}
env.storage().instance().set(&DataKey::Admin, &admin);

ContractInitialized { admin }.publish(&env);
}

/// Registers the RewardPool contract address so the registry can trigger payouts on completion.
Expand Down
8 changes: 8 additions & 0 deletions contracts/governance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ pub struct ContractUpgraded {
pub new_wasm_hash: BytesN<32>,
}

#[contractevent]
pub struct ContractInitialized {
#[topic]
pub admin: Address,
}

#[contractimpl]
impl Governance {
/// Initializes the governance contract with the admin and BadgeNFT contract address.
Expand All @@ -76,6 +82,8 @@ impl Governance {
env.storage()
.instance()
.set(&BADGE_NFT_KEY, &badge_contract_address);

ContractInitialized { admin }.publish(&env);
}

/// Returns the proposal stored for the given proposal ID.
Expand Down
18 changes: 18 additions & 0 deletions contracts/quest-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ pub struct ExploreQuestVerified {
pub amount: i128,
}

#[contractevent]
pub struct ContractInitialized {
#[topic]
pub admin: Address,
}

#[contractevent]
pub struct PauseToggled {
#[topic]
pub admin: Address,
pub status: bool,
}

#[contract]
pub struct QuestEngineContract;

Expand All @@ -124,6 +137,8 @@ impl QuestEngineContract {
.instance()
.set(&DataKey::StakeVault, &stake_vault);
env.storage().instance().set(&DataKey::QuestCounter, &0u32);

ContractInitialized { admin }.publish(&env);
}

/// Toggles the pause state of the contract (emergency circuit breaker).
Expand Down Expand Up @@ -158,6 +173,9 @@ impl QuestEngineContract {

// 4. Store pause status in Instance storage
env.storage().instance().set(&DataKey::IsPaused, &status);

// 5. Emit PauseToggled event
PauseToggled { admin, status }.publish(&env);
}

/// Allows an employer to lock USDC directly in the QuestEngine contract.
Expand Down
51 changes: 43 additions & 8 deletions contracts/reward-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,25 @@ pub trait RewardPoolInterface {
fn fund_pool(env: Env, donor: Address, amount: i128);
fn emergency_sweep(env: Env, admin: Address, recovery_wallet: Address);
fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>);
fn token_decimals(env: Env) -> u32;
fn pool_balance(env: Env) -> i128;
}

#[contractevent]
pub struct PoolInitialized {
pub struct ContractInitialized {
#[topic]
pub admin: Address,
#[topic]
pub token: Address,
}

#[contractevent]
pub struct PauseToggled {
#[topic]
pub admin: Address,
pub status: bool,
}

#[contractevent]
pub struct SpenderAdded {
#[topic]
Expand Down Expand Up @@ -82,8 +91,8 @@ mod contract_impl {

use crate::types::DataKey;
use crate::{
ContractUpgraded, EmergencySweep, PoolFunded, PoolInitialized, RewardDistributed,
SpenderAdded,
ContractInitialized, ContractUpgraded, EmergencySweep, PauseToggled, PoolFunded,
RewardDistributed, SpenderAdded,
};

#[contract]
Expand All @@ -101,7 +110,7 @@ mod contract_impl {
/// * If contract is already initialized
/// * If admin authentication fails
/// Stores admin and reward-token addresses in instance storage and
/// emits the `PoolInitialized` event. Both addresses are recorded
/// emits the `ContractInitialized` event. Both addresses are recorded
/// on the first call; subsequent calls panic with
/// `"Already initialized"`.
pub fn initialize(env: Env, admin: Address, token: Address) {
Expand All @@ -119,8 +128,8 @@ mod contract_impl {
// 4. Store token in Instance storage
env.storage().instance().set(&DataKey::Token, &token);

// 5. Emit PoolInitialized event
PoolInitialized { admin, token }.publish(&env);
// 5. Emit ContractInitialized event
ContractInitialized { admin, token }.publish(&env);
}

/// Adds a contract address to the approved spender whitelist.
Expand Down Expand Up @@ -193,6 +202,9 @@ mod contract_impl {

// 4. Store pause status in Instance storage
env.storage().instance().set(&DataKey::IsPaused, &status);

// 5. Emit PauseToggled event
PauseToggled { admin, status }.publish(&env);
}

/// Distributes rewards from the pool to a learner.
Expand Down Expand Up @@ -250,10 +262,14 @@ mod contract_impl {
// 6. Initialize token::Client::new(&env, &token_id)
let token_client = token::Client::new(&env, &token_id);

// 7. Call token_client.transfer(&env.current_contract_address(), &learner, &amount)
// 7. Assert sufficient pool balance before transfer
let balance = token_client.balance(&env.current_contract_address());
assert!(amount <= balance, "Insufficient pool balance");

// 8. Call token_client.transfer(&env.current_contract_address(), &learner, &amount)
token_client.transfer(&env.current_contract_address(), &learner, &amount);

// 8. Emit RewardDistributed event
// 9. Emit RewardDistributed event
RewardDistributed {
caller,
learner,
Expand Down Expand Up @@ -352,6 +368,25 @@ mod contract_impl {
.publish(&env);
}

/// Returns the token decimals for the reward token.
/// Pure getter — no storage access, no event.
pub fn token_decimals(_env: Env) -> u32 {
crate::REWARD_TOKEN_DECIMALS
}

/// Returns the current pool token balance held by this contract.
/// Pure view — reads the on-chain token balance and returns it.
/// No event is emitted.
pub fn pool_balance(env: Env) -> i128 {
let token_id: Address = env
.storage()
.instance()
.get(&DataKey::Token)
.expect("Not initialized");
let token_client = token::Client::new(&env, &token_id);
token_client.balance(&env.current_contract_address())
}

/// Upgrades the contract WASM. Only callable by the Protocol Admin.
/// Replaces the RewardPool WASM with the supplied hash on the
/// Soroban host. Admin-only. Emits `ContractUpgraded` on
Expand Down
Loading
Loading