diff --git a/README.md b/README.md index e7e7776..358c446 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ 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 @@ -44,7 +46,8 @@ Built on **Stellar** + **Soroban** for the hackathon. ├── 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 ``` @@ -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 @@ -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 @@ -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 @@ -145,11 +163,11 @@ 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: @@ -157,12 +175,27 @@ 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 \ + --source milestack-deployer \ + --network testnet \ + -- initialize \ + --resolver +``` + +Replace `` with the ID from the previous step and `` with the admin's Stellar public key. This call can only succeed once — any subsequent call will panic. + --- ## Smart Contract @@ -170,27 +203,32 @@ The contract ID printed to stdout is your `NEXT_PUBLIC_CONTRACT_ID`. ### 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 | @@ -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 @@ -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) | @@ -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` diff --git a/contracts/mile-stack/src/lib.rs b/contracts/mile-stack/src/lib.rs index e84cc7d..4eea0dc 100644 --- a/contracts/mile-stack/src/lib.rs +++ b/contracts/mile-stack/src/lib.rs @@ -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( @@ -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, @@ -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. diff --git a/contracts/mile-stack/src/test/approve_milestone.rs b/contracts/mile-stack/src/test/approve_milestone.rs index 409fc91..630c093 100644 --- a/contracts/mile-stack/src/test/approve_milestone.rs +++ b/contracts/mile-stack/src/test/approve_milestone.rs @@ -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); @@ -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!( @@ -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(); @@ -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(); @@ -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(); @@ -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); } diff --git a/contracts/mile-stack/src/test/dispute_milestone.rs b/contracts/mile-stack/src/test/dispute_milestone.rs index 2de64de..730a68e 100644 --- a/contracts/mile-stack/src/test/dispute_milestone.rs +++ b/contracts/mile-stack/src/test/dispute_milestone.rs @@ -161,6 +161,7 @@ fn test_dispute_milestone_rejects_already_released_milestone() { let token_address = setup_token(&env, &token_admin, &client_addr, 500_0000000); let project_id = funded_project(&env, &contract, &client_addr, &freelancer, &token_address); + contract.mark_complete(&freelancer, &project_id, &0); contract.approve_milestone(&project_id, &0, &token_address); contract.dispute_milestone(&client_addr, &project_id, &0); } diff --git a/contracts/mile-stack/src/test/lifecycle.rs b/contracts/mile-stack/src/test/lifecycle.rs index c1a5b7a..db2551e 100644 --- a/contracts/mile-stack/src/test/lifecycle.rs +++ b/contracts/mile-stack/src/test/lifecycle.rs @@ -30,7 +30,7 @@ fn test_only_client_can_approve() { let freelancer = Address::generate(&env); let dummy_token = Address::generate(&env); - seed_project_in_storage(&env, &contract_id, &client_addr, &freelancer, MilestoneStatus::Funded); + seed_project_in_storage(&env, &contract_id, &client_addr, &freelancer, MilestoneStatus::Completed); let result = contract.try_approve_milestone(&1u64, &0u32, &dummy_token); assert!(result.is_err(), "approve_milestone must fail when client auth is not provided"); @@ -70,7 +70,9 @@ fn test_full_project_lifecycle() { assert_eq!(token_client.balance(&contract_id), m0_amount); assert_eq!(token_client.balance(&client_addr), m1_amount); - // Approve milestone 0 → XLM released to freelancer + // Freelancer marks milestone 0 complete, then client approves → XLM released + contract.mark_complete(&freelancer, &project_id, &0); + assert!(matches!(contract.get_milestone(&project_id, &0).status, MilestoneStatus::Completed)); contract.approve_milestone(&project_id, &0, &token_address); assert!(matches!(contract.get_milestone(&project_id, &0).status, MilestoneStatus::Released)); assert_eq!(token_client.balance(&freelancer), m0_amount); diff --git a/contracts/mile-stack/src/test/mark_complete.rs b/contracts/mile-stack/src/test/mark_complete.rs new file mode 100644 index 0000000..25ff215 --- /dev/null +++ b/contracts/mile-stack/src/test/mark_complete.rs @@ -0,0 +1,121 @@ +use super::helpers::*; +use crate::{MileStackContract, MileStackContractClient, MilestoneStatus}; +use soroban_sdk::{testutils::Address as _, Address}; + +#[test] +fn test_mark_complete_updates_status_to_completed() { + 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); + assert!(matches!( + contract.get_milestone(&project_id, &0).status, + MilestoneStatus::Funded + )); + + contract.mark_complete(&freelancer, &project_id, &0); + assert!(matches!( + contract.get_milestone(&project_id, &0).status, + MilestoneStatus::Completed + )); +} + +#[test] +fn test_mark_complete_does_not_affect_other_milestones() { + 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.mark_complete(&freelancer, &project_id, &0); + + assert!(matches!( + contract.get_milestone(&project_id, &1).status, + MilestoneStatus::Pending + )); +} + +#[test] +fn test_mark_complete_records_freelancer_auth() { + 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.mark_complete(&freelancer, &project_id, &0); + + let auths = env.auths(); + let freelancer_auth_found = auths.iter().any(|(addr, _)| *addr == freelancer); + assert!(freelancer_auth_found, "freelancer address was not recorded as an auth requirement"); +} + +#[test] +#[should_panic(expected = "milestone must be Funded to mark complete")] +fn test_mark_complete_rejects_pending_milestone() { + let env = make_env(); + env.mock_all_auths(); + + let contract = register(&env); + let client_addr = Address::generate(&env); + let freelancer = Address::generate(&env); + + create_demo_project(&env, &contract, &client_addr, &freelancer); + + contract.mark_complete(&freelancer, &1, &0); +} + +#[test] +#[should_panic(expected = "only the milestone freelancer can mark it complete")] +fn test_mark_complete_rejects_non_freelancer_caller() { + let env = make_env(); + env.mock_all_auths(); + + let contract = register(&env); + let client_addr = Address::generate(&env); + let freelancer = Address::generate(&env); + let impostor = 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.mark_complete(&impostor, &project_id, &0); +} + +#[test] +fn test_only_freelancer_auth_required_for_mark_complete() { + let env = make_env(); + let contract_id = env.register(MileStackContract, ()); + let contract = MileStackContractClient::new(&env, &contract_id); + + let client_addr = Address::generate(&env); + let freelancer = Address::generate(&env); + + seed_project_in_storage(&env, &contract_id, &client_addr, &freelancer, MilestoneStatus::Funded); + + let result = contract.try_mark_complete(&freelancer, &1u64, &0u32); + assert!(result.is_err(), "mark_complete must fail when freelancer auth is not provided"); +} diff --git a/contracts/mile-stack/src/test/mod.rs b/contracts/mile-stack/src/test/mod.rs index 6c226fa..acfec5a 100644 --- a/contracts/mile-stack/src/test/mod.rs +++ b/contracts/mile-stack/src/test/mod.rs @@ -5,6 +5,8 @@ mod create_project; mod dispute_milestone; mod fund_milestone; mod lifecycle; +mod mark_complete; +mod resolve_dispute; mod types; mod view_functions; diff --git a/contracts/mile-stack/src/test/resolve_dispute.rs b/contracts/mile-stack/src/test/resolve_dispute.rs new file mode 100644 index 0000000..9fcc5bd --- /dev/null +++ b/contracts/mile-stack/src/test/resolve_dispute.rs @@ -0,0 +1,174 @@ +use super::helpers::*; +use crate::{MileStackContract, MileStackContractClient, MilestoneStatus}; +use soroban_sdk::{testutils::Address as _, token::Client as TokenClient, Address}; + +fn setup_with_resolver<'a>( + env: &'a soroban_sdk::Env, +) -> (MileStackContractClient<'a>, Address, Address, Address) { + let contract = register(env); + let resolver = Address::generate(env); + contract.initialize(&resolver); + let client_addr = Address::generate(env); + let freelancer = Address::generate(env); + (contract, resolver, client_addr, freelancer) +} + +#[test] +fn test_initialize_sets_resolver() { + let env = make_env(); + env.mock_all_auths(); + + let contract = register(&env); + let resolver = Address::generate(&env); + contract.initialize(&resolver); + + // Verify resolver is stored — attempt to re-initialize should panic + let result = contract.try_initialize(&resolver); + assert!(result.is_err(), "double initialize must fail"); +} + +#[test] +#[should_panic(expected = "contract already initialized")] +fn test_initialize_rejects_double_call() { + let env = make_env(); + env.mock_all_auths(); + + let contract = register(&env); + let resolver = Address::generate(&env); + contract.initialize(&resolver); + contract.initialize(&resolver); +} + +#[test] +fn test_resolve_dispute_releases_to_freelancer() { + let env = make_env(); + env.mock_all_auths(); + + let (contract, resolver, client_addr, freelancer) = setup_with_resolver(&env); + let token_admin = Address::generate(&env); + + let milestone_amount = 500_0000000i128; + let token_address = setup_token(&env, &token_admin, &client_addr, milestone_amount); + let token_client = TokenClient::new(&env, &token_address); + let contract_id = contract.address.clone(); + + let project_id = funded_project(&env, &contract, &client_addr, &freelancer, &token_address); + + contract.dispute_milestone(&client_addr, &project_id, &0); + assert!(matches!( + contract.get_milestone(&project_id, &0).status, + MilestoneStatus::Disputed + )); + assert_eq!(token_client.balance(&contract_id), milestone_amount); + + contract.resolve_dispute(&resolver, &project_id, &0, &token_address, &true); + + assert!(matches!( + contract.get_milestone(&project_id, &0).status, + MilestoneStatus::Released + )); + assert_eq!(token_client.balance(&contract_id), 0); + assert_eq!(token_client.balance(&freelancer), milestone_amount); + assert_eq!(token_client.balance(&client_addr), 0); +} + +#[test] +fn test_resolve_dispute_refunds_to_client() { + let env = make_env(); + env.mock_all_auths(); + + let (contract, resolver, client_addr, freelancer) = setup_with_resolver(&env); + let token_admin = Address::generate(&env); + + let milestone_amount = 500_0000000i128; + let token_address = setup_token(&env, &token_admin, &client_addr, milestone_amount); + let token_client = TokenClient::new(&env, &token_address); + let contract_id = contract.address.clone(); + + let project_id = funded_project(&env, &contract, &client_addr, &freelancer, &token_address); + + contract.dispute_milestone(&client_addr, &project_id, &0); + + contract.resolve_dispute(&resolver, &project_id, &0, &token_address, &false); + + assert!(matches!( + contract.get_milestone(&project_id, &0).status, + MilestoneStatus::Released + )); + assert_eq!(token_client.balance(&contract_id), 0); + assert_eq!(token_client.balance(&client_addr), milestone_amount); + assert_eq!(token_client.balance(&freelancer), 0); +} + +#[test] +fn test_resolve_dispute_records_resolver_auth() { + let env = make_env(); + env.mock_all_auths(); + + let (contract, resolver, client_addr, freelancer) = setup_with_resolver(&env); + let token_admin = Address::generate(&env); + + let token_address = setup_token(&env, &token_admin, &client_addr, 500_0000000); + let project_id = funded_project(&env, &contract, &client_addr, &freelancer, &token_address); + contract.dispute_milestone(&client_addr, &project_id, &0); + + contract.resolve_dispute(&resolver, &project_id, &0, &token_address, &true); + + let auths = env.auths(); + let resolver_auth_found = auths.iter().any(|(addr, _)| *addr == resolver); + assert!(resolver_auth_found, "resolver address was not recorded as an auth requirement"); +} + +#[test] +#[should_panic(expected = "only the resolver can resolve disputes")] +fn test_resolve_dispute_rejects_non_resolver_caller() { + let env = make_env(); + env.mock_all_auths(); + + let (contract, _, client_addr, freelancer) = setup_with_resolver(&env); + let token_admin = Address::generate(&env); + let impostor = Address::generate(&env); + + let token_address = setup_token(&env, &token_admin, &client_addr, 500_0000000); + let project_id = funded_project(&env, &contract, &client_addr, &freelancer, &token_address); + contract.dispute_milestone(&client_addr, &project_id, &0); + + contract.resolve_dispute(&impostor, &project_id, &0, &token_address, &true); +} + +#[test] +#[should_panic(expected = "milestone must be Disputed to resolve")] +fn test_resolve_dispute_rejects_funded_milestone() { + let env = make_env(); + env.mock_all_auths(); + + let (contract, resolver, client_addr, freelancer) = setup_with_resolver(&env); + let token_admin = Address::generate(&env); + + let token_address = setup_token(&env, &token_admin, &client_addr, 500_0000000); + let project_id = funded_project(&env, &contract, &client_addr, &freelancer, &token_address); + + contract.resolve_dispute(&resolver, &project_id, &0, &token_address, &true); +} + +#[test] +fn test_resolve_dispute_only_resolver_auth_required() { + let env = make_env(); + let contract_id_addr = env.register(MileStackContract, ()); + let contract = MileStackContractClient::new(&env, &contract_id_addr); + + let client_addr = Address::generate(&env); + let freelancer = Address::generate(&env); + let resolver = Address::generate(&env); + let dummy_token = Address::generate(&env); + + { + env.mock_all_auths(); + contract.initialize(&resolver); + } + + seed_project_in_storage(&env, &contract_id_addr, &client_addr, &freelancer, MilestoneStatus::Disputed); + + let result = contract.try_resolve_dispute(&resolver, &1u64, &0u32, &dummy_token, &true); + assert!(result.is_err(), "resolve_dispute must fail when resolver auth is not provided"); +} diff --git a/contracts/mile-stack/src/types.rs b/contracts/mile-stack/src/types.rs index a2c20b8..d045da1 100644 --- a/contracts/mile-stack/src/types.rs +++ b/contracts/mile-stack/src/types.rs @@ -32,4 +32,5 @@ pub struct Project { pub enum DataKey { Project(u64), ProjectCount, + Resolver, } diff --git a/mile-stack-frontend/README.md b/mile-stack-frontend/README.md index 88aa7d8..88ea09a 100644 --- a/mile-stack-frontend/README.md +++ b/mile-stack-frontend/README.md @@ -54,7 +54,7 @@ mile-stack-frontend/ │ │ ├── page.tsx # Freelancer Dashboard — active projects │ │ └── projects/[id]/ │ │ ├── page.tsx # Server Component — resolves dynamic params -│ │ └── ProjectDetail.tsx # Client Component — milestones + dispute flow +│ │ └── ProjectDetail.tsx # Client Component — milestones + mark complete + dispute │ ├── projects/ │ │ ├── page.tsx # Public listings browser (no wallet required) │ │ └── [id]/ @@ -67,11 +67,12 @@ mile-stack-frontend/ ├── components/ │ ├── ui/ │ │ ├── Button.tsx # primary / accent / outline / ghost / destructive variants -│ │ └── Badge.tsx # Milestone status badges +│ │ └── Badge.tsx # Milestone status badges (Pending/Funded/Completed/Released/Disputed) │ ├── Footer.tsx # Site footer │ ├── Navbar.tsx # Sticky nav with active link highlight + mobile menu │ ├── Notification.tsx # Toast notification provider + useNotification hook -│ └── ScrollReveal.tsx # IntersectionObserver scroll animation wrapper +│ ├── ScrollReveal.tsx # IntersectionObserver scroll animation wrapper +│ └── WalletGuard.tsx # Wallet connection gate — wraps pages that require Freighter ├── contexts/ │ └── WalletContext.tsx # Freighter wallet state — connect / disconnect / auto-restore ├── lib/ @@ -80,7 +81,7 @@ mile-stack-frontend/ │ └── supabase.ts # Lazy Supabase client singleton ├── public/ │ └── favicon.svg -├── .env.local.example +├── .env.example ├── .prettierrc └── next.config.ts ``` @@ -98,7 +99,7 @@ npm install ### 2. Set up environment variables ```bash -cp .env.local.example .env.local +cp .env.example .env.local ``` The example file ships with the deployed testnet contract ID pre-filled. Fill in the Supabase and simulation source values. @@ -144,17 +145,40 @@ create table project_metadata ( -- RLS policies (anon key access) create policy "anon_select" on listings for select to anon using (true); create policy "anon_insert" on listings for insert to anon with check (true); -create policy "anon_update" on listings for update to anon using (true) with check (true); +create policy "anon_update_open_listings" on listings + for update to anon + using (status = 'open') + with check (status in ('open', 'filled', 'cancelled')); create policy "anon_select" on applications for select to anon using (true); create policy "anon_insert" on applications for insert to anon with check (true); -create policy "anon_update" on applications for update to anon using (true) with check (true); +create policy "anon_update_pending_applications" on applications + for update to anon + using (status = 'pending') + with check (status in ('pending', 'accepted', 'rejected')); create policy "anon_select" on project_metadata for select to anon using (true); create policy "anon_insert" on project_metadata for insert to anon with check (true); -create policy "anon_update" on project_metadata for update to anon using (true) with check (true); +create policy "anon_upsert_project_metadata" on project_metadata + for update to anon + using (true) + with check (length(trim(name)) > 0); + +-- Constraints +alter table listings add constraint listings_status_valid + check (status in ('open', 'filled', 'cancelled')); +alter table applications add constraint applications_status_valid + check (status in ('pending', 'accepted', 'rejected')); +alter table project_metadata add constraint project_metadata_name_nonempty + check (length(trim(name)) > 0); + +-- Prevent duplicate applications +create unique index applications_unique_per_listing + on applications (listing_id, freelancer_address); ``` +> If you have an existing Supabase project, apply [`supabase/migrations/20260607000000_tighten_rls_policies.sql`](../supabase/migrations/20260607000000_tighten_rls_policies.sql) to add the constraints and update the RLS policies. + 3. Copy your **Project URL** and **anon/public key** from **Project Settings > API** into `.env.local` ### 4. Start the development server @@ -178,7 +202,8 @@ Open [http://localhost:3000](http://localhost:3000). 5. Review applications at **Client > listings > applications** 6. Accept one application — Freighter signs the on-chain `create_project` transaction 7. Fund each milestone from the project management page -8. Approve milestones as work is delivered — XLM is released to the freelancer +8. Wait for the freelancer to mark the milestone complete (`mark_complete`) +9. Approve the milestone — XLM is released to the freelancer ### Freelancer @@ -186,7 +211,9 @@ Open [http://localhost:3000](http://localhost:3000). 2. Browse open projects at **/projects** (no wallet required to view) 3. Apply to a listing with an optional cover message 4. Once accepted, the project appears on the **Freelancer** dashboard -5. Dispute a milestone if needed +5. After completing work on a `Funded` milestone, click **Mark as Complete** +6. The client can now approve the milestone and release payment +7. Dispute a milestone if needed --- @@ -213,10 +240,11 @@ The design system lives in `app/globals.css` as CSS custom properties, mapped to | Status | Style | | -------- | ------------------------------------------------------- | -| Pending | Gray — `bg-slate-100 text-slate-600` | -| Funded | Blue — `bg-blue-50 text-accent border-blue-200` | -| Released | Green — `bg-emerald-50 text-success border-emerald-200` | -| Disputed | Red — `bg-red-50 text-destructive border-red-200` | +| Pending | Gray — `bg-slate-100 text-slate-600` | +| Funded | Blue — `bg-blue-50 text-accent border-blue-200` | +| Completed | Amber — `bg-amber-50 text-amber-700 border-amber-200` | +| Released | Green — `bg-emerald-50 text-success border-emerald-200` | +| Disputed | Red — `bg-red-50 text-destructive border-red-200` | ### Rules @@ -232,7 +260,7 @@ The design system lives in `app/globals.css` as CSS custom properties, mapped to | 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 simulations) | diff --git a/mile-stack-frontend/app/client/listings/[id]/applications/page.tsx b/mile-stack-frontend/app/client/listings/[id]/applications/page.tsx index d71e550..313163f 100644 --- a/mile-stack-frontend/app/client/listings/[id]/applications/page.tsx +++ b/mile-stack-frontend/app/client/listings/[id]/applications/page.tsx @@ -178,6 +178,7 @@ export default function ApplicationsPage() { setListing(listingData); setApplications(appsData); } catch (err) { + console.error("[Applications] fetchData:", err); setError(err instanceof Error ? err.message : "Failed to load data"); } finally { setLoading(false); @@ -203,6 +204,7 @@ export default function ApplicationsPage() { notify(`Project #${projectId} created — freelancer accepted.`, "success"); router.push(`/client/projects/${projectId}`); } catch (err) { + console.error("[Applications] handleAccept:", err); notify(err instanceof Error ? err.message : "Failed to accept application", "error"); setAcceptingId(null); } @@ -215,7 +217,9 @@ export default function ApplicationsPage() { setApplications((prev) => prev.map((a) => (a.id === application.id ? { ...a, status: "rejected" } : a)), ); + notify("Application rejected.", "success"); } catch (err) { + console.error("[Applications] handleReject:", err); notify(err instanceof Error ? err.message : "Failed to reject application", "error"); } finally { setRejectingId(null); diff --git a/mile-stack-frontend/app/client/listings/new/page.tsx b/mile-stack-frontend/app/client/listings/new/page.tsx index a8dfe34..22df693 100644 --- a/mile-stack-frontend/app/client/listings/new/page.tsx +++ b/mile-stack-frontend/app/client/listings/new/page.tsx @@ -2,12 +2,13 @@ import { type FormEvent, useState, useRef, type KeyboardEvent } from "react"; import { useRouter } from "next/navigation"; -import { Plus, Trash2, Wallet, X } from "lucide-react"; +import { Plus, Trash2, X } from "lucide-react"; import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; import { Button } from "@/components/ui/Button"; import { useNotification } from "@/components/Notification"; import { useWallet } from "@/contexts/WalletContext"; +import { WalletGuard } from "@/components/WalletGuard"; import { createListing } from "@/lib/listings"; interface MilestoneInput { @@ -22,7 +23,7 @@ function newMilestone(): MilestoneInput { export default function PostListingPage() { const router = useRouter(); - const { address, isConnected, isFreighterInstalled, connect } = useWallet(); + const { address, isConnected } = useWallet(); const { notify } = useNotification(); const [title, setTitle] = useState(""); @@ -99,6 +100,7 @@ export default function PostListingPage() { notify("Listing posted successfully.", "success"); router.push("/client"); } catch (err) { + console.error("[PostListing] handleSubmit:", err); notify(err instanceof Error ? err.message : "Failed to post listing", "error"); } finally { setSubmitting(false); @@ -121,34 +123,7 @@ export default function PostListingPage() {

