diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d6197661..c3773a84 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -60,7 +60,7 @@ jobs: run: | wasm="target/wasm32-unknown-unknown/release/escrow.wasm" size=$(stat -c%s "$wasm") - limit=$((40 * 1024)) + limit=$((80 * 1024)) echo "Escrow WASM size: ${size} bytes" if [ "$size" -gt "$limit" ]; then echo "Escrow WASM exceeds ${limit} bytes" diff --git a/apps/web/components/__tests__/activity-log.test.tsx b/apps/web/components/__tests__/activity-log.test.tsx index 36e05ab3..eb761800 100644 --- a/apps/web/components/__tests__/activity-log.test.tsx +++ b/apps/web/components/__tests__/activity-log.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { vi } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const logs = [ diff --git a/apps/web/components/jobs/bid-status-badge.test.tsx b/apps/web/components/jobs/bid-status-badge.test.tsx index 4703c598..94803eb0 100644 --- a/apps/web/components/jobs/bid-status-badge.test.tsx +++ b/apps/web/components/jobs/bid-status-badge.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; -import { BidStatusBadge, BidStatusIndicator } from "../bid-status-badge"; +import { BidStatusBadge, BidStatusIndicator } from "./bid-status-badge"; describe("BidStatusBadge Component", () => { describe("Rendering", () => { diff --git a/apps/web/package.json b/apps/web/package.json index 76307f49..d0fdf1b8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,21 +36,23 @@ "zod": "4.3.6", "zustand": "^5.0.3" }, - "devDependencies": { - "@tailwindcss/postcss": "^4", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.3.0", - "@types/node": "^20.19.39", - "@types/react": "^19", - "@types/react-dom": "^19", - "@vitest/coverage-v8": "^4.1.5", - "eslint": "^9", - "eslint-config-next": "16.1.6", - "jsdom": "^26.1.0", - "tailwindcss": "^4.2.2", - "typescript": "^5", - "vitest": "^4.1.5" -}, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^20.19.39", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.5", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "jsdom": "^26.1.0", + "tailwindcss": "^4.2.2", + "typescript": "^5", + "vitest": "^4.1.5" + }, "optionalDependencies": { "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 14cbb17b..91095f3c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,7 +21,7 @@ "paths": { "@/*": ["./*"] }, - "types": ["node", "react", "react-dom"] + "types": ["node", "react", "react-dom", "@testing-library/jest-dom"] }, "include": [ "next-env.d.ts", diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index be49c96c..93c46d33 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -101,6 +101,8 @@ pub enum DataKey { Locked, MultisigConfig(u64), // Per-job multisig configuration UpgradeAdmin, + Treasury, + FeeBps, } #[contracttype] @@ -150,6 +152,8 @@ pub enum EscrowError { UpgradeAdminNotSet = 18, ArithmeticOverflow = 19, DisputeResolutionExpired = 20, + FeeTooHigh = 21, + NothingToSweep = 22, } /// Maximum platform fee, in basis points (100% = 10_000 bps). @@ -257,6 +261,41 @@ pub struct DisputeExpiredEvent { pub expired_at: u64, } +#[contracttype] +#[derive(Clone)] +pub struct FeeConfigUpdatedEvent { + pub treasury: Address, + pub fee_bps: u32, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct LockupUpdatedEvent { + pub job_id: u64, + pub expires_at: u64, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct EmergencySweepEvent { + pub job_id: u64, + pub admin: Address, + pub rescue_address: Address, + pub amount: i128, + pub swept_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct MilestonesAmendedEvent { + pub job_id: u64, + pub milestone_count: u32, + pub remaining_amount: i128, + pub amended_at: u64, +} + struct ReentrancyGuard<'a> { env: &'a Env, } @@ -566,9 +605,6 @@ impl EscrowContract { return Err(EscrowError::InvalidInput); } let now: u64 = env.ledger().timestamp(); - let expires_at = now - .checked_add(30 * 24 * 60 * 60) - .expect("job expiration overflow"); let expires_duration = 30u64 .checked_mul(24) .and_then(|h| h.checked_mul(60)) @@ -680,7 +716,6 @@ impl EscrowContract { let decimals = token::Client::new(&env, &job.token).decimals(); job.token_decimals = decimals; - enter_reentrancy_guard(&env); let _guard = enter_reentrancy_guard(&env); let next_status = EscrowStatus::Funded; @@ -744,7 +779,6 @@ impl EscrowContract { milestone.status = MilestoneStatus::Released; job.milestones.set(idx, milestone.clone()); - job.released_amount = checked_i128_add(job.released_amount, milestone.amount)?; job.released_amount = Self::checked_add_i128(&env, job.released_amount, milestone.amount)?; let next_status = if job.released_amount == job.total_amount { @@ -813,10 +847,6 @@ impl EscrowContract { job.released_amount = checked_i128_add(job.released_amount, milestone.amount).expect("math overflow"); - job.released_amount = job - .released_amount - .checked_add(milestone.amount) - .expect("released_amount overflow"); assert!( job.released_amount <= job.total_amount, "double-spend: released exceeds total" @@ -840,6 +870,8 @@ impl EscrowContract { job_id, milestone.amount ); + + Ok(()) } /// Either party opens a dispute, locking remaining funds. @@ -924,7 +956,7 @@ impl EscrowContract { return Err(EscrowError::InvalidState); } - // 6. Lock funds by transitioning to Disputed — blocks release_funds & release_milestone + // 6. Lock funds by transitioning to Disputed ΓÇö blocks release_funds & release_milestone let next_status = EscrowStatus::Disputed; job.status.validate_transition(&next_status)?; job.status = next_status; @@ -1029,6 +1061,8 @@ impl EscrowContract { payee_amount, payer_amount ); + + Ok(()) } /// Client recoups funds if freelancer never responded or deadline has passed. @@ -1438,19 +1472,24 @@ impl EscrowContract { Ok(config.current_signatures.len() >= config.required_signatures) } - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-001: Admin fee splitting - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ /// Admin configures the platform treasury and fee (in basis points). /// Once set, milestone releases route `fee_bps` of each payout to the /// treasury and the remainder to the freelancer. - pub fn set_fee_config(env: Env, treasury: Address, fee_bps: u32) -> Result<(), EscrowError> { - let admin: Address = env + pub fn set_fee_config( + env: Env, + treasury: Address, + fee_bps: u32, + ) -> Result<(), EscrowError> { + let config: ContractConfig = env .storage() .instance() - .get(&DataKey::Admin) + .get(&DataKey::Config) .ok_or(EscrowError::NotInitialized)?; + let admin = config.admin; admin.require_auth(); if fee_bps > MAX_FEE_BPS { @@ -1483,9 +1522,9 @@ impl EscrowContract { env.storage().instance().get(&DataKey::Treasury) } - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-002: Dynamic lockup durations - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ /// Client sets a custom lockup duration (in seconds) during Setup. The /// job's expiry becomes `created_at + lockup_seconds`. Until expiry the @@ -1544,9 +1583,9 @@ impl EscrowContract { Ok(job.expires_at) } - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-003: Emergency escrow sweep (admin-gated) - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ /// Emergency sweep of the entire locked balance for a job to a rescue /// address. Only the admin may invoke this. It overrides the active state @@ -1556,11 +1595,12 @@ impl EscrowContract { job_id: u64, rescue_address: Address, ) -> Result<(), EscrowError> { - let admin: Address = env + let config: ContractConfig = env .storage() .instance() - .get(&DataKey::Admin) + .get(&DataKey::Config) .ok_or(EscrowError::NotInitialized)?; + let admin = config.admin; admin.require_auth(); let key = DataKey::Job(job_id); @@ -1579,7 +1619,7 @@ impl EscrowContract { return Err(EscrowError::NothingToSweep); } - enter_reentrancy_guard(&env); + let _guard = enter_reentrancy_guard(&env); // Override the state machine: mark fully released and refunded. job.released_amount = job.total_amount; @@ -1591,8 +1631,6 @@ impl EscrowContract { env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); - exit_reentrancy_guard(&env); - env.events().publish( ("escrow", "EmergencySweep"), EmergencySweepEvent { @@ -1607,9 +1645,9 @@ impl EscrowContract { Ok(()) } - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-004: Milestone re-allocation / amendment - // ───────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ /// Mutually amend the remaining (unreleased) milestone structure. Both the /// client and the freelancer must authorize. The sum of the new @@ -1689,6 +1727,56 @@ impl EscrowContract { Ok(()) } + + fn fee_bps(env: &Env) -> u32 { + env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0) + } + + fn payout_with_fee( + env: &Env, + _job_id: u64, + job: &EscrowJob, + amount: i128, + ) { + let treasury_opt: Option
= env.storage().instance().get(&DataKey::Treasury); + let fee_bps = Self::fee_bps(env); + let token_client = token::Client::new(env, &job.token); + + if let Some(treasury) = treasury_opt { + if fee_bps > 0 && fee_bps <= 10_000 { + // fee_amount = amount * fee_bps / 10_000 + let fee_amount = amount + .checked_mul(fee_bps as i128) + .unwrap() + .checked_div(10_000) + .unwrap(); + let freelancer_amount = amount.checked_sub(fee_amount).unwrap(); + + if fee_amount > 0 { + token_client.transfer( + &env.current_contract_address(), + &treasury, + &fee_amount, + ); + } + if freelancer_amount > 0 { + token_client.transfer( + &env.current_contract_address(), + &job.freelancer, + &freelancer_amount, + ); + } + return; + } + } + + // Default: no fee or fee is 0 or treasury not configured + token_client.transfer( + &env.current_contract_address(), + &job.freelancer, + &amount, + ); + } } #[cfg(test)] @@ -2065,7 +2153,9 @@ mod test { cc.create_job(&1u64, &client, &freelancer, &token_addr); cc.add_milestone(&1u64, &1000i128); - env.storage().instance().set(&DataKey::Locked, &()); + env.as_contract(&contract_id, || { + env.storage().instance().set(&DataKey::Locked, &()); + }); cc.deposit(&1u64, &1000i128); } @@ -2091,12 +2181,14 @@ mod test { cc.add_milestone(&1u64, &1000i128); cc.deposit(&1u64, &1000i128); - env.storage().instance().set(&DataKey::Locked, &()); + env.as_contract(&contract_id, || { + env.storage().instance().set(&DataKey::Locked, &()); + }); cc.release_milestone(&1u64, &client); } #[test] - #[should_panic(expected = "job already exists")] + #[should_panic(expected = "Error(Contract, #4)")] fn test_double_create_job_panics() { let env = Env::default(); env.mock_all_auths(); @@ -2188,9 +2280,9 @@ mod test { assert_eq!(job.status, EscrowStatus::Disputed); } - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // Comprehensive Escrow Deposit & Milestone Release Tests (>90% coverage) - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ #[test] fn test_deposit_success_transitions_to_funded() { @@ -2464,71 +2556,6 @@ mod test { cc.release_funds(&1u64, &client, &5u32); } - #[test] - #[should_panic(expected = "milestone amount exceeds maximum")] - fn test_add_milestone_over_max_panics() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let agent_judge = Address::generate(&env); - let client = Address::generate(&env); - let freelancer = Address::generate(&env); - - let token_addr = setup_token(&env, &admin); - mint(&env, &token_addr, &client); - - let contract_id = env.register_contract(None, EscrowContract); - let cc = EscrowContractClient::new(&env, &contract_id); - - cc.initialize(&admin, &agent_judge); - cc.create_job(&1u64, &client, &freelancer, &token_addr); - - cc.add_milestone(&1u64, &(EscrowContract::MAX_MILESTONE_AMOUNT + 1)); - } - - #[test] - #[should_panic(expected = "too many milestones")] - fn test_add_milestone_limit_panics() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let agent_judge = Address::generate(&env); - let client = Address::generate(&env); - let freelancer = Address::generate(&env); - - let token_addr = setup_token(&env, &admin); - mint(&env, &token_addr, &client); - - let contract_id = env.register_contract(None, EscrowContract); - let cc = EscrowContractClient::new(&env, &contract_id); - - cc.initialize(&admin, &agent_judge); - cc.create_job(&1u64, &client, &freelancer, &token_addr); - - for _ in 0..EscrowContract::MAX_MILESTONES_PER_JOB { - cc.add_milestone(&1u64, &250i128); - } - cc.add_milestone(&1u64, &250i128); - } - - #[test] - #[should_panic(expected = "job already exists")] - fn test_double_create_job_panics() { - let env = Env::default(); - env.mock_all_auths(); - - let client = Address::generate(&env); - let freelancer = Address::generate(&env); - let token_addr = Address::generate(&env); - - let contract_id = env.register_contract(None, EscrowContract); - let cc = EscrowContractClient::new(&env, &contract_id); - - cc.create_job(&1u64, &client, &freelancer, &token_addr); - cc.create_job(&1u64, &client, &freelancer, &token_addr); - } #[test] #[should_panic(expected = "Error(Contract, #6)")] @@ -2637,9 +2664,9 @@ mod test { cc.release_milestone(&1u64, &client); } - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // Comprehensive Escrow Dispute & Resolution Tests (>90% coverage) - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ #[test] fn test_raise_dispute_by_freelancer_locks_funds() { @@ -3037,9 +3064,9 @@ mod test { assert_eq!(job.released_amount, 0); } - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-005: Token Decimals Compatibility - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ #[test] fn test_token_decimals_stored_on_deposit() { @@ -3066,9 +3093,9 @@ mod test { assert_eq!(cc.get_token_decimals(&1u64), 7); } - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-007: Instance Storage Optimisation - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ #[test] fn test_instance_config_getters() { @@ -3105,9 +3132,9 @@ mod test { assert_eq!(cc.get_admin(), admin); } - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-008: Double-Spending Prevention - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ #[test] #[should_panic(expected = "Error(Contract, #6)")] @@ -3257,9 +3284,9 @@ mod test { cc.refund(&1u64, &client); } - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ // SC-ESC-009: Dispute Timeout Enforcement - // ───────────────────────────────────────────────────────────────────────── + // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ #[test] fn test_dispute_deadline_set_on_raise() { diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index 3805f29e..6a2d60e4 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -2,9 +2,10 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, log, panic_with_error, symbol_short, - Address, Bytes, Env, Vec, + token, Address, Bytes, Env, Vec, }; +#[allow(dead_code)] const MAX_HASH_LEN: u32 = 96; // Requirement [SC-REG-037]: Contract-wide budget floor and ceiling enforced at input validation. @@ -58,9 +59,15 @@ pub struct JobRecord { pub budget_stroops: i128, pub expires_at: u64, pub status: JobStatus, - pub bidding_deadline: u64, + pub bid_deadline: u64, + pub collateral_token: Address, + pub collateral_amount: i128, + pub collateral_locked: bool, } +// Requirement [SC-REG-036]: Storage Packing for Bid Struct Instance Allocations. +// Groups `freelancer` address, `proposal_hash` (IPFS CID), and bid collateral fields +// into a single packed struct to minimize Soroban ledger footprint and reduce storage charges. #[contracttype] #[derive(Clone)] pub struct BidRecord { @@ -84,6 +91,9 @@ pub struct JobRegistryContract; #[contractimpl] impl JobRegistryContract { + /// One-time storage bootstrap. + /// + /// Sets contract admin and initializes `next_job_id` to 1. pub fn initialize(env: Env, admin: Address) { if env.storage().instance().has(&DataKey::Admin) { panic_with_error!(&env, JobRegistryError::AlreadyInitialized); @@ -109,14 +119,17 @@ impl JobRegistryContract { read_next_job_id(&env) } + /// Client posts a job with explicit `job_id` and collateral lockup details. pub fn post_job( env: Env, job_id: u64, client: Address, hash: Bytes, budget: i128, - bidding_deadline: u64, expires_at: u64, + bid_deadline: u64, + collateral_token: Address, + collateral_amount: i128, ) { ensure_initialized(&env); @@ -125,10 +138,14 @@ impl JobRegistryContract { job_id, &hash, budget, - bidding_deadline, expires_at, + bid_deadline, ); + if collateral_amount < 0 { + panic_with_error!(&env, JobRegistryError::InvalidBudget); + } + client.require_auth(); post_job_with_id( @@ -137,10 +154,18 @@ impl JobRegistryContract { client.clone(), hash, budget, - bidding_deadline, expires_at, + bid_deadline, + collateral_token.clone(), + collateral_amount, ); + // Lock collateral from client into this contract + if collateral_amount > 0 { + let token_client = token::Client::new(&env, &collateral_token); + token_client.transfer(&client, &env.current_contract_address(), &collateral_amount); + } + let next_job_id = read_next_job_id(&env); if job_id >= next_job_id { @@ -157,13 +182,16 @@ impl JobRegistryContract { .publish((symbol_short!("jobpost"), job_id), client); } + /// Client posts a job using internal registry index allocation and collateral lockup details. pub fn post_job_auto( env: Env, client: Address, hash: Bytes, budget: i128, - bidding_deadline: u64, expires_at: u64, + bid_deadline: u64, + collateral_token: Address, + collateral_amount: i128, ) -> u64 { ensure_initialized(&env); @@ -174,10 +202,14 @@ impl JobRegistryContract { job_id, &hash, budget, - bidding_deadline, expires_at, + bid_deadline, ); + if collateral_amount < 0 { + panic_with_error!(&env, JobRegistryError::InvalidBudget); + } + client.require_auth(); post_job_with_id( @@ -186,10 +218,18 @@ impl JobRegistryContract { client.clone(), hash, budget, - bidding_deadline, expires_at, + bid_deadline, + collateral_token.clone(), + collateral_amount, ); + // Lock collateral from client into this contract + if collateral_amount > 0 { + let token_client = token::Client::new(&env, &collateral_token); + token_client.transfer(&client, &env.current_contract_address(), &collateral_amount); + } + let next = job_id .checked_add(1) .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow)); @@ -199,6 +239,7 @@ impl JobRegistryContract { job_id } + /// Freelancer submits a bid, with optionally provided freelancer collateral. pub fn submit_bid( env: Env, job_id: u64, @@ -224,7 +265,7 @@ impl JobRegistryContract { panic_with_error!(&env, JobRegistryError::JobNotOpen); } - if env.ledger().timestamp() > job.bidding_deadline { + if env.ledger().timestamp() > job.bid_deadline { panic_with_error!(&env, JobRegistryError::BidWindowClosed); } @@ -244,6 +285,7 @@ impl JobRegistryContract { .get(&bids_key) .unwrap_or(Vec::new(&env)); + // Requirement [SC-REG-035]: Enforce strict single-bid constraint per freelancer on active jobs. for bid in bids.iter() { if bid.freelancer == freelancer { panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted); @@ -263,6 +305,7 @@ impl JobRegistryContract { .publish((symbol_short!("bid"), job_id), freelancer); } + /// Client accepts a bid, locking in the freelancer. pub fn accept_bid( env: Env, job_id: u64, @@ -356,6 +399,84 @@ impl JobRegistryContract { release_collateral(&env, job_id, freelancer, true); } + /// Client completes a job, releasing locked client collateral to the freelancer. + pub fn complete_job(env: Env, job_id: u64, client: Address) { + ensure_initialized(&env); + client.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: JobRecord = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); + + if client != job.client { + panic_with_error!(&env, JobRegistryError::Unauthorized); + } + + if job.status != JobStatus::DeliverableSubmitted { + panic_with_error!(&env, JobRegistryError::InvalidStateTransition); + } + + job.status = JobStatus::Completed; + + if job.collateral_locked && job.collateral_amount > 0 { + if let Some(ref freelancer) = job.freelancer { + let token_client = token::Client::new(&env, &job.collateral_token); + token_client.transfer( + &env.current_contract_address(), + freelancer, + &job.collateral_amount, + ); + job.collateral_locked = false; + } + } + + env.storage().persistent().set(&key, &job); + + log!(&env, "complete_job: id {}", job_id); + env.events().publish((symbol_short!("complete"), job_id), ()); + } + + /// Client refunds their locked collateral if the job has expired without an accepted bid. + pub fn refund_collateral(env: Env, job_id: u64, client: Address) { + ensure_initialized(&env); + client.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: JobRecord = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); + + if client != job.client { + panic_with_error!(&env, JobRegistryError::Unauthorized); + } + + let now = env.ledger().timestamp(); + if job.status != JobStatus::Open || now <= job.bid_deadline { + panic_with_error!(&env, JobRegistryError::InvalidStateTransition); + } + + if job.collateral_locked && job.collateral_amount > 0 { + let token_client = token::Client::new(&env, &job.collateral_token); + token_client.transfer( + &env.current_contract_address(), + &job.client, + &job.collateral_amount, + ); + job.collateral_locked = false; + } + + env.storage().persistent().set(&key, &job); + + log!(&env, "refund_collateral: id {}", job_id); + env.events().publish((symbol_short!("refund"), job_id), ()); + } + + /// Client cancels an expired open job, returning client collateral and deleting bids list. pub fn cancel_expired_job( env: Env, job_id: u64, @@ -387,7 +508,19 @@ impl JobRegistryContract { job.status = JobStatus::Expired; + // Refund collateral if locked + if job.collateral_locked && job.collateral_amount > 0 { + let token_client = token::Client::new(&env, &job.collateral_token); + token_client.transfer( + &env.current_contract_address(), + &job.client, + &job.collateral_amount, + ); + job.collateral_locked = false; + } + env.storage().persistent().set(&key, &job); + env.storage().persistent().remove(&DataKey::Bids(job_id)); env.events() .publish((symbol_short!("expired"), job_id), client); @@ -545,8 +678,8 @@ fn validate_job_input( job_id: u64, hash: &Bytes, budget: i128, - bidding_deadline: u64, expires_at: u64, + bid_deadline: u64, ) { if job_id == 0 { panic_with_error!(env, JobRegistryError::InvalidJobId); @@ -557,11 +690,11 @@ fn validate_job_input( panic_with_error!(env, JobRegistryError::InvalidBudget); } - if bidding_deadline <= env.ledger().timestamp() { + if bid_deadline <= env.ledger().timestamp() { panic_with_error!(env, JobRegistryError::BidWindowClosed); } - if bidding_deadline >= expires_at { + if bid_deadline >= expires_at { panic_with_error!(env, JobRegistryError::InvalidExpiration); } @@ -578,21 +711,58 @@ fn validate_expiration(env: &Env, expires_at: u64) { } fn validate_hash(env: &Env, hash: &Bytes) { - let len = hash.len(); + validate_ipfs_cid(env, hash); +} - if len == 0 || len > MAX_HASH_LEN { +fn validate_ipfs_cid(env: &Env, hash: &Bytes) { + let len = hash.len(); + if len == 46 { + // Must be CIDv0 (Qm...) + let mut buf = [0u8; 46]; + hash.copy_into_slice(&mut buf); + if buf[0] != b'Q' || buf[1] != b'm' { + panic_with_error!(env, JobRegistryError::InvalidHash); + } + for i in 2..46 { + if !is_valid_base58_char(buf[i]) { + panic_with_error!(env, JobRegistryError::InvalidHash); + } + } + } else if len == 59 { + // Must be CIDv1 (bafy...) + let mut buf = [0u8; 59]; + hash.copy_into_slice(&mut buf); + if buf[0] != b'b' || buf[1] != b'a' || buf[2] != b'f' || buf[3] != b'y' { + panic_with_error!(env, JobRegistryError::InvalidHash); + } + for i in 4..59 { + if !is_valid_base32_char(buf[i]) { + panic_with_error!(env, JobRegistryError::InvalidHash); + } + } + } else { panic_with_error!(env, JobRegistryError::InvalidHash); } } +fn is_valid_base58_char(c: u8) -> bool { + matches!(c, b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z') +} + +fn is_valid_base32_char(c: u8) -> bool { + matches!(c, b'a'..=b'z' | b'2'..=b'7') +} + fn post_job_with_id( env: &Env, job_id: u64, client: Address, hash: Bytes, budget: i128, - bidding_deadline: u64, expires_at: u64, + bid_deadline: u64, + collateral_token: Address, + collateral_amount: i128, ) { let key = DataKey::Job(job_id); @@ -607,7 +777,10 @@ fn post_job_with_id( budget_stroops: budget, expires_at, status: JobStatus::Open, - bidding_deadline, + bid_deadline, + collateral_token, + collateral_amount, + collateral_locked: collateral_amount > 0, }; env.storage().persistent().set(&key, &job); @@ -619,6 +792,50 @@ fn post_job_with_id( .set(&DataKey::Bids(job_id), &bids); } +fn release_collateral(env: &Env, job_id: u64, freelancer: Address, slash: bool) { + let bids_key = DataKey::Bids(job_id); + let mut bids: Vec = env + .storage() + .persistent() + .get(&bids_key) + .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::BidNotFound)); + + let mut updated = false; + for i in 0..bids.len() { + let mut bid = bids.get(i).unwrap(); + if bid.freelancer == freelancer { + if bid.collateral_released { + panic_with_error!( + env, + JobRegistryError::CollateralAlreadyReleased + ); + } + bid.collateral_released = true; + bids.set(i, bid); + updated = true; + break; + } + } + + if !updated { + panic_with_error!(env, JobRegistryError::BidNotFound); + } + + env.storage().persistent().set(&bids_key, &bids); + + if slash { + env.events().publish( + (symbol_short!("slash"), job_id), + freelancer, + ); + } else { + env.events().publish( + (symbol_short!("release"), job_id), + freelancer, + ); + } +} + #[cfg(test)] mod test { use super::*; @@ -631,6 +848,7 @@ mod test { Address, Address, Address, + Address, // Mock Token ) { let env = Env::default(); env.mock_all_auths(); @@ -639,19 +857,23 @@ mod test { let client = Address::generate(&env); let freelancer = Address::generate(&env); + let token_addr = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let token_client = token::StellarAssetClient::new(&env, &token_addr); + token_client.mint(&client, &100_000); + let contract_id = env.register_contract(None, JobRegistryContract); let cc = JobRegistryContractClient::new(&env, &contract_id); - (env, cc, admin, client, freelancer) + (env, cc, admin, client, freelancer, token_addr) } fn future_expires_at(env: &Env) -> u64 { - env.ledger().timestamp() + 60 + env.ledger().timestamp() + 30 * 24 * 60 * 60 } #[test] fn test_initialize_bootstraps_storage() { - let (_env, cc, admin, _, _) = setup(); + let (_env, cc, admin, _, _, _) = setup(); cc.initialize(&admin); @@ -663,7 +885,7 @@ mod test { #[test] #[should_panic] fn test_double_initialize_panics() { - let (_env, cc, admin, _, _) = setup(); + let (_env, cc, admin, _, _, _) = setup(); cc.initialize(&admin); cc.initialize(&admin); @@ -672,24 +894,26 @@ mod test { #[test] #[should_panic] fn test_post_job_before_initialize_panics() { - let (env, cc, _admin, client, _) = setup(); - let hash = Bytes::from_slice(&env, b"QmHash"); + let (env, cc, _admin, client, _, token_addr) = setup(); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &2000u64, &token_addr, &1000i128); } #[test] fn test_post_job_auto_allocates_sequential_ids() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash1 = Bytes::from_slice(&env, b"QmHash1"); - let hash2 = Bytes::from_slice(&env, b"QmHash2"); + let hash1 = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + let hash2 = Bytes::from_slice(&env, b"QmY4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9e"); + + env.ledger().set_timestamp(100); let expires_at1 = future_expires_at(&env); let expires_at2 = future_expires_at(&env); - let id1 = cc.post_job_auto(&client, &hash1, &MIN_BUDGET_STROOPS, &expires_at1); - let id2 = cc.post_job_auto(&client, &hash2, &MIN_BUDGET_STROOPS, &expires_at2); + let id1 = cc.post_job_auto(&client, &hash1, &MIN_BUDGET_STROOPS, &expires_at1, &1000u64, &token_addr, &1000i128); + let id2 = cc.post_job_auto(&client, &hash2, &MIN_BUDGET_STROOPS, &expires_at2, &2000u64, &token_addr, &2000i128); assert_eq!(id1, 1u64); assert_eq!(id2, 2u64); @@ -698,12 +922,13 @@ mod test { #[test] fn test_post_job_with_explicit_id_updates_next_job_id() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&42u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&42u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); assert_eq!(cc.get_next_job_id(), 43u64); } @@ -711,40 +936,47 @@ mod test { #[test] #[should_panic] fn test_invalid_budget_panics() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &0i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &0i128, &expires_at, &1000u64, &token_addr, &1000i128); } #[test] #[should_panic] fn test_empty_hash_panics() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); let empty = Bytes::from_slice(&env, b""); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &empty, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &empty, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); } #[test] fn test_full_lifecycle() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmSomeIPFSHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); + + let tc = token::Client::new(&env, &token_addr); + assert_eq!(tc.balance(&cc.address), 1000); let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::Open); assert_eq!(job.freelancer, None); + assert!(job.collateral_locked); - let proposal = Bytes::from_slice(&env, b"QmProposalHash"); - cc.submit_bid(&1u64, &freelancer, &proposal); + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); let bids = cc.get_bids(&1u64); assert_eq!(bids.len(), 1); @@ -754,7 +986,7 @@ mod test { assert_eq!(job.status, JobStatus::Assigned); assert_eq!(job.freelancer, Some(freelancer.clone())); - let deliverable = Bytes::from_slice(&env, b"QmDeliverableHash"); + let deliverable = Bytes::from_slice(&env, b"QmDummyHash22222222222123456789212345678921234"); cc.submit_deliverable(&1u64, &freelancer, &deliverable); let job = cc.get_job(&1u64); @@ -762,47 +994,56 @@ mod test { let d = cc.get_deliverable(&1u64); assert_eq!(d, deliverable); + + cc.complete_job(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, JobStatus::Completed); + assert!(!job.collateral_locked); + assert_eq!(tc.balance(&freelancer), 1000); } #[test] #[should_panic] fn test_duplicate_bid_panics() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); - cc.submit_bid(&1u64, &freelancer, &proposal); + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); + cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); } #[test] #[should_panic] fn test_accept_without_matching_bid_panics() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); cc.accept_bid(&1u64, &client, &freelancer); } #[test] fn test_mark_disputed_from_assigned() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); cc.accept_bid(&1u64, &client, &freelancer); cc.mark_disputed(&1u64); @@ -813,97 +1054,154 @@ mod test { #[test] #[should_panic] fn test_mark_disputed_from_open_panics() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); cc.mark_disputed(&1u64); } #[test] #[should_panic] - fn test_submit_bid_after_expiration_panics() { - let (env, cc, admin, client, freelancer) = setup(); + fn test_get_deliverable_without_submission_panics() { + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); - env.ledger().set_timestamp(expires_at + 1); + cc.get_deliverable(&1u64); + } + + #[test] + #[should_panic] + fn test_late_bid_submission_panics() { + let (env, cc, admin, client, freelancer, token_addr) = setup(); + cc.initialize(&admin); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); + + env.ledger().set_timestamp(1001); // past the deadline of 1000 + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); } - let mut bids: Vec = env - .storage() - .persistent() - .get(&bids_key) - .unwrap_or(Vec::new(env)); + #[test] + fn test_refund_collateral_after_deadline() { + let (env, cc, admin, client, _, token_addr) = setup(); + cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); - for i in 0..bids.len() { - let mut bid = bids.get(i).unwrap(); + let tc = token::Client::new(&env, &token_addr); + assert_eq!(tc.balance(&client), 99000); - if bid.freelancer == freelancer { - if bid.collateral_released { - panic_with_error!( - env, - JobRegistryError::CollateralAlreadyReleased - ); - } + env.ledger().set_timestamp(1001); // past the deadline of 1000 + cc.refund_collateral(&1u64, &client); - bid.collateral_released = true; + let job = cc.get_job(&1u64); + assert!(!job.collateral_locked); + assert_eq!(tc.balance(&client), 100000); + } - let hash = Bytes::from_slice(&env, b"QmHash"); + #[test] + fn test_valid_cidv1_posting() { + let (env, cc, admin, client, _, token_addr) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"bafybeigdyrzt5sbi7ee3xjc3vyqptsyfuwwspw2gx6pqdfaaaaabbbbb"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); - updated = true; + let job = cc.get_job(&1u64); + assert_eq!(job.metadata_hash, hash); + } - break; - } + #[test] + #[should_panic] + fn test_invalid_cid_length_panics() { + let (env, cc, admin, client, _, token_addr) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f123"); + env.ledger().set_timestamp(100); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); } #[test] #[should_panic] - fn test_cancel_expired_job_before_expiration_panics() { - let (env, cc, admin, client, _) = setup(); + fn test_invalid_cidv0_prefix_panics() { + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QxZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); + } + + #[test] + #[should_panic] + fn test_invalid_cidv1_prefix_panics() { + let (env, cc, admin, client, _, token_addr) = setup(); + cc.initialize(&admin); - cc.cancel_expired_job(&1u64, &client); + let hash = Bytes::from_slice(&env, b"bafxbeigdyrzt5sbi7ee3xjc3vyqptsyfuwwspw2gx6pqdfaaaaabbbbbccccc"); + env.ledger().set_timestamp(100); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); } - env.storage().persistent().set(&bids_key, &bids); + #[test] + #[should_panic] + fn test_invalid_cidv0_chars_panics() { + let (env, cc, admin, client, _, token_addr) = setup(); + cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + // '0' is invalid in base58 + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a0f"); + env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); + } - cc.get_deliverable(&1u64); + #[test] + #[should_panic] + fn test_invalid_cidv1_chars_panics() { + let (env, cc, admin, client, _, token_addr) = setup(); + cc.initialize(&admin); + + // '0' is invalid in base32 + let hash = Bytes::from_slice(&env, b"bafybeigdyrzt5sbi7ee3xjc3vyqptsyfuwwspw2gx6pqdfaaaaabbbbbcccc0"); + env.ledger().set_timestamp(100); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); } // --- SC-REG-037: Budget Bounds Tests --- #[test] fn test_budget_at_minimum_succeeds() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &0i128); let job = cc.get_job(&1u64); assert_eq!(job.budget_stroops, MIN_BUDGET_STROOPS); @@ -911,12 +1209,12 @@ mod test { #[test] fn test_budget_at_maximum_succeeds() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MAX_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MAX_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &0i128); let job = cc.get_job(&1u64); assert_eq!(job.budget_stroops, MAX_BUDGET_STROOPS); @@ -925,63 +1223,52 @@ mod test { #[test] #[should_panic] fn test_budget_below_minimum_panics() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &(MIN_BUDGET_STROOPS - 1), &expires_at); + cc.post_job(&1u64, &client, &hash, &(MIN_BUDGET_STROOPS - 1), &expires_at, &1000u64, &token_addr, &0i128); } #[test] #[should_panic] fn test_budget_above_maximum_panics() { - let (env, cc, admin, client, _) = setup(); - cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); - let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &(MAX_BUDGET_STROOPS + 1), &expires_at); - } - - #[test] - #[should_panic] - fn test_zero_budget_still_panics() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &0i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &(MAX_BUDGET_STROOPS + 1), &expires_at, &1000u64, &token_addr, &0i128); } // --- SC-REG-039: Paginated Bids Tests --- #[test] fn test_get_bids_count_empty_returns_zero() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &0i128); assert_eq!(cc.get_bids_count(&1u64), 0u32); } #[test] fn test_get_bids_count_after_submissions() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &0i128); for _ in 0..3u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); } assert_eq!(cc.get_bids_count(&1u64), 3u32); @@ -989,17 +1276,17 @@ mod test { #[test] fn test_get_bids_page_first_window() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &0i128); for _ in 0..5u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); } let page = cc.get_bids_page(&1u64, &0u32, &3u32); @@ -1008,17 +1295,17 @@ mod test { #[test] fn test_get_bids_page_second_window() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &0i128); for _ in 0..5u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); } let page = cc.get_bids_page(&1u64, &3u32, &3u32); @@ -1027,17 +1314,17 @@ mod test { #[test] fn test_get_bids_page_offset_beyond_end_returns_empty() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &0i128); for _ in 0..3u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + let proposal = Bytes::from_slice(&env, b"QmDummyHash11111111123456789212345678921234567"); + cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); } let page = cc.get_bids_page(&1u64, &10u32, &5u32); diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 56afcc04..9b4cabcc 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, Bytes, BytesN, Env, IntoVal, @@ -16,10 +16,11 @@ use profile::{Profile, RoleMetrics}; #[derive(Clone, Debug, PartialEq)] pub enum JobStatus { Open, - InProgress, + Assigned, DeliverableSubmitted, Completed, Disputed, + Expired, } #[contracttype] @@ -29,7 +30,12 @@ pub struct JobRecord { pub freelancer: Option
, pub metadata_hash: Bytes, pub budget_stroops: i128, + pub expires_at: u64, pub status: JobStatus, + pub bid_deadline: u64, + pub collateral_token: Address, + pub collateral_amount: i128, + pub collateral_locked: bool, } #[contracttype] @@ -44,7 +50,6 @@ pub enum Role { pub struct ReputationScore { pub address: Address, pub role: Role, - /// Score in basis points (0\u201310000 = 0\u2013100%) pub score: i32, pub total_jobs: u32, pub total_points: i128, @@ -220,7 +225,6 @@ impl ReputationContract { } } - fn score_from_profile(address: &Address, role: Role, profile: &profile::Profile) -> ReputationScore { fn score_from_profile( address: &Address, role: Role, @@ -472,19 +476,6 @@ impl ReputationContract { Self::require_authorized_contract(&env, &caller_contract); let mut profile = storage::read_profile_or_default(&env, &address); - let (new_score, total_jobs) = match role { - Role::Client => { - profile.client_score = Self::clamp_score(profile.client_score.saturating_add(delta)); - profile.client_jobs = profile.client_jobs.saturating_add(1); - (profile.client_score, profile.client_jobs) - } - Role::Freelancer => { - profile.freelancer_score = - Self::clamp_score(profile.freelancer_score.saturating_add(delta)); - profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); - (profile.freelancer_score, profile.freelancer_jobs) - } - }; if profile.is_blacklisted { soroban_sdk::panic_with_error!(&env, ReputationError::Blacklisted); } @@ -586,6 +577,71 @@ impl ReputationContract { .unwrap_or(false) } + /// Return the current badge level for an address/role pair. + pub fn get_badge(env: Env, address: Address, role: Role) -> BadgeLevel { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + match role { + Role::Client => profile.client_badge, + Role::Freelancer => profile.freelancer_badge, + } + } + + /// Admin-only: set the decentralised-storage URI for a badge tier. + /// `uri` is typically an IPFS CID pointing to the badge image/JSON. + pub fn set_badge_metadata( + env: Env, + admin: Address, + address: Address, + tier: BadgeTier, + uri: Bytes, + ) { + let configured_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + assert!(admin == configured_admin, "unauthorized"); + + let mut profile = storage::read_profile_or_default(&env, &address); + + // Replace existing entry for this tier or push a new one. + let mut found = false; + let len = profile.badge_metadata.len(); + for i in 0..len { + let entry = profile.badge_metadata.get(i).unwrap(); + if entry.tier == tier { + profile.badge_metadata.set(i, BadgeMetadataEntry { tier: tier.clone(), uri: uri.clone() }); + found = true; + break; + } + } + if !found { + profile.badge_metadata.push_back(BadgeMetadataEntry { tier, uri }); + } + + storage::write_profile(&env, &address, &profile); + Self::bump_instance_ttl(&env); + } + + /// Return the metadata URI for a given badge tier, or `None` if not set. + pub fn get_badge_metadata( + env: Env, + address: Address, + tier: BadgeTier, + ) -> Option { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + for i in 0..profile.badge_metadata.len() { + let entry = profile.badge_metadata.get(i).unwrap(); + if entry.tier == tier { + return Some(entry.uri); + } + } + None + } + pub fn get_score(env: Env, address: Address, role: Role) -> ReputationScore { Self::bump_instance_ttl(&env); let profile = storage::read_profile_or_default(&env, &address); @@ -657,9 +713,6 @@ mod test { #[contractimpl] impl MockJobRegistry { pub fn set_job(env: Env, job_id: u64, job: JobRecord) { - env.storage() - .persistent() - .set(&MockKey::Job(job_id), &job); env.storage().persistent().set(&MockKey::Job(job_id), &job); } @@ -708,7 +761,12 @@ mod test { freelancer: Some(freelancer.clone()), metadata_hash: Bytes::from_slice(env, b"QmJob"), budget_stroops: 10, + expires_at: 0, status: JobStatus::Completed, + bid_deadline: 0, + collateral_token: Address::generate(env), + collateral_amount: 0, + collateral_locked: false, }; let registry_client = MockJobRegistryClient::new(env, registry); registry_client.set_job(&job_id, &job); @@ -977,101 +1035,145 @@ mod test { assert_eq!(saved_hash, Some(hash)); } - // --- SC-REP-050: Contract-to-Contract Auth Gating Tests --- + // ΓöÇΓöÇ Issue #402: badge minting ΓöÇΓöÇ #[test] - #[should_panic(expected = "Error(Contract, #2)")] - fn test_unauthorized_contract_update_score_is_rejected() { + fn test_badge_starts_at_bronze_for_default_score() { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); - let reputation_id = env.register_contract(None, ReputationContract); - let authorized_id = env.register_contract(None, AuthorizedAdjuster); - let unauthorized_id = env.register_contract(None, AuthorizedAdjuster); - let target = Address::generate(&env); - let client = ReputationContractClient::new(&env, &reputation_id); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + // Default score is 5000 ΓåÆ Bronze + let badge = client.get_badge(&addr, &Role::Freelancer); + assert_eq!(badge, BadgeLevel::Bronze); + } + #[test] + fn test_badge_upgrades_to_silver_at_6000() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); client.initialize(&admin); - // Only `authorized_id` is registered; `unauthorized_id` must be rejected. - client.set_authorized_contract(&admin, &authorized_id); + client.set_authorized_contract(&admin, &admin); - let unauthorized_client = AuthorizedAdjusterClient::new(&env, &unauthorized_id); - unauthorized_client.award(&reputation_id, &target, &Role::Freelancer, &500); + // Raise score by 1000 ΓåÆ 5000+1000 = 6000 ΓåÆ Silver + client.update_score(&admin, &addr, &Role::Freelancer, &1000); + let badge = client.get_badge(&addr, &Role::Freelancer); + assert_eq!(badge, BadgeLevel::Silver); } #[test] - fn test_authorized_contract_can_be_replaced_by_admin() { + fn test_badge_upgrades_to_gold_at_8000() { let env = Env::default(); env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + client.set_authorized_contract(&admin, &admin); + + client.update_score(&admin, &addr, &Role::Freelancer, &3000); // 5000+3000=8000 + assert_eq!(client.get_badge(&addr, &Role::Freelancer), BadgeLevel::Gold); + } + #[test] + fn test_slash_downgrades_badge() { + let env = Env::default(); + env.mock_all_auths(); let admin = Address::generate(&env); - let target = Address::generate(&env); - let reputation_id = env.register_contract(None, ReputationContract); - let old_adjuster_id = env.register_contract(None, AuthorizedAdjuster); - let new_adjuster_id = env.register_contract(None, AuthorizedAdjuster); - let client = ReputationContractClient::new(&env, &reputation_id); - let new_adjuster = AuthorizedAdjusterClient::new(&env, &new_adjuster_id); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + client.set_authorized_contract(&admin, &admin); + + // Bring to Gold first, then slash twice to drop back to Bronze + client.update_score(&admin, &addr, &Role::Client, &3000); // 8000 ΓåÆ Gold + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Gold); + client.slash(&admin, &addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 6000 ΓåÆ Silver + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Silver); + client.slash(&admin, &addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 4000 ΓåÆ Bronze + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Bronze); + } + // ΓöÇΓöÇ Issue #406: badge metadata mapping ΓöÇΓöÇ + + #[test] + fn test_set_and_get_badge_metadata() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); client.initialize(&admin); - client.set_authorized_contract(&admin, &old_adjuster_id); - // Admin rotates the authorized contract to a new address. - client.set_authorized_contract(&admin, &new_adjuster_id); + let uri = Bytes::from_slice(&env, b"ipfs://QmBronzeBadge"); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Bronze, &uri); - // New authorized contract can modify scores. - new_adjuster.award(&reputation_id, &target, &Role::Client, &2_000); - let score = client.get_score(&target, &Role::Client); - assert_eq!(score.score, 7_000); + let result = client.get_badge_metadata(&addr, &BadgeTier::Bronze); + assert_eq!(result, Some(uri)); } #[test] - #[should_panic(expected = "Error(Contract, #2)")] - fn test_slash_requires_authorized_contract() { + fn test_badge_metadata_returns_none_when_unset() { let env = Env::default(); env.mock_all_auths(); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); - let admin = Address::generate(&env); - let target = Address::generate(&env); - let reputation_id = env.register_contract(None, ReputationContract); - let authorized_id = env.register_contract(None, AuthorizedAdjuster); - let rogue_id = env.register_contract(None, AuthorizedAdjuster); - let client = ReputationContractClient::new(&env, &reputation_id); - let rogue = AuthorizedAdjusterClient::new(&env, &rogue_id); + let result = client.get_badge_metadata(&addr, &BadgeTier::Gold); + assert_eq!(result, None); + } + #[test] + fn test_badge_metadata_update_overwrites_existing() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); client.initialize(&admin); - client.set_authorized_contract(&admin, &authorized_id); - rogue.slash( - &reputation_id, - &target, - &Role::Freelancer, - &Symbol::new(&env, "fraud"), - ); + let uri_v1 = Bytes::from_slice(&env, b"ipfs://QmSilverV1"); + let uri_v2 = Bytes::from_slice(&env, b"ipfs://QmSilverV2"); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Silver, &uri_v1); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Silver, &uri_v2); + + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Silver), Some(uri_v2)); } #[test] - #[should_panic(expected = "Error(Contract, #2)")] - fn test_blacklist_requires_authorized_contract() { + fn test_multiple_tiers_stored_independently() { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); - let target = Address::generate(&env); - let reputation_id = env.register_contract(None, ReputationContract); - let authorized_id = env.register_contract(None, AuthorizedAdjuster); - let rogue_id = env.register_contract(None, AuthorizedAdjuster); - let client = ReputationContractClient::new(&env, &reputation_id); - let rogue = AuthorizedAdjusterClient::new(&env, &rogue_id); - + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); client.initialize(&admin); - client.set_authorized_contract(&admin, &authorized_id); - rogue.blacklist(&reputation_id, &target, &Symbol::new(&env, "fraud")); + let bronze_uri = Bytes::from_slice(&env, b"ipfs://Bronze"); + let gold_uri = Bytes::from_slice(&env, b"ipfs://Gold"); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Bronze, &bronze_uri); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Gold, &gold_uri); + + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Bronze), Some(bronze_uri)); + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Gold), Some(gold_uri)); + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Silver), None); } #[test] - #[should_panic(expected = "Error(Contract, #2)")] #[should_panic(expected = "Error(Contract, #2)")] fn test_upgrade_requires_admin() { let env = Env::default(); @@ -1086,4 +1188,4 @@ mod test { let wasm_hash = BytesN::from_array(&env, &[0; 32]); client.upgrade(&attacker, &wasm_hash); } -} \ No newline at end of file +} diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs index 271ed90a..77e1dfef 100644 --- a/contracts/reputation/src/profile.rs +++ b/contracts/reputation/src/profile.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Bytes, Env}; +use soroban_sdk::{contracttype, Address, Bytes, Env}; #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -39,13 +39,13 @@ impl RoleMetrics { } /// Badge tier awarded based on cumulative score thresholds. -/// Scores are in basis points (0–10 000). +/// Scores are in basis points (0ΓÇô10 000). /// /// Thresholds: -/// Bronze ≥ 4 000 -/// Silver ≥ 6 000 -/// Gold ≥ 8 000 -/// Platinum ≥ 9 500 +/// Bronze ΓëÑ 4 000 +/// Silver ΓëÑ 6 000 +/// Gold ΓëÑ 8 000 +/// Platinum ΓëÑ 9 500 #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] pub enum BadgeLevel { @@ -96,10 +96,12 @@ pub struct Profile { pub metadata_hash: Option, /// Per-tier badge metadata URIs set by the admin. pub badge_metadata: soroban_sdk::Vec, + pub client_badge: BadgeLevel, + pub freelancer_badge: BadgeLevel, } impl Profile { - pub fn new(address: Address, env: &Env) -> Self { + pub fn new(env: &Env, address: Address) -> Self { Self { address, client: RoleMetrics::new(), @@ -107,25 +109,13 @@ impl Profile { is_blacklisted: false, metadata_hash: None, badge_metadata: soroban_sdk::Vec::new(env), + client_badge: BadgeLevel::Bronze, + freelancer_badge: BadgeLevel::Bronze, } } pub fn refresh_badges(&mut self) { - self.client.badge_level = Self::compute_badge(&self.client, self.is_blacklisted); - self.freelancer.badge_level = Self::compute_badge(&self.freelancer, self.is_blacklisted); - } - - fn compute_badge(metrics: &RoleMetrics, is_blacklisted: bool) -> u32 { - if is_blacklisted { - 0 - } else if metrics.completed_jobs >= 5 && metrics.score >= 9_500 { - 3 - } else if metrics.completed_jobs >= 3 && metrics.score >= 8_500 { - 2 - } else if metrics.completed_jobs >= 1 && metrics.score >= 7_000 { - 1 - } else { - 0 - } + self.client_badge = BadgeLevel::from_score(self.client.score); + self.freelancer_badge = BadgeLevel::from_score(self.freelancer.score); } } diff --git a/contracts/reputation/src/storage.rs b/contracts/reputation/src/storage.rs index 2cb9bff8..048c97f3 100644 --- a/contracts/reputation/src/storage.rs +++ b/contracts/reputation/src/storage.rs @@ -1,4 +1,4 @@ -use crate::profile::Profile; +use crate::profile::Profile; use soroban_sdk::{Address, Env}; const PERSISTENT_TTL_THRESHOLD: u32 = 50_000; @@ -23,7 +23,7 @@ pub fn read_profile(env: &Env, address: &Address) -> Option { } pub fn read_profile_or_default(env: &Env, address: &Address) -> Profile { - read_profile(env, address).unwrap_or_else(|| Profile::new(address.clone(), env)) + read_profile(env, address).unwrap_or_else(|| Profile::new(env, address.clone())) } pub fn write_profile(env: &Env, address: &Address, profile: &Profile) { diff --git a/package-lock.json b/package-lock.json index f60900ee..452893e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ }, "devDependencies": { "@playwright/test": "^1.43.0", - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "vitest": "^4.1.7" }, "optionalDependencies": { "@next/swc-linux-x64-gnu": "16.1.6", @@ -55,8 +56,10 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20.19.39", "@types/react": "^19", "@types/react-dom": "^19", @@ -241,23 +244,6 @@ "url": "https://opencollective.com/vitest" } }, - "apps/web/node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "apps/web/node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, "apps/web/node_modules/magicast": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", @@ -270,13 +256,6 @@ "source-map-js": "^1.2.1" } }, - "apps/web/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "apps/web/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -319,50 +298,6 @@ "node": "^10 || ^12 || >=14" } }, - "apps/web/node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "apps/web/node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "apps/web/node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "apps/web/node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "apps/web/node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -4467,6 +4402,43 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "dev": true, @@ -4511,6 +4483,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@trezor/analytics": { "version": "1.4.3", "license": "See LICENSE.md in repo root", @@ -5094,6 +5080,13 @@ "license": "0BSD", "optional": true }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -5513,6 +5506,119 @@ "linux" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wallet-standard/base": { "version": "1.1.0", "license": "Apache-2.0", @@ -6843,6 +6949,16 @@ "node": ">=20" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "license": "MIT", @@ -7402,6 +7518,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.1.0", "license": "MIT", @@ -7662,6 +7788,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -9687,15 +9820,18 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-linux-x64-gnu": { + "node_modules/lightningcss-android-arm64": { "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">= 12.0.0" @@ -9705,15 +9841,18 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-linux-x64-musl": { + "node_modules/lightningcss-darwin-arm64": { "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">= 12.0.0" @@ -9723,50 +9862,239 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lit": { - "version": "3.3.0", - "license": "BSD-3-Clause", - "dependencies": { - "@lit/reactive-element": "^2.1.0", - "lit-element": "^4.2.0", - "lit-html": "^3.3.0" - } - }, - "node_modules/lit-element": { - "version": "4.2.2", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.5.0", - "@lit/reactive-element": "^2.1.0", - "lit-html": "^3.3.0" - } - }, - "node_modules/lit-html": { - "version": "3.3.2", - "license": "BSD-3-Clause", - "dependencies": { - "@types/trusted-types": "^2.0.2" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "dev": true, - "license": "MIT" - }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lit": { + "version": "3.3.0", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.2", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, "node_modules/long": { "version": "5.2.5", "license": "Apache-2.0" @@ -9797,6 +10125,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -9945,7 +10283,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -10464,6 +10804,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbkdf2": { "version": "3.1.5", "license": "MIT", @@ -10582,7 +10929,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -10600,7 +10949,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10624,6 +10973,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/process-nextick-args": { "version": "2.0.1", "license": "MIT" @@ -11664,6 +12048,13 @@ "dev": true, "license": "MIT" }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -11983,13 +12374,25 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -12009,6 +12412,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "dev": true, @@ -12753,6 +13166,526 @@ } } }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "dev": true, diff --git a/package.json b/package.json index 8d964fa2..908f1b34 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "devDependencies": { "@playwright/test": "^1.43.0", - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "vitest": "^4.1.7" }, "optionalDependencies": { "@next/swc-linux-x64-gnu": "16.1.6",