Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cd7e50b
fix: ensure addresses are uppercase when calling createProject to mat…
johneliud Jun 7, 2026
e3a3a89
chore: preserve original case for Stellar addresses in seed script (n…
johneliud Jun 7, 2026
a2a4734
feat: add getProjectNamesByIds helper to fetch metadata for multiple …
johneliud Jun 7, 2026
4ae264a
refactor: remove icon from notification component for simpler, cleane…
johneliud Jun 7, 2026
e9327fe
feat: add project name display to client project management page usin…
johneliud Jun 7, 2026
52ac5c7
feat: add project name display to freelancer dashboard using Supabase…
johneliud Jun 7, 2026
abe5d57
feat: add project name display to freelancer project detail page usin…
johneliud Jun 7, 2026
c9073ed
refactor: remove redundant icon from approve milestone button for cle…
johneliud Jun 7, 2026
8a8985c
feat: add mark_complete function for freelancers to signal milestone …
johneliud Jun 7, 2026
9f4d1a3
test: update approve_milestone tests to include mark_complete step an…
johneliud Jun 7, 2026
7c0a2b2
test: add mark_complete step before approval in dispute milestone tes…
johneliud Jun 7, 2026
143b1f5
test: update lifecycle tests to include mark_complete step before app…
johneliud Jun 7, 2026
a872e3c
test: add mark_complete module to test suite exports.
johneliud Jun 7, 2026
c66e788
fix: add error logging and success notification for application rejec…
johneliud Jun 7, 2026
9200e05
fix: add error logging to listing creation handler for debugging.
johneliud Jun 7, 2026
6fce7d7
fix: add error logging to client dashboard data fetching and update m…
johneliud Jun 7, 2026
a109dfa
feat: add 'waiting for freelancer' status display and update mileston…
johneliud Jun 7, 2026
8efeab0
fix: add error logging to direct project creation handler for debugging.
johneliud Jun 7, 2026
a4e38dd
fix: add error logging to freelancer dashboard project fetching and u…
johneliud Jun 7, 2026
6ec3462
feat: add mark complete functionality for freelancers with button, lo…
johneliud Jun 7, 2026
5671039
feat: add markComplete contract interaction for freelancers to signal…
johneliud Jun 7, 2026
41b6f2f
fix: add error logging to listing detail page for better debugging of…
johneliud Jun 7, 2026
01e79f9
feat: add completed badge variant for milestone status with amber sty…
johneliud Jun 7, 2026
3f3eeed
test: add comprehensive tests for mark_complete function including au…
johneliud Jun 7, 2026
62ae80a
feat: add initialize and resolve_dispute functions for contract resol…
johneliud Jun 7, 2026
dbdbbe8
test: add resolve_dispute module to test suite exports.
johneliud Jun 7, 2026
b805a38
feat: add Resolver storage key for dispute resolver address.
johneliud Jun 7, 2026
954ef3c
refactor: replace wallet connection UI with reusable WalletGuard comp…
johneliud Jun 7, 2026
0d0300e
refactor: replace wallet connection UI with reusable WalletGuard comp…
johneliud Jun 7, 2026
16d5b01
feat: add disputed state display in milestone card with locked funds …
johneliud Jun 7, 2026
98994b2
refactor: replace wallet connection UI with reusable WalletGuard comp…
johneliud Jun 7, 2026
66eefb8
refactor: replace wallet connection UI with reusable WalletGuard comp…
johneliud Jun 7, 2026
f77f819
feat: add disputed state display in freelancer project detail milesto…
johneliud Jun 7, 2026
12c89f5
feat: add resolveDispute contract interaction for platform resolver t…
johneliud Jun 7, 2026
cd225ae
test: add comprehensive tests for initialize and resolve_dispute func…
johneliud Jun 7, 2026
3ca4ec2
feat: add reusable WalletGuard component for consistent wallet connec…
johneliud Jun 7, 2026
cda8f9e
chore: tighten RLS policies with status constraints, unique applicati…
johneliud Jun 7, 2026
53db4ba
docs: update README with contract initialization step, mark_complete/…
johneliud Jun 7, 2026
c374e65
docs: update frontend README with mark_complete flow, WalletGuard com…
johneliud Jun 7, 2026
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
76 changes: 58 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,17 @@ Built on **Stellar** + **Soroban** for the hackathon.
│ │ ├── fund_milestone.rs # fund_milestone tests
│ │ ├── approve_milestone.rs
│ │ ├── dispute_milestone.rs
│ │ ├── mark_complete.rs # mark_complete tests
│ │ ├── resolve_dispute.rs # resolve_dispute + initialize tests
│ │ ├── view_functions.rs
│ │ └── lifecycle.rs # Auth guards + end-to-end lifecycle test
│ └── Cargo.toml
├── mile-stack-frontend/ # Next.js 16 frontend
├── supabase/
│ └── migrations/ # SQL migration files
│ ├── 20260604000000_create_listings_and_applications.sql
│ └── 20260604000001_create_project_metadata.sql
│ ├── 20260604000001_create_project_metadata.sql
│ └── 20260607000000_tighten_rls_policies.sql
├── Cargo.toml
└── README.md
```
Expand Down Expand Up @@ -93,9 +96,10 @@ cargo test
Expected output:

```
running 35 tests
running 50 tests
test test::approve_milestone::test_approve_milestone_records_client_auth ... ok
test test::approve_milestone::test_approve_milestone_rejects_already_released_milestone ... ok
test test::approve_milestone::test_approve_milestone_rejects_funded_milestone ... ok
test test::approve_milestone::test_approve_milestone_rejects_pending_milestone ... ok
test test::approve_milestone::test_approve_milestone_releases_xlm_to_freelancer ... ok
test test::approve_milestone::test_approve_milestone_updates_status_to_released ... ok
Expand All @@ -119,6 +123,20 @@ test test::fund_milestone::test_fund_milestone_updates_status_to_funded ... ok
test test::lifecycle::test_full_project_lifecycle ... ok
test test::lifecycle::test_only_client_can_approve ... ok
test test::lifecycle::test_only_client_can_fund ... ok
test test::mark_complete::test_mark_complete_auth_required ... ok
test test::mark_complete::test_mark_complete_does_not_affect_siblings ... ok
test test::mark_complete::test_mark_complete_rejects_non_freelancer ... ok
test test::mark_complete::test_mark_complete_rejects_pending_milestone ... ok
test test::mark_complete::test_mark_complete_records_freelancer_auth ... ok
test test::mark_complete::test_mark_complete_updates_status_to_completed ... ok
test test::resolve_dispute::test_initialize_double_init_rejected ... ok
test test::resolve_dispute::test_initialize_sets_resolver ... ok
test test::resolve_dispute::test_resolve_dispute_auth_required ... ok
test test::resolve_dispute::test_resolve_dispute_records_resolver_auth ... ok
test test::resolve_dispute::test_resolve_dispute_refunds_to_client ... ok
test test::resolve_dispute::test_resolve_dispute_rejects_funded_milestone ... ok
test test::resolve_dispute::test_resolve_dispute_rejects_non_resolver ... ok
test test::resolve_dispute::test_resolve_dispute_releases_to_freelancer ... ok
test test::types::test_initial_project_count_is_zero ... ok
test test::view_functions::test_get_milestone_panics_for_out_of_range_index ... ok
test test::view_functions::test_get_milestone_panics_for_unknown_project ... ok
Expand All @@ -128,7 +146,7 @@ test test::view_functions::test_get_project_panics_for_unknown_id ... ok
test test::view_functions::test_get_project_returns_correct_fields ... ok
test test::view_functions::test_view_functions_do_not_require_auth ... ok

test result: ok. 35 passed; 0 failed
test result: ok. 50 passed; 0 failed
```

### 3. Build the contract
Expand All @@ -145,52 +163,72 @@ target/wasm32v1-none/release/mile_stack.wasm

### 4. Deploy to Stellar Testnet

Generate and fund a deployer account:
Generate and fund a deployer account (skip if the `milestack-deployer` key already exists):

```bash
stellar keys generate deployer --network testnet
stellar keys fund deployer --network testnet
stellar keys generate milestack-deployer --network testnet
stellar keys fund milestack-deployer --network testnet
```

Deploy the contract:

```bash
stellar contract deploy \
--wasm target/wasm32v1-none/release/mile_stack.wasm \
--source deployer \
--source milestack-deployer \
--network testnet
```

The contract ID printed to stdout is your `NEXT_PUBLIC_CONTRACT_ID`.

### 5. Initialize the contract (required after every deploy)

The contract must be initialized once to set the dispute resolver address. Use the deployer's public key (or any trusted admin account):

```bash
stellar contract invoke \
--id <CONTRACT_ID> \
--source milestack-deployer \
--network testnet \
-- initialize \
--resolver <RESOLVER_ADDRESS>
```

Replace `<CONTRACT_ID>` with the ID from the previous step and `<RESOLVER_ADDRESS>` with the admin's Stellar public key. This call can only succeed once — any subsequent call will panic.

---

## Smart Contract

### Milestone Lifecycle

```
Pending → Funded → Released
↘ Disputed
Pending → Funded → Completed → Released
↘ Disputed ──────────────→ Released (via resolver)
```

The freelancer calls `mark_complete` after finishing work on a `Funded` milestone. Only then can the client call `approve_milestone` to release payment. Either party can call `dispute_milestone` at any time while funds are escrowed; a designated resolver account settles the dispute by calling `resolve_dispute`.

### Data Types

| Type | Description |
|------|-------------|
| `MilestoneStatus` | `Pending` → `Funded` → `Released` or `Disputed` |
| `MilestoneStatus` | `Pending` → `Funded` → `Completed` → `Released` or `Disputed` |
| `Milestone` | Title, XLM amount, status, freelancer address |
| `Project` | ID, client address, milestone list, creation timestamp |
| `DataKey` | Storage keys: `Project(id)`, `ProjectCount` |
| `DataKey` | Storage keys: `Project(id)`, `ProjectCount`, `Resolver` |

### Contract Functions

| Function | Auth | Description |
|----------|------|-------------|
| `initialize(resolver)` | Deployer (one-time) | Sets the dispute resolver address; panics if called again |
| `create_project(client, freelancer, titles, amounts)` | Client | Creates a new escrow project; returns project ID |
| `fund_milestone(project_id, milestone_index, token)` | Client | Locks milestone XLM in contract escrow (must be `Pending`) |
| `approve_milestone(project_id, milestone_index, token)` | Client | Releases escrowed XLM to the freelancer (must be `Funded`) |
| `mark_complete(caller, project_id, milestone_index)` | Freelancer | Signals work is done; transitions `Funded` → `Completed` |
| `approve_milestone(project_id, milestone_index, token)` | Client | Releases escrowed XLM to the freelancer (must be `Completed`) |
| `dispute_milestone(caller, project_id, milestone_index)` | Client or Freelancer | Flags milestone as `Disputed`, funds stay locked |
| `resolve_dispute(caller, project_id, milestone_index, token, release_to_freelancer)` | Resolver | Settles a `Disputed` milestone — pays winner, marks `Released` |
| `get_project_count()` | None | Returns total number of projects |
| `get_project(project_id)` | None | Fetch a full project by ID |
| `get_milestone(project_id, milestone_index)` | None | Fetch a single milestone |
Expand All @@ -206,7 +244,7 @@ cd mile-stack-frontend
npm install

# Copy environment variables
cp .env.local.example .env.local
cp .env.example .env.local
# Fill in values — see Environment Variables section below

# Start the development server
Expand All @@ -221,12 +259,12 @@ See [`mile-stack-frontend/README.md`](./mile-stack-frontend/README.md) for full

## Environment Variables

Create `.env.local` inside `mile-stack-frontend/` using `.env.local.example` as a template:
Create `.env.local` inside `mile-stack-frontend/` using `.env.example` as a template:

| Variable | Value / Description |
|----------|---------------------|
| `NEXT_PUBLIC_STELLAR_RPC_URL` | `https://soroban-testnet.stellar.org` |
| `NEXT_PUBLIC_CONTRACT_ID` | `CAHK5YTBEY7RGHYHIC4TJBWD7755IEMJVMYAKB7FJZWQJOLGMZ4W2EP7` |
| `NEXT_PUBLIC_CONTRACT_ID` | `CAYB22PI2YRQPW4VGCX34XSCKMHNPERQYXIJ6Z2OZ3YKISW4XG2DSLIU` |
| `NEXT_PUBLIC_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` |
| `NEXT_PUBLIC_XLM_TOKEN_ID` | `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` |
| `NEXT_PUBLIC_SIMULATION_SOURCE` | A funded testnet account public key (for read-only contract simulations) |
Expand All @@ -252,11 +290,13 @@ Import each secret key into Freighter (set to Testnet), then run `npm run seed`

| Network | Contract ID |
|---------|-------------|
| Testnet | [`CAHK5YTBEY7RGHYHIC4TJBWD7755IEMJVMYAKB7FJZWQJOLGMZ4W2EP7`](https://stellar.expert/explorer/testnet/contract/CAHK5YTBEY7RGHYHIC4TJBWD7755IEMJVMYAKB7FJZWQJOLGMZ4W2EP7) |
| Testnet | [`CAYB22PI2YRQPW4VGCX34XSCKMHNPERQYXIJ6Z2OZ3YKISW4XG2DSLIU`](https://stellar.expert/explorer/testnet/contract/CAYB22PI2YRQPW4VGCX34XSCKMHNPERQYXIJ6Z2OZ3YKISW4XG2DSLIU) |

**Deployer / Resolver:** `GCQQCRKG3C5DPPWTVTYYWEJLIPHUVBKQYT6HIOBX7I37UVSY7DNMNFZR`

**Deployer:** `GCQQCRKG3C5DPPWTVTYYWEJLIPHUVBKQYT6HIOBX7I37UVSY7DNMNFZR`
**Deploy transaction:** [`b5b91f2f43f746a0d08d741450af7a92747ee5f8f837ac3c96605033b3c4b8ff`](https://stellar.expert/explorer/testnet/tx/b5b91f2f43f746a0d08d741450af7a92747ee5f8f837ac3c96605033b3c4b8ff)

**Deploy transaction:** [`a9ffe9e9f7a5f15ba0c2dd81139760b7e350dcd205db06055bd26f2a850ab3c8`](https://stellar.expert/explorer/testnet/tx/a9ffe9e9f7a5f15ba0c2dd81139760b7e350dcd205db06055bd26f2a850ab3c8)
**Initialize transaction:** [`ea7bde1d6446dde399714c79e35027ca3bcb5ac3be77388adace8c5981365690`](https://stellar.expert/explorer/testnet/tx/ea7bde1d6446dde399714c79e35027ca3bcb5ac3be77388adace8c5981365690)

**Native XLM token (testnet):** `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC`

Expand Down
123 changes: 120 additions & 3 deletions contracts/mile-stack/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,77 @@ pub struct MileStackContract;

#[contractimpl]
impl MileStackContract {
/// Set the dispute resolver address. Can only be called once after deployment.
pub fn initialize(env: Env, resolver: Address) {
assert!(
!env.storage().instance().has(&DataKey::Resolver),
"contract already initialized"
);
env.storage().instance().set(&DataKey::Resolver, &resolver);
}

/// Resolve a disputed milestone by releasing funds to either the freelancer or the client.
/// Only the address set via `initialize` may call this.
pub fn resolve_dispute(
env: Env,
caller: Address,
project_id: u64,
milestone_index: u32,
token_address: Address,
release_to_freelancer: bool,
) -> bool {
caller.require_auth();

let resolver: Address = env
.storage()
.instance()
.get(&DataKey::Resolver)
.expect("contract not initialized");

assert!(caller == resolver, "only the resolver can resolve disputes");

let project = load_project(&env, project_id);

let milestone = project
.milestones
.get(milestone_index)
.expect("milestone index out of range");

assert!(
matches!(milestone.status, MilestoneStatus::Disputed),
"milestone must be Disputed to resolve"
);

let recipient = if release_to_freelancer {
milestone.freelancer.clone()
} else {
project.client.clone()
};

token::Client::new(&env, &token_address).transfer(
&env.current_contract_address(),
&recipient,
&milestone.amount,
);

let updated = Milestone {
status: MilestoneStatus::Released,
..milestone
};
let project = update_milestone(&env, project, milestone_index, updated);
save_project(&env, &project);

log!(
&env,
"DisputeResolved: project_id={}, milestone_index={}, release_to_freelancer={}",
project_id,
milestone_index,
release_to_freelancer
);

true
}

/// Initialise a new escrow project with a list of milestones.
/// Returns the newly assigned project ID.
pub fn create_project(
Expand Down Expand Up @@ -110,8 +181,54 @@ impl MileStackContract {
true
}

/// Signal that work on a funded milestone is complete.
/// Only the milestone's freelancer may call this. Milestone must be Funded.
/// Transitions status: Funded → Completed.
pub fn mark_complete(
env: Env,
caller: Address,
project_id: u64,
milestone_index: u32,
) -> bool {
caller.require_auth();

let project = load_project(&env, project_id);

let milestone = project
.milestones
.get(milestone_index)
.expect("milestone index out of range");

assert!(
caller == milestone.freelancer,
"only the milestone freelancer can mark it complete"
);

assert!(
matches!(milestone.status, MilestoneStatus::Funded),
"milestone must be Funded to mark complete"
);

let updated = Milestone {
status: MilestoneStatus::Completed,
..milestone
};
let project = update_milestone(&env, project, milestone_index, updated);
save_project(&env, &project);

log!(
&env,
"MilestoneCompleted: project_id={}, milestone_index={}, freelancer={}",
project_id,
milestone_index,
caller
);

true
}

/// Release escrowed XLM to the freelancer.
/// Only the project client may call this. Milestone must be Funded.
/// Only the project client may call this. Milestone must be Completed.
pub fn approve_milestone(
env: Env,
project_id: u64,
Expand All @@ -128,8 +245,8 @@ impl MileStackContract {
.expect("milestone index out of range");

assert!(
matches!(milestone.status, MilestoneStatus::Funded),
"milestone must be Funded to approve"
matches!(milestone.status, MilestoneStatus::Completed),
"milestone must be Completed to approve"
);

// Release escrowed XLM from the contract to the freelancer.
Expand Down
26 changes: 24 additions & 2 deletions contracts/mile-stack/src/test/approve_milestone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ fn test_approve_milestone_releases_xlm_to_freelancer() {
let contract_id = contract.address.clone();

contract.fund_milestone(&project_id, &0, &token_address);
contract.mark_complete(&freelancer, &project_id, &0);

assert_eq!(token_client.balance(&contract_id), milestone_amount);
assert_eq!(token_client.balance(&freelancer), 0);
Expand All @@ -44,6 +45,7 @@ fn test_approve_milestone_updates_status_to_released() {
let project_id = create_demo_project(&env, &contract, &client_addr, &freelancer);

contract.fund_milestone(&project_id, &0, &token_address);
contract.mark_complete(&freelancer, &project_id, &0);
contract.approve_milestone(&project_id, &0, &token_address);

assert!(matches!(
Expand All @@ -70,6 +72,7 @@ fn test_approve_milestone_records_client_auth() {
let project_id = create_demo_project(&env, &contract, &client_addr, &freelancer);

contract.fund_milestone(&project_id, &0, &token_address);
contract.mark_complete(&freelancer, &project_id, &0);
contract.approve_milestone(&project_id, &0, &token_address);

let auths = env.auths();
Expand All @@ -78,7 +81,7 @@ fn test_approve_milestone_records_client_auth() {
}

#[test]
#[should_panic(expected = "milestone must be Funded to approve")]
#[should_panic(expected = "milestone must be Completed to approve")]
fn test_approve_milestone_rejects_pending_milestone() {
let env = make_env();
env.mock_all_auths();
Expand All @@ -95,7 +98,25 @@ fn test_approve_milestone_rejects_pending_milestone() {
}

#[test]
#[should_panic(expected = "milestone must be Funded to approve")]
#[should_panic(expected = "milestone must be Completed to approve")]
fn test_approve_milestone_rejects_funded_milestone() {
let env = make_env();
env.mock_all_auths();

let contract = register(&env);
let client_addr = Address::generate(&env);
let freelancer = Address::generate(&env);
let token_admin = Address::generate(&env);

let token_address = setup_token(&env, &token_admin, &client_addr, 500_0000000);
let project_id = create_demo_project(&env, &contract, &client_addr, &freelancer);

contract.fund_milestone(&project_id, &0, &token_address);
contract.approve_milestone(&project_id, &0, &token_address);
}

#[test]
#[should_panic(expected = "milestone must be Completed to approve")]
fn test_approve_milestone_rejects_already_released_milestone() {
let env = make_env();
env.mock_all_auths();
Expand All @@ -109,6 +130,7 @@ fn test_approve_milestone_rejects_already_released_milestone() {
let project_id = create_demo_project(&env, &contract, &client_addr, &freelancer);

contract.fund_milestone(&project_id, &0, &token_address);
contract.mark_complete(&freelancer, &project_id, &0);
contract.approve_milestone(&project_id, &0, &token_address);
contract.approve_milestone(&project_id, &0, &token_address);
}
Loading
Loading