- {/* Wallet not connected */} - {!isConnected && ( -
- -

Connect your wallet

-

- {isFreighterInstalled === false - ? "Install the Freighter extension to get started." - : "Connect your Freighter wallet to post a listing."} -

- {isFreighterInstalled === false ? ( - - Install Freighter - - ) : ( - - )} -
- )} - + {/* Form */} {isConnected && (
@@ -343,6 +318,7 @@ export default function PostListingPage() {
)} +
diff --git a/mile-stack-frontend/app/client/page.tsx b/mile-stack-frontend/app/client/page.tsx index 2abfb6d..8bb3e86 100644 --- a/mile-stack-frontend/app/client/page.tsx +++ b/mile-stack-frontend/app/client/page.tsx @@ -2,12 +2,13 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { Wallet, FolderOpen, ChevronRight, AlertCircle, RefreshCw, Plus } from "lucide-react"; +import { FolderOpen, ChevronRight, AlertCircle, RefreshCw, Plus } from "lucide-react"; import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { useWallet } from "@/contexts/WalletContext"; +import { WalletGuard } from "@/components/WalletGuard"; import { getClientProjects, stroopsToXlm, @@ -38,11 +39,11 @@ function formatDate(timestamp: bigint): string { const MILESTONE_BADGE_VARIANT: Record< MilestoneStatus, - "pending" | "funded" | "released" | "disputed" + "pending" | "funded" | "completed" | "released" | "disputed" > = { Pending: "pending", Funded: "funded", - Completed: "released", + Completed: "completed", Released: "released", Disputed: "disputed", }; @@ -199,7 +200,7 @@ function ProjectCard({ project, name }: { project: ContractProject; name?: strin } export default function ClientDashboard() { - const { address, isConnected, isFreighterInstalled, connect } = useWallet(); + const { address, isConnected } = useWallet(); const [projects, setProjects] = useState([]); const [projectNames, setProjectNames] = useState>({}); @@ -221,6 +222,7 @@ export default function ClientDashboard() { setProjects(data); setProjectNames(names); } catch (err) { + console.error("[ClientDashboard] fetchProjects:", err); setProjectsError(err instanceof Error ? err.message : "Failed to load projects"); } finally { setProjectsLoading(false); @@ -240,6 +242,7 @@ export default function ClientDashboard() { ); setListings(withCounts); } catch (err) { + console.error("[ClientDashboard] fetchListings:", err); setListingsError(err instanceof Error ? err.message : "Failed to load listings"); } finally { setListingsLoading(false); @@ -277,34 +280,7 @@ export default function ClientDashboard() { )} - {/* Wallet not connected */} - {!isConnected && ( -
- -

Connect your wallet

-

- {isFreighterInstalled === false - ? "Install the Freighter extension to get started." - : "Connect your Freighter wallet to view your dashboard."} -

- {isFreighterInstalled === false ? ( - - Install Freighter - - ) : ( - - )} -
- )} - + {isConnected && (
{/* ── My Listings ── */} @@ -418,6 +394,7 @@ export default function ClientDashboard() {
)} +
diff --git a/mile-stack-frontend/app/client/projects/[id]/ProjectManage.tsx b/mile-stack-frontend/app/client/projects/[id]/ProjectManage.tsx index 2e4e574..6fef161 100644 --- a/mile-stack-frontend/app/client/projects/[id]/ProjectManage.tsx +++ b/mile-stack-frontend/app/client/projects/[id]/ProjectManage.tsx @@ -29,14 +29,15 @@ import { type ContractProject, type MilestoneStatus, } from "@/lib/contract"; +import { getProjectNamesByIds } from "@/lib/listings"; const MILESTONE_BADGE_VARIANT: Record< MilestoneStatus, - "pending" | "funded" | "released" | "disputed" + "pending" | "funded" | "completed" | "released" | "disputed" > = { Pending: "pending", Funded: "funded", - Completed: "released", + Completed: "completed", Released: "released", Disputed: "disputed", }; @@ -80,7 +81,9 @@ function MilestoneCard({ const [fundConfirming, setFundConfirming] = useState(false); const [approveConfirming, setApproveConfirming] = useState(false); const showFundButton = canFund && milestone.status === "Pending"; - const showApproveButton = canApprove && milestone.status === "Funded"; + const showWaitingForFreelancer = milestone.status === "Funded"; + const showApproveButton = canApprove && milestone.status === "Completed"; + const showDisputed = milestone.status === "Disputed"; return (
@@ -111,7 +114,6 @@ function MilestoneCard({ {showFundButton && !fundConfirming && (
@@ -154,11 +156,19 @@ function MilestoneCard({
)} + {/* Waiting for freelancer */} + {showWaitingForFreelancer && ( +
+

+ Funds are in escrow — waiting for the freelancer to mark this milestone complete. +

+
+ )} + {/* Approve flow */} {showApproveButton && !approveConfirming && (
@@ -209,6 +219,17 @@ function MilestoneCard({ )} + + {/* Disputed state */} + {showDisputed && ( +
+
+ )} ); } @@ -218,6 +239,7 @@ export function ProjectManage({ projectId }: { projectId: number }) { const { notify } = useNotification(); const [project, setProject] = useState(null); + const [projectName, setProjectName] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [fundingIndex, setFundingIndex] = useState(null); @@ -227,9 +249,14 @@ export function ProjectManage({ projectId }: { projectId: number }) { setLoading(true); setError(null); try { - const data = await getProject(projectId); + const [data, names] = await Promise.all([ + getProject(projectId), + getProjectNamesByIds([projectId]).catch(() => ({}) as Record), + ]); setProject(data); + setProjectName(names[projectId] ?? null); } catch (err) { + console.error("[ProjectManage] fetchProject:", err); setError(err instanceof Error ? err.message : "Failed to load project"); } finally { setLoading(false); @@ -248,6 +275,7 @@ export function ProjectManage({ projectId }: { projectId: number }) { notify("Milestone funded — XLM is now held in escrow.", "success"); await fetchProject(); } catch (err) { + console.error("[ProjectManage] handleFund:", err); notify(err instanceof Error ? err.message : "Failed to fund milestone", "error"); } finally { setFundingIndex(null); @@ -262,6 +290,7 @@ export function ProjectManage({ projectId }: { projectId: number }) { notify("Milestone approved — XLM released to the freelancer.", "success"); await fetchProject(); } catch (err) { + console.error("[ProjectManage] handleApprove:", err); notify(err instanceof Error ? err.message : "Failed to approve milestone", "error"); } finally { setApprovingIndex(null); @@ -333,9 +362,11 @@ export function ProjectManage({ projectId }: { projectId: number }) {
{/* Header */}
-

Project #{String(project.id)}

+

+ {projectName ?? `Project #${String(project.id)}`} +

- Created {formatDate(project.created_at)} + #{String(project.id)} · Created {formatDate(project.created_at)}

diff --git a/mile-stack-frontend/app/client/projects/new/page.tsx b/mile-stack-frontend/app/client/projects/new/page.tsx index 00ac8cb..deb23a1 100644 --- a/mile-stack-frontend/app/client/projects/new/page.tsx +++ b/mile-stack-frontend/app/client/projects/new/page.tsx @@ -2,12 +2,13 @@ import { type FormEvent, useState } from "react"; import Link from "next/link"; -import { Plus, Trash2, Wallet, CheckCircle, ExternalLink } from "lucide-react"; +import { Plus, Trash2, CheckCircle, ExternalLink } from "lucide-react"; import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; import { Button } from "@/components/ui/Button"; import { useNotification } from "@/components/Notification"; import { useWallet } from "@/contexts/WalletContext"; +import { WalletGuard } from "@/components/WalletGuard"; import { createProject } from "@/lib/contract"; import { saveProjectName } from "@/lib/listings"; @@ -24,7 +25,7 @@ function newMilestone(): MilestoneInput { } export default function CreateProjectPage() { - const { address, isConnected, isFreighterInstalled, connect } = useWallet(); + const { address, isConnected } = useWallet(); const { notify } = useNotification(); const [projectName, setProjectName] = useState(""); @@ -78,6 +79,7 @@ export default function CreateProjectPage() { setCreatedProjectId(projectId); notify(`Project #${projectId} created successfully.`, "success"); } catch (err) { + console.error("[CreateProject] handleSubmit:", err); notify(err instanceof Error ? err.message : "Failed to create project", "error"); } finally { setSubmitting(false); @@ -104,34 +106,7 @@ export default function CreateProjectPage() {

- {/* Wallet not connected */} - {!isConnected && ( -
- -

Connect your wallet

-

- {isFreighterInstalled === false - ? "Install the Freighter extension to get started." - : "Connect your Freighter wallet to create a project."} -

- {isFreighterInstalled === false ? ( - - Install Freighter - - ) : ( - - )} -
- )} - + {/* Success state */} {createdProjectId !== null && (
@@ -321,6 +296,7 @@ export default function CreateProjectPage() {
)} +
diff --git a/mile-stack-frontend/app/freelancer/page.tsx b/mile-stack-frontend/app/freelancer/page.tsx index f67f0ad..6a59621 100644 --- a/mile-stack-frontend/app/freelancer/page.tsx +++ b/mile-stack-frontend/app/freelancer/page.tsx @@ -2,12 +2,13 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { Wallet, FolderOpen, ChevronRight, AlertCircle, RefreshCw } from "lucide-react"; +import { FolderOpen, ChevronRight, AlertCircle, RefreshCw } from "lucide-react"; import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { useWallet } from "@/contexts/WalletContext"; +import { WalletGuard } from "@/components/WalletGuard"; import { getFreelancerProjects, stroopsToXlm, @@ -17,6 +18,7 @@ import { type ContractProject, type MilestoneStatus, } from "@/lib/contract"; +import { getProjectNamesByIds } from "@/lib/listings"; function truncateAddress(addr: string) { return `${addr.slice(0, 6)}...${addr.slice(-6)}`; @@ -32,11 +34,11 @@ function formatDate(timestamp: bigint): string { const MILESTONE_BADGE_VARIANT: Record< MilestoneStatus, - "pending" | "funded" | "released" | "disputed" + "pending" | "funded" | "completed" | "released" | "disputed" > = { Pending: "pending", Funded: "funded", - Completed: "released", + Completed: "completed", Released: "released", Disputed: "disputed", }; @@ -64,7 +66,7 @@ function MilestoneRow({ milestone }: { milestone: ContractMilestone }) { ); } -function ProjectCard({ project }: { project: ContractProject }) { +function ProjectCard({ project, name }: { project: ContractProject; name?: string }) { const total = totalProjectValue(project); const overall = projectOverallStatus(project); @@ -72,7 +74,10 @@ function ProjectCard({ project }: { project: ContractProject }) {
-

Project #{String(project.id)}

+

+ {name ?? `Project #${String(project.id)}`} +

+

#{String(project.id)}

Created {formatDate(project.created_at)}

@@ -115,8 +120,9 @@ function ProjectCard({ project }: { project: ContractProject }) { } export default function FreelancerDashboard() { - const { address, isConnected, isFreighterInstalled, connect } = useWallet(); + const { address, isConnected } = useWallet(); const [projects, setProjects] = useState([]); + const [projectNames, setProjectNames] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -126,7 +132,11 @@ export default function FreelancerDashboard() { try { const data = await getFreelancerProjects(addr); setProjects(data); + const ids = data.map((p) => Number(p.id)); + const names = await getProjectNamesByIds(ids).catch(() => ({}) as Record); + setProjectNames(names); } catch (err) { + console.error("[FreelancerDashboard] fetchProjects:", err); setError(err instanceof Error ? err.message : "Failed to load projects"); } finally { setLoading(false); @@ -153,34 +163,7 @@ export default function FreelancerDashboard() {

- {/* Wallet not connected */} - {!isConnected && ( -
- -

Connect your wallet

-

- {isFreighterInstalled === false - ? "Install the Freighter extension to get started." - : "Connect your Freighter wallet to view your active projects."} -

- {isFreighterInstalled === false ? ( - - Install Freighter - - ) : ( - - )} -
- )} - + {/* Loading */} {isConnected && loading && (
@@ -232,10 +215,15 @@ export default function FreelancerDashboard() { {isConnected && !loading && !error && projects.length > 0 && (
{projects.map((project) => ( - + ))}
)} +
diff --git a/mile-stack-frontend/app/freelancer/projects/[id]/ProjectDetail.tsx b/mile-stack-frontend/app/freelancer/projects/[id]/ProjectDetail.tsx index d3c2135..46f50d0 100644 --- a/mile-stack-frontend/app/freelancer/projects/[id]/ProjectDetail.tsx +++ b/mile-stack-frontend/app/freelancer/projects/[id]/ProjectDetail.tsx @@ -2,7 +2,15 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { ArrowLeft, AlertTriangle, RefreshCw, AlertCircle, Wallet, FolderOpen } from "lucide-react"; +import { + ArrowLeft, + AlertTriangle, + Check, + RefreshCw, + AlertCircle, + Wallet, + FolderOpen, +} from "lucide-react"; import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; import { Badge } from "@/components/ui/Badge"; @@ -12,19 +20,21 @@ import { useWallet } from "@/contexts/WalletContext"; import { getProject, disputeMilestone, + markComplete, stroopsToXlm, type ContractMilestone, type ContractProject, type MilestoneStatus, } from "@/lib/contract"; +import { getProjectNamesByIds } from "@/lib/listings"; const MILESTONE_BADGE_VARIANT: Record< MilestoneStatus, - "pending" | "funded" | "released" | "disputed" + "pending" | "funded" | "completed" | "released" | "disputed" > = { Pending: "pending", Funded: "funded", - Completed: "released", + Completed: "completed", Released: "released", Disputed: "disputed", }; @@ -47,19 +57,32 @@ function canDispute(milestone: ContractMilestone, walletAddress: string | null): return isFreelancer && (milestone.status === "Funded" || milestone.status === "Completed"); } +function canMarkComplete(milestone: ContractMilestone, walletAddress: string | null): boolean { + if (!walletAddress) return false; + return ( + milestone.freelancer.toLowerCase() === walletAddress.toLowerCase() && + milestone.status === "Funded" + ); +} + function MilestoneCard({ milestone, index, walletAddress, + isMarkingComplete, isDisputing, + onMarkComplete, onDispute, }: { milestone: ContractMilestone; index: number; walletAddress: string | null; + isMarkingComplete: boolean; isDisputing: boolean; + onMarkComplete: (index: number) => void; onDispute: (index: number) => void; }) { + const showMarkComplete = canMarkComplete(milestone, walletAddress); const showDispute = canDispute(milestone, walletAddress); return ( @@ -87,17 +110,40 @@ function MilestoneCard({
- {showDispute && ( -
- + {milestone.status === "Disputed" && ( +
+
+ )} + + {(showMarkComplete || showDispute) && ( +
+ {showMarkComplete && ( + + )} + {showDispute && ( + + )}
)}
@@ -109,17 +155,24 @@ export function ProjectDetail({ projectId }: { projectId: number }) { const { notify } = useNotification(); const [project, setProject] = useState(null); + const [projectName, setProjectName] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [markingCompleteIndex, setMarkingCompleteIndex] = useState(null); const [disputingIndex, setDisputingIndex] = useState(null); const fetchProject = async () => { setLoading(true); setError(null); try { - const data = await getProject(projectId); + const [data, names] = await Promise.all([ + getProject(projectId), + getProjectNamesByIds([projectId]).catch(() => ({}) as Record), + ]); setProject(data); + setProjectName(names[projectId] ?? null); } catch (err) { + console.error("[ProjectDetail] fetchProject:", err); setError(err instanceof Error ? err.message : "Failed to load project"); } finally { setLoading(false); @@ -130,6 +183,21 @@ export function ProjectDetail({ projectId }: { projectId: number }) { fetchProject(); }, [projectId]); + const handleMarkComplete = async (milestoneIndex: number) => { + if (!address) return; + setMarkingCompleteIndex(milestoneIndex); + try { + await markComplete(address, projectId, milestoneIndex); + notify("Milestone marked as complete — waiting for client approval.", "success"); + await fetchProject(); + } catch (err) { + console.error("[ProjectDetail] handleMarkComplete:", err); + notify(err instanceof Error ? err.message : "Failed to mark milestone complete", "error"); + } finally { + setMarkingCompleteIndex(null); + } + }; + const handleDispute = async (milestoneIndex: number) => { if (!address) return; setDisputingIndex(milestoneIndex); @@ -138,6 +206,7 @@ export function ProjectDetail({ projectId }: { projectId: number }) { notify("Dispute raised — funds are now locked until resolved.", "success"); await fetchProject(); } catch (err) { + console.error("[ProjectDetail] handleDispute:", err); notify(err instanceof Error ? err.message : "Failed to raise dispute", "error"); } finally { setDisputingIndex(null); @@ -208,9 +277,11 @@ export function ProjectDetail({ projectId }: { projectId: number }) {
{/* Header */}
-

Project #{String(project.id)}

+

+ {projectName ?? `Project #${String(project.id)}`} +

- Created {formatDate(project.created_at)} + #{String(project.id)} · Created {formatDate(project.created_at)}

@@ -263,7 +334,9 @@ export function ProjectDetail({ projectId }: { projectId: number }) { milestone={m} index={i} walletAddress={address} + isMarkingComplete={markingCompleteIndex === i} isDisputing={disputingIndex === i} + onMarkComplete={handleMarkComplete} onDispute={handleDispute} /> ))} diff --git a/mile-stack-frontend/app/projects/[id]/ListingDetail.tsx b/mile-stack-frontend/app/projects/[id]/ListingDetail.tsx index 6a2ecb7..121bce6 100644 --- a/mile-stack-frontend/app/projects/[id]/ListingDetail.tsx +++ b/mile-stack-frontend/app/projects/[id]/ListingDetail.tsx @@ -39,6 +39,7 @@ export function ListingDetail({ listingId }: { listingId: string }) { const data = await getListing(listingId); setListing(data); } catch (err) { + console.error("[ListingDetail] fetchListing:", err); setError(err instanceof Error ? err.message : "Failed to load listing"); } finally { setLoading(false); @@ -53,7 +54,7 @@ export function ListingDetail({ listingId }: { listingId: string }) { if (!address || !listingId) return; hasApplied(listingId, address) .then(setAlreadyApplied) - .catch(() => {}); + .catch((err) => console.error("[ListingDetail] hasApplied:", err)); }, [address, listingId]); const handleApply = async () => { @@ -65,6 +66,7 @@ export function ListingDetail({ listingId }: { listingId: string }) { setAlreadyApplied(true); notify("Application submitted successfully.", "success"); } catch (err) { + console.error("[ListingDetail] handleApply:", err); notify(err instanceof Error ? err.message : "Failed to submit application", "error"); } finally { setSubmitting(false); diff --git a/mile-stack-frontend/components/Notification.tsx b/mile-stack-frontend/components/Notification.tsx index 434f401..310a824 100644 --- a/mile-stack-frontend/components/Notification.tsx +++ b/mile-stack-frontend/components/Notification.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useCallback, useContext, useRef, useState } from "react"; -import { X, CheckCircle, AlertCircle } from "lucide-react"; +import { X } from "lucide-react"; type NotificationType = "success" | "error"; @@ -65,11 +65,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode } isSuccess ? "bg-success text-white" : "bg-destructive text-white" } ${visible ? "translate-x-0 opacity-100" : "translate-x-4 opacity-0"}`} > - {isSuccess ? ( -
+ ); +} diff --git a/mile-stack-frontend/components/ui/Badge.tsx b/mile-stack-frontend/components/ui/Badge.tsx index 512b699..4881ec2 100644 --- a/mile-stack-frontend/components/ui/Badge.tsx +++ b/mile-stack-frontend/components/ui/Badge.tsx @@ -1,4 +1,4 @@ -type Variant = "default" | "pending" | "funded" | "released" | "disputed"; +type Variant = "default" | "pending" | "funded" | "completed" | "released" | "disputed"; interface BadgeProps { variant?: Variant; @@ -10,6 +10,7 @@ const variantClasses: Record = { default: "bg-muted text-muted-foreground", pending: "bg-slate-100 text-slate-600", funded: "bg-blue-50 text-accent border border-blue-200", + completed: "bg-amber-50 text-amber-700 border border-amber-200", released: "bg-emerald-50 text-success border border-emerald-200", disputed: "bg-red-50 text-destructive border border-red-200", }; diff --git a/mile-stack-frontend/lib/contract.ts b/mile-stack-frontend/lib/contract.ts index 618fd99..cffa0db 100644 --- a/mile-stack-frontend/lib/contract.ts +++ b/mile-stack-frontend/lib/contract.ts @@ -246,6 +246,117 @@ export async function createProject( throw new Error("Transaction timed out — check your wallet for status"); } +export async function resolveDispute( + resolverAddress: string, + projectId: number, + milestoneIndex: number, + releaseToFreelancer: boolean, +): Promise { + const XLM_TOKEN = process.env.NEXT_PUBLIC_XLM_TOKEN_ID; + if (!XLM_TOKEN) throw new Error("XLM_TOKEN_NOT_CONFIGURED"); + if (!CONTRACT_ID) throw new Error("CONTRACT_NOT_CONFIGURED"); + + const server = new rpc.Server(RPC_URL, { allowHttp: true }); + const contract = new Contract(CONTRACT_ID); + + const account = await server.getAccount(resolverAddress); + + const tx = new TransactionBuilder(account, { + fee: "300000", + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + contract.call( + "resolve_dispute", + new Address(resolverAddress).toScVal(), + nativeToScVal(BigInt(projectId), { type: "u64" }), + nativeToScVal(milestoneIndex, { type: "u32" }), + new Address(XLM_TOKEN).toScVal(), + nativeToScVal(releaseToFreelancer, { type: "bool" }), + ), + ) + .setTimeout(30) + .build(); + + const simResult = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(simResult)) throw new Error(simResult.error); + + const assembled = rpc.assembleTransaction(tx, simResult).build(); + const signedXdr = await signTx(assembled.toXDR(), NETWORK_PASSPHRASE); + + const signedTx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); + const submitResult = await server.sendTransaction(signedTx); + + if (submitResult.status === "ERROR") { + throw new Error( + `Transaction submission failed: ${JSON.stringify(submitResult.errorResult ?? "")}`, + ); + } + + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 2000)); + const txResult = await server.getTransaction(submitResult.hash); + if (txResult.status === rpc.Api.GetTransactionStatus.SUCCESS) return; + if (txResult.status === rpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed on-chain"); + } + } + throw new Error("Transaction timed out — check your wallet for status"); +} + +export async function markComplete( + walletAddress: string, + projectId: number, + milestoneIndex: number, +): Promise { + if (!CONTRACT_ID) throw new Error("CONTRACT_NOT_CONFIGURED"); + + const server = new rpc.Server(RPC_URL, { allowHttp: true }); + const contract = new Contract(CONTRACT_ID); + + const account = await server.getAccount(walletAddress); + + const tx = new TransactionBuilder(account, { + fee: "300000", + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + contract.call( + "mark_complete", + new Address(walletAddress).toScVal(), + nativeToScVal(BigInt(projectId), { type: "u64" }), + nativeToScVal(milestoneIndex, { type: "u32" }), + ), + ) + .setTimeout(30) + .build(); + + const simResult = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(simResult)) throw new Error(simResult.error); + + const assembled = rpc.assembleTransaction(tx, simResult).build(); + const signedXdr = await signTx(assembled.toXDR(), NETWORK_PASSPHRASE); + + const signedTx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); + const submitResult = await server.sendTransaction(signedTx); + + if (submitResult.status === "ERROR") { + throw new Error( + `Transaction submission failed: ${JSON.stringify(submitResult.errorResult ?? "")}`, + ); + } + + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 2000)); + const txResult = await server.getTransaction(submitResult.hash); + if (txResult.status === rpc.Api.GetTransactionStatus.SUCCESS) return; + if (txResult.status === rpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed on-chain"); + } + } + throw new Error("Transaction timed out — check your wallet for status"); +} + export async function fundMilestone( walletAddress: string, projectId: number, diff --git a/mile-stack-frontend/lib/listings.ts b/mile-stack-frontend/lib/listings.ts index 0c7dd03..51682c6 100644 --- a/mile-stack-frontend/lib/listings.ts +++ b/mile-stack-frontend/lib/listings.ts @@ -173,6 +173,19 @@ export async function getProjectNames(clientAddress: string): Promise> { + if (ids.length === 0) return {}; + const sb = getSupabaseClient(); + const { data, error } = await sb + .from("project_metadata") + .select("on_chain_project_id, name") + .in("on_chain_project_id", ids); + if (error) throw new Error(error.message); + return Object.fromEntries( + (data ?? []).map((r) => [r.on_chain_project_id as number, r.name as string]), + ); +} + // Accepts an application: creates the on-chain project, marks the listing as filled, // marks the accepted application as accepted, and rejects all others. export async function acceptApplication( @@ -188,7 +201,11 @@ export async function acceptApplication( amount: BigInt(Math.round(m.amount_xlm * 10_000_000)), })); - const projectId = await createProject(clientAddress, freelancerAddress, contractMilestones); + const projectId = await createProject( + clientAddress.toUpperCase(), + freelancerAddress.toUpperCase(), + contractMilestones, + ); const sb = getSupabaseClient(); diff --git a/mile-stack-frontend/scripts/seed-demo.ts b/mile-stack-frontend/scripts/seed-demo.ts index 4fe9b73..241564d 100644 --- a/mile-stack-frontend/scripts/seed-demo.ts +++ b/mile-stack-frontend/scripts/seed-demo.ts @@ -30,7 +30,7 @@ interface DemoListing { const DEMO_LISTINGS: DemoListing[] = [ { - client_address: CLIENT_ADDRESS.toLowerCase(), + client_address: CLIENT_ADDRESS, title: "Website Redesign", description: "Complete redesign of our company website covering UI/UX design, frontend implementation in React/TypeScript, and backend API integration. The final deliverable should be responsive, performant, and deployed to production.", @@ -44,7 +44,7 @@ const DEMO_LISTINGS: DemoListing[] = [ status: "open", }, { - client_address: CLIENT_ADDRESS.toLowerCase(), + client_address: CLIENT_ADDRESS, title: "Soroban Smart Contract Audit", description: "We need an experienced Soroban developer to audit our escrow contract before mainnet deployment. Review the contract logic, identify vulnerabilities, and provide a written report with recommendations.", @@ -58,7 +58,7 @@ const DEMO_LISTINGS: DemoListing[] = [ status: "open", }, { - client_address: CLIENT_ADDRESS.toLowerCase(), + client_address: CLIENT_ADDRESS, title: "Mobile App UI Design", description: "Design a clean, modern UI for our fintech mobile application. We need Figma designs covering onboarding, dashboard, transaction history, and settings screens. Our brand uses a dark-blue and white palette.", @@ -72,7 +72,7 @@ const DEMO_LISTINGS: DemoListing[] = [ status: "open", }, { - client_address: CLIENT_ADDRESS.toLowerCase(), + client_address: CLIENT_ADDRESS, title: "REST API Development", description: "Build a RESTful API for our marketplace platform. The API must handle user authentication, product listings, orders, and payments. Node.js with PostgreSQL preferred. Full test coverage required.", @@ -86,7 +86,7 @@ const DEMO_LISTINGS: DemoListing[] = [ status: "open", }, { - client_address: CLIENT_ADDRESS.toLowerCase(), + client_address: CLIENT_ADDRESS, title: "Data Dashboard & Analytics", description: "Create an interactive analytics dashboard to visualise our sales and user data. Charts, filters, date ranges, and CSV export are required. The dashboard will be embedded in our existing Next.js admin panel.", @@ -144,7 +144,7 @@ async function seedListing(listing: DemoListing, appMessage: string): Promise 0); + +drop policy if exists "anon_update" on project_metadata; + +create policy "anon_upsert_project_metadata" on project_metadata + for update to anon + using (true) + with check (length(trim(name)) > 0);