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",