diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 437deef..d1a9866 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -356,6 +356,17 @@ pub struct PaymentSplit { pub reason: soroban_sdk::String, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompletionCertificate { + pub job_id: u64, + pub client: Address, + pub freelancer: Address, + pub amount: i128, + pub completed_at: u64, + pub metadata_uri: soroban_sdk::String, +} + #[contracttype] #[derive(Clone)] pub enum DataKey { @@ -423,6 +434,12 @@ pub enum DataKey { // ── Payment splits ─────────────────────────────────────────────────────── PaymentSplitCount(u64), PaymentSplit(u64, u32), + // ── Job view counter ───────────────────────────────────────────────────── + JobViewCount(u64), + JobViewDedup(u64, Address), + // ── Completion certificates ────────────────────────────────────────────── + CertificateCount(Address), + Certificate(Address, u64), } #[contracterror] @@ -957,6 +974,24 @@ impl EscrowContract { let key = DataKey::UserCompletedJobs(freelancer.clone()); env.storage().persistent().set(&key, &(user_count + 1)); env.storage().persistent().extend_ttl(&key, 10000, 10000); + + let cert_count: u64 = env.storage().persistent().get(&DataKey::CertificateCount(freelancer.clone())).unwrap_or(0); + let cert = CompletionCertificate { + job_id, + client: job.client.clone(), + freelancer: freelancer.clone(), + amount: job.amount, + completed_at: current_ledger(&env), + metadata_uri: soroban_sdk::String::from_str(&env, ""), + }; + env.storage().persistent().set(&DataKey::Certificate(freelancer.clone(), cert_count), &cert); + env.storage().persistent().extend_ttl(&DataKey::Certificate(freelancer.clone(), cert_count), 10000, 10000); + env.storage().persistent().set(&DataKey::CertificateCount(freelancer.clone()), &(cert_count + 1)); + + env.events().publish( + (soroban_sdk::Symbol::new(&env, "certificate_minted"),), + (job_id, freelancer.clone(), cert_count), + ); } pub fn cancel_job(env: Env, client: Address, job_id: u64) { @@ -3213,6 +3248,40 @@ impl EscrowContract { } result } + + pub fn record_job_view(e: Env, viewer: Address, job_id: u64) { + let dedup_key = DataKey::JobViewDedup(job_id, viewer.clone()); + if e.storage().temporary().has(&dedup_key) { + return; + } + e.storage().temporary().set(&dedup_key, &true); + e.storage().temporary().extend_ttl(&dedup_key, 17_280, 17_280); + + let count_key = DataKey::JobViewCount(job_id); + let current: u64 = e.storage().persistent().get(&count_key).unwrap_or(0); + e.storage().persistent().set(&count_key, &(current + 1)); + e.storage().persistent().extend_ttl(&count_key, ACTIVE_JOB_LIFETIME_THRESHOLD, ACTIVE_JOB_BUMP_AMOUNT); + } + + pub fn get_job_views(e: Env, job_id: u64) -> u64 { + e.storage().persistent().get(&DataKey::JobViewCount(job_id)).unwrap_or(0) + } + + pub fn get_certificates(e: Env, freelancer: Address, start: u64, limit: u64) -> Vec { + let total: u64 = e.storage().persistent().get(&DataKey::CertificateCount(freelancer.clone())).unwrap_or(0); + let mut result: Vec = Vec::new(&e); + let end = if start + limit > total { total } else { start + limit }; + for i in start..end { + if let Some(cert) = e.storage().persistent().get::(&DataKey::Certificate(freelancer.clone(), i)) { + result.push_back(cert); + } + } + result + } + + pub fn get_certificate_count(e: Env, freelancer: Address) -> u64 { + e.storage().persistent().get(&DataKey::CertificateCount(freelancer)).unwrap_or(0) + } } /// Core dispute resolution logic shared by `resolve_dispute` and `batch_resolve_disputes`. diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2214390..716192d 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -811,3 +811,113 @@ fn test_post_job_valid_duration_within_bounds() { ); assert_eq!(job_id, 1); } + +#[test] +fn test_record_job_view_increments_count() { + let env = Env::default(); + let (_admin, client, freelancer, token, contract_id) = setup_test(&env); + let escrow = new_escrow(&env, &contract_id); + let desc_hash = BytesN::from_array(&env, &[1u8; 32]); + let deadline: u64 = 1000; + let amount: i128 = 100_0000000; + + let job_id = escrow.post_job(&client, &amount, &desc_hash, &100u32, &deadline, &token, &dummy_title(&env), &dummy_category(&env)); + assert_eq!(escrow.get_job_views(&job_id), 0); + + escrow.record_job_view(&freelancer, &job_id); + assert_eq!(escrow.get_job_views(&job_id), 1); + + escrow.record_job_view(&client, &job_id); + assert_eq!(escrow.get_job_views(&job_id), 2); +} + +#[test] +fn test_record_job_view_deduplicates_same_viewer() { + let env = Env::default(); + let (_admin, client, freelancer, token, contract_id) = setup_test(&env); + let escrow = new_escrow(&env, &contract_id); + let desc_hash = BytesN::from_array(&env, &[1u8; 32]); + let deadline: u64 = 1000; + let amount: i128 = 100_0000000; + + let job_id = escrow.post_job(&client, &amount, &desc_hash, &100u32, &deadline, &token, &dummy_title(&env), &dummy_category(&env)); + + escrow.record_job_view(&freelancer, &job_id); + escrow.record_job_view(&freelancer, &job_id); + escrow.record_job_view(&freelancer, &job_id); + assert_eq!(escrow.get_job_views(&job_id), 1); +} + +#[test] +fn test_completion_certificate_minted_on_approve() { + let env = Env::default(); + let (_admin, client, freelancer, token, contract_id) = setup_test(&env); + let escrow = new_escrow(&env, &contract_id); + let desc_hash = BytesN::from_array(&env, &[1u8; 32]); + let deadline: u64 = 1000; + let amount: i128 = 100_0000000; + + assert_eq!(escrow.get_certificate_count(&freelancer), 0); + + let job_id = escrow.post_job(&client, &amount, &desc_hash, &100u32, &deadline, &token, &dummy_title(&env), &dummy_category(&env)); + escrow.accept_job(&freelancer, &job_id); + escrow.submit_work(&freelancer, &job_id); + escrow.approve_work(&client, &job_id); + + assert_eq!(escrow.get_certificate_count(&freelancer), 1); + + let certs = escrow.get_certificates(&freelancer, &0u64, &10u64); + assert_eq!(certs.len(), 1); + let cert = certs.get(0).unwrap(); + assert_eq!(cert.job_id, job_id); + assert_eq!(cert.client, client); + assert_eq!(cert.freelancer, freelancer); + assert_eq!(cert.amount, amount); +} + +#[test] +fn test_multiple_certificates_across_jobs() { + let env = Env::default(); + let (_admin, client, freelancer, token, contract_id) = setup_test(&env); + let escrow = new_escrow(&env, &contract_id); + let desc_hash = BytesN::from_array(&env, &[1u8; 32]); + let deadline: u64 = 1000; + let amount: i128 = 50_0000000; + + for _ in 0..3 { + let job_id = escrow.post_job(&client, &amount, &desc_hash, &100u32, &deadline, &token, &dummy_title(&env), &dummy_category(&env)); + escrow.accept_job(&freelancer, &job_id); + escrow.submit_work(&freelancer, &job_id); + escrow.approve_work(&client, &job_id); + } + + assert_eq!(escrow.get_certificate_count(&freelancer), 3); + let certs = escrow.get_certificates(&freelancer, &0u64, &10u64); + assert_eq!(certs.len(), 3); +} + +#[test] +fn test_get_certificates_pagination() { + let env = Env::default(); + let (_admin, client, freelancer, token, contract_id) = setup_test(&env); + let escrow = new_escrow(&env, &contract_id); + let desc_hash = BytesN::from_array(&env, &[1u8; 32]); + let deadline: u64 = 1000; + let amount: i128 = 50_0000000; + + for _ in 0..5 { + let job_id = escrow.post_job(&client, &amount, &desc_hash, &100u32, &deadline, &token, &dummy_title(&env), &dummy_category(&env)); + escrow.accept_job(&freelancer, &job_id); + escrow.submit_work(&freelancer, &job_id); + escrow.approve_work(&client, &job_id); + } + + let page1 = escrow.get_certificates(&freelancer, &0u64, &2u64); + assert_eq!(page1.len(), 2); + + let page2 = escrow.get_certificates(&freelancer, &2u64, &2u64); + assert_eq!(page2.len(), 2); + + let page3 = escrow.get_certificates(&freelancer, &4u64, &2u64); + assert_eq!(page3.len(), 1); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8cf6d16..27ba7fe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,70 +1,411 @@ # System Architecture -This document provides a high-level overview of the system architecture, component interactions, and the data lifecycle for the platform. +This document provides a comprehensive overview of the StellarWork platform's system design, component interactions, data flow, and deployment architecture. -## System Components +## System Context -Our application is built on a decentralized architecture consisting of three main layers: +StellarWork is a decentralized freelancing platform built on the Stellar network using Soroban smart contracts. Users interact through a Next.js frontend, authenticate via the Freighter wallet, and execute trustless escrow transactions on-chain. -1. **Frontend (Next.js)**: The user-facing application built with React and Next.js. It handles user interactions, wallet connectivity, and off-chain data management. -2. **Soroban RPC Layer**: Acts as the bridge between the frontend and the Stellar network. It forwards transactions to the network, simulates contract calls, and reads on-chain state. -3. **Smart Contract (Soroban)**: The on-chain Rust contract deployed on the Stellar network. It enforces the core business logic, manages the Escrow, and holds the authoritative state of Jobs. +```mermaid +graph TB + subgraph Users + Client[Client / Employer] + Freelancer + end + + subgraph Browser + FE[Next.js Frontend] + FW[Freighter Wallet Extension] + end + + subgraph Stellar Network + RPC[Soroban RPC Node] + SC[Escrow Smart Contract] + TC[Token Contracts
XLM / Stellar Assets] + end + + subgraph Off-chain Services + IPFS[IPFS Gateway] + Sentry[Sentry Error Tracking] + end + + subgraph Monitoring Stack + Prom[Prometheus] + Graf[Grafana] + AM[Alertmanager] + end + + Client <--> FE + Freelancer <--> FE + FE <--> FW + FW -- sign tx --> FE + FE -- HTTP --> RPC + RPC -- invoke --> SC + SC -- transfer --> TC + FE -- store metadata --> IPFS + FE -- error reports --> Sentry + FE -- POST /api/metrics --> Prom + Prom --> Graf + Prom --> AM +``` + +## Component Architecture + +### Smart Contract Layer + +The platform's on-chain logic lives in two Soroban contracts written in Rust: + +#### Escrow Contract (`contracts/escrow/`) + +The primary contract managing the full job lifecycle. + +| Category | Functions | Purpose | +|----------|-----------|---------| +| **Lifecycle** | `post_job`, `accept_job`, `submit_work`, `approve_work`, `cancel_job`, `enforce_deadline`, `mutual_cancel` | Core job state machine transitions | +| **Milestones** | `create_job_with_milestones`, `approve_milestone`, `complete_milestone`, `get_milestones` | Multi-milestone escrow with per-milestone payment release | +| **Disputes** | `raise_dispute`, `resolve_dispute`, `resolve_dispute_split`, `batch_resolve_disputes` | Conflict resolution with configurable split payouts | +| **Fee Management** | `update_fee_bps`, `get_fee_bps`, `withdraw_fees`, `set_discount_tiers`, `calculate_effective_fee_bps` | Platform fee configuration with volume-based discounts | +| **Access Control** | `set_whitelist_mode`, `add_to_whitelist`, `add_to_blacklist`, `set_fee_exemption` | Whitelist/blacklist gating and fee exemptions | +| **Visibility** | `set_job_visibility`, `add_invited_freelancer`, `get_job_visibility` | Public, Private, and InviteOnly job visibility modes | +| **Attestations** | `get_attestation`, `get_user_attestations` | On-chain work completion attestations | +| **Referrals** | `register_referral`, `post_job_with_referral`, `get_referral_earnings`, `withdraw_referral_earnings` | Referral code system with earn-and-withdraw flow | +| **SLA** | `post_job_with_sla`, `get_sla_status` | Service-level agreements with penalty enforcement | +| **Admin** | `admin_get_all_jobs`, `get_dashboard_stats`, `set_paused`, `propose_upgrade` | Administrative oversight and contract governance | +| **Gasless** | `set_trusted_forwarder`, `relay_cancel_job` | Meta-transaction support via trusted forwarders | +| **Payment Splits** | `approve_with_splits`, `set_payment_splits`, `get_available_splits` | Multi-recipient payout splitting | +| **Templates** | (saved via `Template` / `TemplateCount` storage keys) | Reusable job configurations per client | + +**Storage Model:** + +Soroban provides two storage tiers used by the contract: + +- **Instance storage** — configuration and counters that live with the contract instance: `Admin`, `NativeToken`, `FeeBps`, `JobsCount`, `Paused`, `Fees`, `TotalVolume`, `UniqueClients`, `UniqueFreelancers`, `DiscountTiers`, `AllowedTokenCount` +- **Persistent storage** — per-entity data with independent TTL management: `Job(id)`, `AllowedToken(addr)`, `Milestone(job_id, idx)`, `SLAConfig(job_id)`, `Attestation(job_id)`, `ReferralEarnings(addr)`, `Blacklisted(addr)`, `Whitelisted(addr)`, `FreelancerJobs(addr)`, `ClientJobs(addr)`, `PaymentSplit(job_id, idx)` + +TTL is actively bumped: active jobs get `518_400` ledger extensions (~30 days), archived jobs get `120_960` (~7 days). + +#### Retainer Contract (`contracts/retainer/`) + +Manages recurring retainer agreements and cross-chain job portability: + +- `create_retainer`, `renew_retainer`, `cancel_retainer` — periodic payment agreements +- `export_job`, `import_job` — cross-chain job migration +- `set_rate_limit`, `set_trusted_address` — rate limiting and trust management + +### Frontend Layer -## Data Split: On-chain vs Off-chain +Built with **Next.js 16** (App Router) and **React 19**, deployed to Vercel. -To optimize costs and performance, data is strategically split: +```mermaid +graph LR + subgraph Pages["Next.js App Router Pages"] + Home["/ (Home)"] + Dashboard["/dashboard"] + PostJob["/post-job"] + JobDetail["/job/[id]"] + Profile["/profile/[address]"] + Admin["/admin"] + Disputes["/disputes"] + Messages["/messages"] + Meetings["/meetings"] + Settings["/settings"] + Transactions["/transactions"] + end -- **On-chain Data (Soroban Contract)**: Contains only the critical financial and state data necessary for trustless execution. This includes the `Job` struct containing IDs, statuses, wallet addresses of the employer and freelancer, and escrow amounts. -- **Off-chain Data (localStorage / Backend)**: Contains heavy metadata that does not require on-chain consensus, such as detailed job descriptions, titles, images, and user profiles. The frontend matches off-chain metadata with on-chain IDs. + subgraph Components["Key Components"] + Sidebar + WalletSelector + StatusPill + JobFilterPanel + MilestoneProgress + RichTextEditor + NotificationInbox + CommandPalette + CallOverlay + end -## Wallet Integration (Freighter) + subgraph Lib["Library Modules"] + StellarLib["stellar.ts
Wallet + RPC"] + ContractLib["contract.ts
Contract wrappers"] + ConfigLib["config.ts
Env validation"] + MetricsLib["metrics.ts
Prometheus registry"] + MetricsClient["metrics-client.ts
Browser beacon"] + NetworkConfig["network-config.ts
Network switching"] + end -Wallet connectivity is handled via the Freighter wallet (`@stellar/freighter-api`). + subgraph Contexts["State Management (React Context)"] + WalletCtx["WalletProvider"] + NetworkCtx["NetworkProvider"] + NotifCtx["NotificationProvider"] + MsgCtx["MessagingProvider"] + MeetCtx["MeetingsProvider"] + end -1. **Connection**: Users connect their Freighter wallet to authenticate. -2. **Transaction Signing**: When a user performs an on-chain action (e.g., funding a job), the frontend constructs the transaction and prompts the user to sign it via the Freighter extension. -3. **Network Submission**: The signed transaction is then submitted to the Soroban RPC. + Home --> Components + Dashboard --> Components + JobDetail --> Components + Components --> Lib + Components --> Contexts + Lib --> StellarLib + StellarLib --> ContractLib +``` -## Job & Escrow Lifecycle +**State management** uses React Context providers — no external state library: -The core mechanism of the platform relies on a smart contract Escrow. +| Provider | Scope | +|----------|-------| +| `WalletProvider` | Wallet connection, account switching, balance, legal consent | +| `NetworkProvider` | Stellar network selection (testnet/futurenet/mainnet) | +| `NotificationProvider` | In-app notification queue | +| `MessagingProvider` | Direct messaging state | +| `MeetingsProvider` | Video call state | +| `TypographyProvider` | Font size preferences | -1. **Post**: An employer creates a job and deposits funds into the contract's escrow. -2. **Accept**: A freelancer accepts the job, updating the on-chain state to link their address. -3. **Submit**: The freelancer submits their completed work. -4. **Approve/Cancel**: The employer reviews the work. If approved, funds are released to the freelancer. If cancelled/disputed, funds can be returned to the employer (depending on dispute resolution rules). +### Stellar Integration -### Flow Diagram +The frontend communicates with the Stellar network through Soroban RPC: ```mermaid sequenceDiagram - actor Employer - actor Freelancer + participant User participant Frontend participant Freighter - participant SorobanContract - - Employer->>Frontend: Post Job & Deposit Funds - Frontend->>Freighter: Request Signature - Freighter-->>Frontend: Signed Tx - Frontend->>SorobanContract: Submit Tx (Post Job) - SorobanContract-->>Frontend: Job Created (Escrow Locked) - - Freelancer->>Frontend: Accept Job - Frontend->>Freighter: Request Signature - Freighter-->>Frontend: Signed Tx - Frontend->>SorobanContract: Submit Tx (Accept Job) - SorobanContract-->>Frontend: Job Assigned - - Freelancer->>Frontend: Submit Work - Frontend->>Freighter: Request Signature - Freighter-->>Frontend: Signed Tx - Frontend->>SorobanContract: Submit Tx (Submit Work) - SorobanContract-->>Frontend: Status: Pending Approval - - Employer->>Frontend: Approve Work - Frontend->>Freighter: Request Signature - Freighter-->>Frontend: Signed Tx - Frontend->>SorobanContract: Submit Tx (Approve Work) - SorobanContract-->>Frontend: Escrow Released to Freelancer + participant RPC as Soroban RPC + participant Network as Stellar Network + participant Contract as Escrow Contract + + User->>Frontend: Click "Post Job" + Frontend->>Frontend: Build transaction
(Contract.post_job) + Frontend->>Freighter: Request signature + Freighter->>User: Show approval dialog + User->>Freighter: Approve + Freighter-->>Frontend: Signed XDR + Frontend->>RPC: sendTransaction + RPC->>Network: Submit to ledger + Network-->>RPC: Transaction result + RPC-->>Frontend: Poll getTransaction + Frontend-->>User: Show success + tx link +``` + +**RPC interaction patterns:** + +- **Transaction building** — `@stellar/stellar-sdk` constructs `TransactionBuilder` with `Contract` invocation +- **Signing** — Freighter extension signs the XDR envelope (`@stellar/freighter-api`) +- **Submission** — `rpc.Server.sendTransaction()` followed by polling `getTransaction()` with exponential backoff (3 retries: 1s, 2s, 4s) +- **Read-only calls** — `simulateTransaction()` for view functions, no signing required +- **Event polling** — Soroban events published by the contract are queryable via `getEvents()` + +## Data Flow Diagrams + +### Job Posting Flow + +```mermaid +sequenceDiagram + actor Client + participant FE as Frontend + participant FW as Freighter + participant RPC as Soroban RPC + participant SC as Escrow Contract + participant Token as Token Contract + + Client->>FE: Fill job form + deposit amount + FE->>FE: Hash description (SHA-256) + FE->>FE: Store description on IPFS + FE->>FE: Build post_job transaction + FE->>FW: Request signature + FW-->>FE: Signed transaction + FE->>RPC: sendTransaction + RPC->>SC: Invoke post_job + SC->>Token: Transfer deposit from client to contract + SC->>SC: Store Job struct, increment JobsCount + SC-->>RPC: Emit job_created event + RPC-->>FE: Transaction SUCCESS + FE-->>Client: Show job created confirmation +``` + +### Job Completion & Payment Release Flow + +```mermaid +sequenceDiagram + actor Freelancer + actor Client + participant FE as Frontend + participant FW as Freighter + participant SC as Escrow Contract + participant Token as Token Contract + + Freelancer->>FE: Click "Submit Work" + FE->>FW: Sign submit_work tx + FW-->>FE: Signed tx + FE->>SC: submit_work(job_id) + SC->>SC: Status → SubmittedForReview + + Client->>FE: Review submission + Client->>FE: Click "Approve Work" + FE->>FW: Sign approve_work tx + FW-->>FE: Signed tx + FE->>SC: approve_work(job_id) + SC->>SC: Calculate platform fee (2.5% base) + SC->>SC: Apply SLA penalty if breached + SC->>SC: Apply volume discount if eligible + SC->>Token: Transfer payout to freelancer + SC->>SC: Status → Completed + SC->>SC: Increment CompletedJobsCount + SC->>SC: Store attestation + SC-->>FE: Emit work_approved event + FE-->>Client: Show completion confirmation + FE-->>Freelancer: Show payment received ``` + +### Dispute Resolution Flow + +```mermaid +sequenceDiagram + actor Freelancer + actor Client + actor Admin + participant SC as Escrow Contract + participant Token as Token Contract + + Freelancer->>SC: raise_dispute(job_id) + Note over SC: Status → Disputed
Freelancer deposits dispute fee + + Admin->>SC: resolve_dispute(job_id, client_bps) + Note over SC: client_bps determines split
0 = freelancer wins all
10000 = client wins all + + SC->>Token: Transfer client share + SC->>Token: Transfer freelancer share + SC->>Token: Refund dispute deposit to raiser + SC->>SC: Status → Completed +``` + +## Deployment Architecture + +### Development + +```mermaid +graph LR + subgraph Docker Compose + Stellar["stellar
(Stellar Quickstart)"] + Frontend["frontend
(Next.js dev server)"] + Builder["contract-builder
(Rust toolchain)"] + end + + subgraph Monitoring + Prometheus + Grafana + Alertmanager + end + + Frontend -- localhost:3000 --> Stellar + Frontend -- /api/metrics --> Prometheus + Prometheus --> Grafana + Prometheus --> Alertmanager +``` + +Local development uses Docker Compose with three services: +- **stellar** — Stellar Quickstart image with Soroban RPC on port 8000 +- **frontend** — Next.js dev server on port 3000 +- **contract-builder** — Rust/Soroban CLI for building and testing contracts + +Monitoring stack (`monitoring/docker-compose.monitoring.yml`) adds Prometheus, Grafana, Alertmanager, Blackbox Exporter, and Node Exporter. + +### Production + +```mermaid +graph TB + subgraph CDN["Edge / CDN"] + Vercel[Vercel Edge Network] + end + + subgraph Frontend["Frontend (Vercel)"] + NextApp[Next.js App] + MetricsAPI["/api/metrics"] + end + + subgraph Stellar["Stellar Network"] + MainnetRPC[Soroban RPC] + MainnetContract[Escrow Contract] + end + + subgraph Monitoring["Monitoring (Kubernetes)"] + Prom[Prometheus] + Graf[Grafana] + AM[Alertmanager] + end + + subgraph Infra["Infrastructure (Terraform + Helm)"] + K8s[Kubernetes Cluster] + Ingress[Ingress Controller] + end + + Users --> Vercel + Vercel --> NextApp + NextApp --> MainnetRPC + MainnetRPC --> MainnetContract + MetricsAPI --> Prom + Prom --> Graf + Prom --> AM + K8s --> Ingress + Ingress --> NextApp +``` + +**Production deployment:** +- **Frontend** — Deployed to Vercel with automatic preview deployments on PRs +- **Smart contracts** — Deployed to Stellar mainnet via Soroban CLI; verified on StellarExpert +- **Infrastructure** — Provisioned with Terraform (AWS), orchestrated with Helm charts on Kubernetes +- **Monitoring** — Prometheus scrapes `/api/metrics` every 30s, Grafana dashboards auto-provisioned, Alertmanager routes to Slack + +## Tech Stack + +| Layer | Technology | Rationale | +|-------|-----------|-----------| +| **Smart Contracts** | Rust + Soroban SDK 21.7 | Stellar's native WASM contract runtime; Rust provides memory safety and performance | +| **Frontend Framework** | Next.js 16 (App Router) | SSR/SSG, API routes, built-in image optimization, Vercel-native | +| **UI** | React 19 + Tailwind CSS 4 | Component model, concurrent features, utility-first styling | +| **Wallet** | Freighter (`@stellar/freighter-api`) | Official Stellar browser wallet; also supports WalletConnect and Ledger | +| **Stellar SDK** | `@stellar/stellar-sdk` 15.x | Transaction building, RPC client, XDR encoding | +| **Rich Text** | TipTap 3.27 | Extensible editor built on ProseMirror; supports links, placeholders | +| **Icons** | Lucide React | Tree-shakeable, consistent icon set | +| **i18n** | next-intl 4.x | Type-safe internationalization with App Router support | +| **Testing** | Vitest + Testing Library + Playwright | Unit tests, component tests, and E2E browser tests | +| **Contract Testing** | Soroban testutils + proptest + cargo-fuzz | Snapshot tests, property-based tests, fuzz testing | +| **Monitoring** | Prometheus + Grafana + Alertmanager | Industry-standard observability stack | +| **Error Tracking** | Sentry | Real-time error monitoring with source maps | +| **CI/CD** | GitHub Actions | Automated lint, typecheck, build, test, deploy pipeline | +| **Deployment** | Vercel (frontend) + Terraform + Helm (infra) | Git-driven deploys with infrastructure as code | +| **Containerization** | Docker + Docker Compose | Consistent local development environment | +| **Security** | CodeQL + pre-commit hooks | Static analysis and commit-time checks | + +## Key Design Decisions + +### On-chain vs Off-chain Data Split + +To optimize cost and performance, data is strategically split: + +| Data | Location | Reason | +|------|----------|--------| +| Job state (status, amounts, addresses) | On-chain (Soroban) | Trustless escrow requires authoritative on-chain state | +| Job descriptions, titles, images | Off-chain (IPFS + localStorage) | Large payloads are expensive on-chain; IPFS provides content addressing | +| User profiles, skills, testimonials | Off-chain (localStorage) | Personal data doesn't need consensus | +| Notifications, messages | Off-chain (browser state) | Ephemeral communication data | +| Metrics, telemetry | Off-chain (in-memory + Prometheus) | Operational data for monitoring | + +### Fee Architecture + +The platform charges a base fee of **2.5% (250 bps)** on every completed job: + +- **Volume discounts** — Configurable `DiscountTier` table reduces fees for high-volume freelancers +- **SLA penalties** — Late delivery can incur additional deductions based on `SLAConfig.penalty_bps` +- **Fee exemptions** — Admin can exempt specific addresses from fees +- **Dispute deposits** — 5 XLM default deposit to discourage frivolous disputes; refunded to the raiser + +### Contract Upgradeability + +The contract supports a governed upgrade path: + +1. Admin calls `propose_upgrade(wasm_hash)` — starts a 24-hour timelock +2. After timelock expires, admin calls `execute_upgrade()` — replaces WASM +3. Admin can `cancel_upgrade()` at any time before execution + +This prevents surprise contract changes and gives users time to react. diff --git a/docs/MONITORING.md b/docs/MONITORING.md index 4ae28a2..99fb6f5 100644 --- a/docs/MONITORING.md +++ b/docs/MONITORING.md @@ -59,6 +59,11 @@ browser ──POST /api/metrics──▶ Next.js server (in-memory registry) | `stellarwork_contract_tx_duration_milliseconds` | histogram | `method`, `network` | End-to-end invocation latency | | `stellarwork_rpc_errors_total` | counter | `kind`, `network` | Stellar RPC failures by coarse kind | | `stellarwork_client_errors_total` | counter | `kind`, `path` | Unhandled frontend errors | +| `stellarwork_http_requests_total` | counter | `route`, `status` | HTTP requests by route and status code | +| `stellarwork_http_request_duration_milliseconds` | histogram | `route` | HTTP request processing latency | +| `stellarwork_http_errors_total` | counter | `route` | HTTP responses with 4xx/5xx status | +| `stellarwork_active_sessions_total` | counter | `type` | Beacon pings (proxy for concurrent visitors) | +| `stellarwork_job_views_total` | counter | `job_id` | Job detail page views | Label values are sanitized and each metric is capped at 500 distinct series, so browser-supplied labels cannot grow memory without bound. @@ -93,6 +98,8 @@ Defined in [`monitoring/prometheus/alerts.yml`](../monitoring/prometheus/alerts. | `LcpRegression` | warning | p75 LCP >2.5s for 30m | | `InpRegression` | warning | p75 INP >200ms for 30m | | `ClientErrorSpike` | warning | >1 client error/sec for 10m | +| `HttpErrorRateHigh` | warning | >1% HTTP 4xx/5xx responses for 10m | +| `HttpLatencyHigh` | warning | p95 HTTP latency >3s for 15m | Routing lives in [`monitoring/alertmanager/alertmanager.yml`](../monitoring/alertmanager/alertmanager.yml). Alertmanager does **not** expand environment variables — replace the placeholder Slack diff --git a/frontend/__tests__/cancel-job-confirm.test.tsx b/frontend/__tests__/cancel-job-confirm.test.tsx index f82abc2..f235d9d 100644 --- a/frontend/__tests__/cancel-job-confirm.test.tsx +++ b/frontend/__tests__/cancel-job-confirm.test.tsx @@ -56,7 +56,6 @@ vi.mock("@/lib/meetings-context", () => ({ cancelMeeting: vi.fn(), completeMeeting: vi.fn(), rescheduleProposal: vi.fn(), - cancelMeeting: vi.fn(), confirmMeeting: vi.fn(), getMeetingsForJob: () => [], getUpcomingMeetings: () => [], @@ -76,6 +75,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/contract.test.ts b/frontend/__tests__/contract.test.ts index b2a9642..7f0ef8b 100644 --- a/frontend/__tests__/contract.test.ts +++ b/frontend/__tests__/contract.test.ts @@ -126,7 +126,7 @@ describe("contract interaction wrappers", () => { describe("success paths", () => { it("postJob calls post_job", async () => { mockCallContract.mockResolvedValue({ status: "SUCCESS" }); - const result = await postJob("GCLIENT", "100", "0a10ff", 32, "12345", "GTOKEN"); + const result = await postJob("GCLIENT", "100", "0a10ff", 32, "12345", "GTOKEN", "", "development"); expect(result).toEqual({ status: "SUCCESS" }); expect(mockCallContract).toHaveBeenCalledWith( CONTRACT_ID, @@ -328,7 +328,7 @@ describe("contract interaction wrappers", () => { }); it.each([ - ["postJob", () => postJob("GCLIENT", "100", "0a10ff", 32, "12345", "GTOKEN")], + ["postJob", () => postJob("GCLIENT", "100", "0a10ff", 32, "12345", "GTOKEN", "", "development")], ["acceptJob", () => acceptJob("GFREELANCER", "1")], ["submitWork", () => submitWork("GFREELANCER", "1")], ["approveWork", () => approveWork("GCLIENT", "1")], @@ -362,7 +362,7 @@ describe("contract calls without NEXT_PUBLIC_CONTRACT_ID", () => { }); it.each([ - ["postJob", () => postJob("GCLIENT", "100", "0x0a", 4, "0", "GTOKEN")], + ["postJob", () => postJob("GCLIENT", "100", "0x0a", 4, "0", "GTOKEN", "", "development")], ["acceptJob", () => acceptJob("GFREELANCER", "1")], ["submitWork", () => submitWork("GFREELANCER", "1")], ["approveWork", () => approveWork("GCLIENT", "1")], diff --git a/frontend/__tests__/dashboard-filter-chips.test.tsx b/frontend/__tests__/dashboard-filter-chips.test.tsx index 8080f70..1dea1ae 100644 --- a/frontend/__tests__/dashboard-filter-chips.test.tsx +++ b/frontend/__tests__/dashboard-filter-chips.test.tsx @@ -70,6 +70,7 @@ describe("Dashboard filter chip toggling", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GTESTWALLET", @@ -81,6 +82,7 @@ describe("Dashboard filter chip toggling", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -92,6 +94,7 @@ describe("Dashboard filter chip toggling", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); }); @@ -234,6 +237,7 @@ describe("Dashboard filter chip toggling", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -265,6 +269,7 @@ describe("Dashboard filter chip toggling", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); diff --git a/frontend/__tests__/dashboard-job-list-rendering.test.tsx b/frontend/__tests__/dashboard-job-list-rendering.test.tsx index 5fca0ff..3069452 100644 --- a/frontend/__tests__/dashboard-job-list-rendering.test.tsx +++ b/frontend/__tests__/dashboard-job-list-rendering.test.tsx @@ -73,6 +73,7 @@ describe("Dashboard job list rendering", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GTESTWALLET", @@ -85,6 +86,7 @@ describe("Dashboard job list rendering", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); diff --git a/frontend/__tests__/dashboard-wallet-connect.test.tsx b/frontend/__tests__/dashboard-wallet-connect.test.tsx index 2eb4331..391099e 100644 --- a/frontend/__tests__/dashboard-wallet-connect.test.tsx +++ b/frontend/__tests__/dashboard-wallet-connect.test.tsx @@ -190,6 +190,7 @@ describe("Dashboard wallet connect flow", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); mockUseWallet.mockReturnValue({ diff --git a/frontend/__tests__/home-job-listing.test.tsx b/frontend/__tests__/home-job-listing.test.tsx index e146537..2af0cd9 100644 --- a/frontend/__tests__/home-job-listing.test.tsx +++ b/frontend/__tests__/home-job-listing.test.tsx @@ -78,6 +78,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -89,6 +90,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -100,6 +102,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -126,6 +129,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -137,6 +141,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -148,6 +153,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -205,6 +211,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -216,6 +223,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -242,6 +250,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockRejectedValueOnce(new Error("network error")) .mockResolvedValueOnce({ @@ -254,6 +263,7 @@ describe("Home page job listing after getJobCount", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); diff --git a/frontend/__tests__/home-layout-toggles.test.tsx b/frontend/__tests__/home-layout-toggles.test.tsx index 2d105ef..1bae161 100644 --- a/frontend/__tests__/home-layout-toggles.test.tsx +++ b/frontend/__tests__/home-layout-toggles.test.tsx @@ -75,6 +75,7 @@ describe("Home page layout toggle buttons", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -86,6 +87,7 @@ describe("Home page layout toggle buttons", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); }); diff --git a/frontend/__tests__/home-sort-options.test.tsx b/frontend/__tests__/home-sort-options.test.tsx index e9624ec..9e64cfe 100644 --- a/frontend/__tests__/home-sort-options.test.tsx +++ b/frontend/__tests__/home-sort-options.test.tsx @@ -73,6 +73,7 @@ const JOB_FIXTURES = [ deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }, }, { @@ -87,6 +88,7 @@ const JOB_FIXTURES = [ deadline: "1720000000", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }, }, { @@ -101,6 +103,7 @@ const JOB_FIXTURES = [ deadline: "1715000000", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }, }, ]; diff --git a/frontend/__tests__/home-swipe-actions.test.tsx b/frontend/__tests__/home-swipe-actions.test.tsx index 341aa6f..e788cb6 100644 --- a/frontend/__tests__/home-swipe-actions.test.tsx +++ b/frontend/__tests__/home-swipe-actions.test.tsx @@ -88,6 +88,7 @@ const OWN_OPEN_JOB = { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }; describe("Home page mobile swipe quick actions", () => { diff --git a/frontend/__tests__/job-comparison.test.tsx b/frontend/__tests__/job-comparison.test.tsx index 5aa2ec1..1092427 100644 --- a/frontend/__tests__/job-comparison.test.tsx +++ b/frontend/__tests__/job-comparison.test.tsx @@ -82,6 +82,7 @@ function makeJob(overrides: Partial<{ deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }; } diff --git a/frontend/__tests__/job-detail-accept-button.test.tsx b/frontend/__tests__/job-detail-accept-button.test.tsx index b67a357..5074c86 100644 --- a/frontend/__tests__/job-detail-accept-button.test.tsx +++ b/frontend/__tests__/job-detail-accept-button.test.tsx @@ -71,6 +71,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/job-detail-actions.test.tsx b/frontend/__tests__/job-detail-actions.test.tsx index dff4100..d809cd0 100644 --- a/frontend/__tests__/job-detail-actions.test.tsx +++ b/frontend/__tests__/job-detail-actions.test.tsx @@ -72,6 +72,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/job-detail-approve-work-button.test.tsx b/frontend/__tests__/job-detail-approve-work-button.test.tsx index 2befb50..f87ebc7 100644 --- a/frontend/__tests__/job-detail-approve-work-button.test.tsx +++ b/frontend/__tests__/job-detail-approve-work-button.test.tsx @@ -75,6 +75,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/job-detail-cancel-button.test.tsx b/frontend/__tests__/job-detail-cancel-button.test.tsx index a241a95..bd2de06 100644 --- a/frontend/__tests__/job-detail-cancel-button.test.tsx +++ b/frontend/__tests__/job-detail-cancel-button.test.tsx @@ -71,6 +71,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/job-detail-copy-feedback.test.tsx b/frontend/__tests__/job-detail-copy-feedback.test.tsx index b5cb56f..54f8255 100644 --- a/frontend/__tests__/job-detail-copy-feedback.test.tsx +++ b/frontend/__tests__/job-detail-copy-feedback.test.tsx @@ -71,6 +71,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/job-detail-integration.test.tsx b/frontend/__tests__/job-detail-integration.test.tsx index 1e054c1..7c7839e 100644 --- a/frontend/__tests__/job-detail-integration.test.tsx +++ b/frontend/__tests__/job-detail-integration.test.tsx @@ -60,6 +60,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } @@ -85,7 +86,7 @@ describe("Job detail page integration", () => { it("calls acceptJob with correct arguments and handles success", async () => { mockGetJob.mockResolvedValue(makeJob({ status: "Open" })); - vi.mocked(contract.acceptJob).mockResolvedValue({ hash: "txhash123" }); + vi.mocked(contract.acceptJob).mockResolvedValue({ hash: "txhash123", status: "SUCCESS" }); renderJobPage(); @@ -121,7 +122,7 @@ describe("Job detail page integration", () => { mockGetJob.mockResolvedValue( makeJob({ status: "InProgress", freelancer: "GFREELANCER" }) ); - vi.mocked(contract.submitWork).mockResolvedValue({ hash: "txhash456" }); + vi.mocked(contract.submitWork).mockResolvedValue({ hash: "txhash456", status: "SUCCESS" }); renderJobPage(); @@ -145,7 +146,7 @@ describe("Job detail page integration", () => { mockGetJob.mockResolvedValue( makeJob({ status: "SubmittedForReview", client: "GCLIENT", freelancer: "GFREELANCER" }) ); - vi.mocked(contract.approveWork).mockResolvedValue({ hash: "txhash789" }); + vi.mocked(contract.approveWork).mockResolvedValue({ hash: "txhash789", status: "SUCCESS" }); renderJobPage(); @@ -167,7 +168,7 @@ describe("Job detail page integration", () => { connectWallet: vi.fn(), }); mockGetJob.mockResolvedValue(makeJob({ status: "Open", client: "GCLIENT" })); - vi.mocked(contract.cancelJob).mockResolvedValue({ hash: "txhash000" }); + vi.mocked(contract.cancelJob).mockResolvedValue({ hash: "txhash000", status: "SUCCESS" }); renderJobPage(); diff --git a/frontend/__tests__/job-detail-loading-state.test.tsx b/frontend/__tests__/job-detail-loading-state.test.tsx index 648a2ed..f81f85b 100644 --- a/frontend/__tests__/job-detail-loading-state.test.tsx +++ b/frontend/__tests__/job-detail-loading-state.test.tsx @@ -81,6 +81,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/job-detail-mobile-footer.test.tsx b/frontend/__tests__/job-detail-mobile-footer.test.tsx index ccb8c2d..ab7e7b6 100644 --- a/frontend/__tests__/job-detail-mobile-footer.test.tsx +++ b/frontend/__tests__/job-detail-mobile-footer.test.tsx @@ -103,6 +103,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -136,6 +137,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -164,6 +166,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -208,6 +211,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -249,6 +253,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -277,6 +282,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); // Mock acceptJob to simulate loading state @@ -323,6 +329,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -349,6 +356,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -384,6 +392,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -414,6 +423,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); // Mock localStorage to return a long description @@ -448,6 +458,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; @@ -472,6 +483,7 @@ describe("Job Detail Mobile Footer", () => { deadline: "0", token: "GTOKEN123", revision_count: 0, + submitted_at: "0", }); const JobDetailPage = (await import("@/app/job/[id]/page")).default; diff --git a/frontend/__tests__/job-detail-status-badges.test.tsx b/frontend/__tests__/job-detail-status-badges.test.tsx index 984afc8..1b38d71 100644 --- a/frontend/__tests__/job-detail-status-badges.test.tsx +++ b/frontend/__tests__/job-detail-status-badges.test.tsx @@ -78,6 +78,7 @@ function makeJob(status: JobStatus): Job { deadline: "1720000000", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }; } diff --git a/frontend/__tests__/job-detail-submit-work-button.test.tsx b/frontend/__tests__/job-detail-submit-work-button.test.tsx index 62d22c0..8ea24c9 100644 --- a/frontend/__tests__/job-detail-submit-work-button.test.tsx +++ b/frontend/__tests__/job-detail-submit-work-button.test.tsx @@ -75,6 +75,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/job-detail-wallet-connect-label.test.tsx b/frontend/__tests__/job-detail-wallet-connect-label.test.tsx index cbb4417..f95146b 100644 --- a/frontend/__tests__/job-detail-wallet-connect-label.test.tsx +++ b/frontend/__tests__/job-detail-wallet-connect-label.test.tsx @@ -71,6 +71,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/role-detection.test.tsx b/frontend/__tests__/role-detection.test.tsx index 1b97dd2..2282eae 100644 --- a/frontend/__tests__/role-detection.test.tsx +++ b/frontend/__tests__/role-detection.test.tsx @@ -72,6 +72,7 @@ function makeJob(overrides: Partial = {}): Job { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", ...overrides, }; } diff --git a/frontend/__tests__/smoke.test.tsx b/frontend/__tests__/smoke.test.tsx index 9b48de1..b51d0c0 100644 --- a/frontend/__tests__/smoke.test.tsx +++ b/frontend/__tests__/smoke.test.tsx @@ -103,6 +103,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -114,6 +115,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -138,6 +140,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -149,6 +152,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -175,6 +179,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -196,6 +201,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -207,6 +213,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); fireEvent.click(screen.getByRole("button", { name: "Refresh" })); @@ -233,6 +240,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); @@ -253,6 +261,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }) .mockResolvedValueOnce({ client: "GCLIENT", @@ -264,6 +273,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); fireEvent.click(screen.getByRole("button", { name: "Refresh" })); @@ -292,6 +302,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); const { rerender } = render(); @@ -321,6 +332,7 @@ describe("Home page render states", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }); render(); diff --git a/frontend/__tests__/types.test.ts b/frontend/__tests__/types.test.ts index 25ee4d8..f3ed9c3 100644 --- a/frontend/__tests__/types.test.ts +++ b/frontend/__tests__/types.test.ts @@ -61,6 +61,7 @@ describe("Job type guard", () => { deadline: "1710100000", token: "GTOKENADDRESS789", revision_count: 0, + submitted_at: "0", }; expect(isValidJob(job)).toBe(true); }); @@ -76,6 +77,7 @@ describe("Job type guard", () => { deadline: "0", token: "GTOKENADDRESS789", revision_count: 0, + submitted_at: "0", }; expect(isValidJob(job)).toBe(true); }); @@ -98,6 +100,7 @@ describe("Job type guard", () => { deadline: "0", token: "GTOKEN", revision_count: 0, + submitted_at: "0", }; expect(isValidJob(job)).toBe(false); }); diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 7d6251b..6b29d31 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -122,10 +122,11 @@ export default function AdminPage() { const count = await adminGetJobCount(actualAdmin); setJobs([]); setLoading(true); + let fetched: { id: number; job: Awaited>[number] }[] = []; try { const limit = 50; const list = await adminGetAllJobs(actualAdmin, 0, Math.max(1, count > limit ? limit : count)); - const fetched = list.map((job, idx) => ({ id: idx + 1, job })); + fetched = list.map((job, idx) => ({ id: idx + 1, job })); setJobs(fetched); } catch { setJobs([]); diff --git a/frontend/app/api/metrics/route.ts b/frontend/app/api/metrics/route.ts index 1a37ee7..892e548 100644 --- a/frontend/app/api/metrics/route.ts +++ b/frontend/app/api/metrics/route.ts @@ -1,7 +1,9 @@ import { WEB_VITALS, + recordActiveSession, recordClientError, recordContractTx, + recordJobView, recordLayoutShift, recordPageView, recordRpcError, @@ -80,6 +82,12 @@ function ingest(sample: Sample) { case "client_error": recordClientError(str(sample.kind), str(sample.path)); return; + case "job_view": + recordJobView(str(sample.jobId)); + return; + case "session_ping": + recordActiveSession(); + return; default: return; } diff --git a/frontend/app/client-providers.tsx b/frontend/app/client-providers.tsx new file mode 100644 index 0000000..bbf73c9 --- /dev/null +++ b/frontend/app/client-providers.tsx @@ -0,0 +1,28 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const CommandPalette = dynamic(() => import("@/components/CommandPalette"), { ssr: false }); +const ShortcutCheatSheet = dynamic(() => import("@/components/ShortcutCheatSheet"), { ssr: false }); +const OnboardingProvider = dynamic(() => import("@/components/OnboardingProvider"), { ssr: false }); +const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: false }); +const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false }); +const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false }); +const MetricsReporter = dynamic(() => import("@/components/MetricsReporter"), { ssr: false }); +const Sidebar = dynamic(() => import("@/components/Sidebar"), { ssr: false }); + +export function ClientProviders({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + + + + + + + + ); +} diff --git a/frontend/app/job/[id]/page.tsx b/frontend/app/job/[id]/page.tsx index fd478d0..8c2d781 100644 --- a/frontend/app/job/[id]/page.tsx +++ b/frontend/app/job/[id]/page.tsx @@ -11,7 +11,6 @@ import RichTextRenderer, { PlainTextRenderer, } from "@/components/RichTextRenderer"; import TruncatedAddress from "@/components/TruncatedAddress"; -import RichTextRenderer, { isRichText, PlainTextRenderer } from "@/components/RichTextRenderer"; import { verifyHtmlMatchesHash } from "@/lib/crypto"; import { useNotifications } from "@/lib/notifications-context"; import { @@ -21,9 +20,17 @@ import { freelancerCancelJob, getDescriptionCid, getJob, + getJobViews, + recordJobView, submitWork, } from "@/lib/contract"; import { fetchFromIpfs } from "@/lib/ipfs-service"; +import { + hasViewedToday, + markViewed, + hasViewedThisSession, + markSessionViewed, +} from "@/lib/job-views"; import { fetchXlmFiatRates, formatDeadline, @@ -114,6 +121,7 @@ function JobDetailPageContent() { const [isBookmarked, setIsBookmarked] = useState(false); const [bookmarkAnimating, setBookmarkAnimating] = useState(false); const [statusAnnouncement, setStatusAnnouncement] = useState(""); + const [viewCount, setViewCount] = useState(0); const { proposeMeeting, getMeetingsForJob } = useMeetings(); const [showScheduleForm, setShowScheduleForm] = useState(false); const [meetingTitle, setMeetingTitle] = useState(""); @@ -230,6 +238,33 @@ function JobDetailPageContent() { }; }, []); + useEffect(() => { + if (!id || !isIdValid) return; + let cancelled = false; + + getJobViews(id) + .then((count) => { + if (!cancelled) setViewCount(count); + }) + .catch(() => {}); + + if (wallet && !hasViewedToday(id, wallet) && !hasViewedThisSession(id)) { + recordJobView(wallet, id) + .then(() => { + markViewed(id, wallet); + markSessionViewed(id); + if (!cancelled) setViewCount((prev) => prev + 1); + }) + .catch(() => {}); + } else if (!hasViewedThisSession(id)) { + markSessionViewed(id); + } + + return () => { + cancelled = true; + }; + }, [id, wallet, isIdValid]); + const isClient = wallet && job && wallet === job.client; const isFreelancer = wallet && job && wallet === job.freelancer; const canAccept = Boolean(job && job.status === "Open"); @@ -239,9 +274,6 @@ function JobDetailPageContent() { const canFreelancerCancel = Boolean( isFreelancer && job?.status === "InProgress", ); - const hasPrimaryActions = - canAccept || canSubmit || canApprove || canCancel || canFreelancerCancel; - const canFreelancerCancel = Boolean(isFreelancer && job?.status === "InProgress"); const hasPrimaryActions = !wallet ? Boolean(job && ["Open", "InProgress", "SubmittedForReview"].includes(job.status)) : canAccept || canSubmit || canApprove || canCancel || canFreelancerCancel; @@ -513,12 +545,6 @@ function JobDetailPageContent() { return (
{/* Screen reader announcer for job status transitions */} -

{statusAnnouncement}

@@ -561,22 +587,6 @@ function JobDetailPageContent() {

)} - {job.status === "SubmittedForReview" && (() => { - const countdown = getAutoApprovalCountdown(job.submitted_at); - if (!countdown) return null; - return ( -
-
- - - -
-

{isClient ? "Action Required: Review Submitted Work" : "Work Under Review"}

-

{countdown.text}

{job.status === "SubmittedForReview" && (() => { const countdown = getAutoApprovalCountdown(job.submitted_at); @@ -621,6 +631,13 @@ function JobDetailPageContent() {

Status:

+ + + {viewCount} +
Token:{" "} - {job.token - ? `${job.token.slice(0, 8)}...${job.token.slice(-4)}` - : "N/A"} {job.token ? ( ) : ( @@ -794,27 +808,6 @@ function JobDetailPageContent() { })()} {/* Schedule meeting form */} - {showScheduleForm && wallet && (() => { - const otherParty = - wallet === job.client ? job.freelancer : - wallet === job.freelancer ? job.client : - job.client; - if (!otherParty) return null; - return ( -
-

Propose a Meeting

-
-
- - setMeetingTitle(e.target.value)} - placeholder="e.g. Project kickoff call" - className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" - /> -
-
{showScheduleForm && wallet && (() => { @@ -909,23 +902,6 @@ function JobDetailPageContent() { })()} {/* Show existing meetings for this job */} - {wallet && (() => { - const jobMeetings = getMeetingsForJob(numericId); - if (jobMeetings.length === 0) return null; - return ( -
-

Meetings

- {jobMeetings.map((m) => ( -
-
- {m.title} - - {m.status} - {wallet && (() => { const jobMeetings = getMeetingsForJob(numericId); @@ -1073,6 +1049,7 @@ function JobDetailPageContent() { )} + )}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index dd1fb92..6caac65 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,7 +3,6 @@ import { Geist, Geist_Mono } from "next/font/google"; import Link from "next/link"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; -import dynamic from "next/dynamic"; import { WalletProvider } from "@/lib/wallet-context"; import { ToastProvider } from "@/components/ToastProvider"; import { NotificationProvider } from "@/lib/notifications-context"; @@ -19,17 +18,9 @@ import JsonLd from "@/components/JsonLd"; import AppFooter from "@/components/AppFooter"; import OfflineIndicator from "@/components/OfflineIndicator"; import DeferredClientFeatures from "@/components/DeferredClientFeatures"; +import { ClientProviders } from "./client-providers"; import "./globals.css"; -const CommandPalette = dynamic(() => import("@/components/CommandPalette"), { ssr: false }); -const ShortcutCheatSheet = dynamic(() => import("@/components/ShortcutCheatSheet"), { ssr: false }); -const OnboardingProvider = dynamic(() => import("@/components/OnboardingProvider"), { ssr: false }); -const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: false }); -const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false }); -const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false }); -const MetricsReporter = dynamic(() => import("@/components/MetricsReporter"), { ssr: false }); -const Sidebar = dynamic(() => import("@/components/Sidebar"), { ssr: false }); - const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -156,11 +147,8 @@ export default async function RootLayout({ Skip to main content - - - + -
@@ -193,6 +181,7 @@ export default async function RootLayout({
+ diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index fac5e1a..f3ff3d2 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -216,8 +216,6 @@ export default function HomePage() { sessionStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode); }, [viewMode]); - const totalPages = useMemo(() => Math.max(1, Math.ceil(totalJobs / pageSize)), [pageSize, totalJobs]); - useEffect(() => { try { const stored = localStorage.getItem(BOOKMARK_STORAGE_KEY); @@ -1023,7 +1021,7 @@ export default function HomePage() { const deadline = formatDeadline(job.deadline); // ✅ FIX: Check if the connected wallet is the job owner - const isOwnJob = wallet && wallet.address && job.client && wallet.address === job.client; + const isOwnJob = wallet && job.client && wallet === job.client; return (
  • @@ -1084,9 +1082,6 @@ export default function HomePage() { markJobViewed(id)}>

    Job #{id} - {newJobIds.has(id) && ( -

    - {job.title || `Job #${id}`} {newJobIds.has(id) && (

  • )} + {/* ── Completion Certificates ─────────────────────────────────────────── */} + {!loading && certificates.length > 0 && ( +
    +

    Completion Certificates

    +

    {certificates.length} on-chain proof{certificates.length !== 1 ? "s" : ""} of completed work

    +
    + {certificates.map((cert, idx) => ( +
    +
    + + + Job #{cert.job_id} + +
    +
    +

    Client:

    +

    Amount: {toXlm(cert.amount)} XLM

    +

    Completed: ledger {cert.completed_at}

    +
    +
    + ))} +
    +
    + )} + {/* ── Job History ────────────────────────────────────────────────────── */} {!loading && (
    diff --git a/frontend/components/MetricsReporter.tsx b/frontend/components/MetricsReporter.tsx index 7086ad1..a2bcf28 100644 --- a/frontend/components/MetricsReporter.tsx +++ b/frontend/components/MetricsReporter.tsx @@ -17,6 +17,16 @@ export default function MetricsReporter() { reportSample({ type: "page_view", path: pathname }); }, [pathname]); + useEffect(() => { + reportSample({ type: "session_ping" }); + const interval = setInterval(() => { + if (document.visibilityState === "visible") { + reportSample({ type: "session_ping" }); + } + }, 30_000); + return () => clearInterval(interval); + }, []); + useEffect(() => { if (typeof PerformanceObserver === "undefined") return; diff --git a/frontend/components/PullToRefresh.tsx b/frontend/components/PullToRefresh.tsx index 01a65dc..73f8d5b 100644 --- a/frontend/components/PullToRefresh.tsx +++ b/frontend/components/PullToRefresh.tsx @@ -50,7 +50,7 @@ export default function PullToRefresh({ onRefresh, disabled = false, label = "Re className="pointer-events-none fixed left-1/2 top-0 z-40 flex -translate-x-1/2 justify-center" style={{ transform: `translate3d(-50%, ${offset}px, 0)`, - opacity: visible ? Math.max(progress, phase === "idle" ? 0 : 0.4) : 0, + opacity: visible ? Math.max(progress, 0.4) : 0, transition: distance === 0 ? "transform 200ms ease-out, opacity 200ms ease-out" : "opacity 120ms linear", }} > diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 985fea3..9c3d60c 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -17,7 +17,7 @@ import { ChevronRight, Menu, } from "lucide-react"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; const SIDEBAR_STATE_KEY = "stellarwork:sidebar-collapsed"; const SIDEBAR_WIDTH_EXPANDED = 220; diff --git a/frontend/lib/contract.ts b/frontend/lib/contract.ts index d393c02..696108d 100644 --- a/frontend/lib/contract.ts +++ b/frontend/lib/contract.ts @@ -592,3 +592,61 @@ export async function migrateJobVersion( ]); return Number(response.data ?? targetVersion); } + +// ─── Job View Counter ──────────────────────────────────────────────────────── + +export async function recordJobView(viewer: string, jobId: string) { + return callContract(getActiveContractId(), "record_job_view", [ + nativeToScVal(viewer, { type: "address" }), + nativeToScVal(jobId, { type: "u64" }), + ]); +} + +export async function getJobViews(jobId: string): Promise { + const response = await callContract( + getActiveContractId(), + "get_job_views", + [nativeToScVal(jobId, { type: "u64" })], + { readOnly: true }, + ); + return Number(response.data ?? 0); +} + +// ─── Completion Certificates ───────────────────────────────────────────────── + +export interface CompletionCertificate { + job_id: number; + client: string; + freelancer: string; + amount: string; + completed_at: string; + metadata_uri: string; +} + +export async function getCertificates( + freelancer: string, + start: number, + limit: number, +): Promise { + const response = await callContract( + getActiveContractId(), + "get_certificates", + [ + nativeToScVal(freelancer, { type: "address" }), + nativeToScVal(start, { type: "u64" }), + nativeToScVal(limit, { type: "u64" }), + ], + { readOnly: true }, + ); + return (response.data as CompletionCertificate[]) ?? []; +} + +export async function getCertificateCount(freelancer: string): Promise { + const response = await callContract( + getActiveContractId(), + "get_certificate_count", + [nativeToScVal(freelancer, { type: "address" })], + { readOnly: true }, + ); + return Number(response.data ?? 0); +} diff --git a/frontend/lib/job-views.ts b/frontend/lib/job-views.ts new file mode 100644 index 0000000..9bf5ad8 --- /dev/null +++ b/frontend/lib/job-views.ts @@ -0,0 +1,35 @@ +const VIEW_STORAGE_PREFIX = "stellarwork:viewed:"; + +function getStorageKey(jobId: string, wallet: string): string { + return `${VIEW_STORAGE_PREFIX}${wallet}:${jobId}`; +} + +function getSessionKey(jobId: string): string { + return `stellarwork:session-view:${jobId}`; +} + +export function hasViewedToday(jobId: string, wallet: string): boolean { + if (typeof window === "undefined") return true; + const key = getStorageKey(jobId, wallet); + const stored = localStorage.getItem(key); + if (!stored) return false; + const timestamp = Number(stored); + const oneDayMs = 24 * 60 * 60 * 1000; + return Date.now() - timestamp < oneDayMs; +} + +export function markViewed(jobId: string, wallet: string): void { + if (typeof window === "undefined") return; + const key = getStorageKey(jobId, wallet); + localStorage.setItem(key, String(Date.now())); +} + +export function hasViewedThisSession(jobId: string): boolean { + if (typeof window === "undefined") return true; + return sessionStorage.getItem(getSessionKey(jobId)) === "1"; +} + +export function markSessionViewed(jobId: string): void { + if (typeof window === "undefined") return; + sessionStorage.setItem(getSessionKey(jobId), "1"); +} diff --git a/frontend/lib/metrics-client.ts b/frontend/lib/metrics-client.ts index ac8e12e..e01d708 100644 --- a/frontend/lib/metrics-client.ts +++ b/frontend/lib/metrics-client.ts @@ -23,7 +23,9 @@ export type MetricSample = durationMs?: number; } | { type: "rpc_error"; kind: string; network: string } - | { type: "client_error"; kind: string; path: string }; + | { type: "client_error"; kind: string; path: string } + | { type: "job_view"; jobId: string } + | { type: "session_ping" }; let queue: MetricSample[] = []; let timer: ReturnType | null = null; @@ -84,6 +86,14 @@ export function reportRpcError(kind: string, network: string) { reportSample({ type: "rpc_error", kind, network }); } +export function reportJobView(jobId: string) { + reportSample({ type: "job_view", jobId }); +} + +export function reportSessionPing() { + reportSample({ type: "session_ping" }); +} + /** Buckets a thrown value into a coarse, low-cardinality error kind. */ export function classifyError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); diff --git a/frontend/lib/metrics.ts b/frontend/lib/metrics.ts index e31022b..ce84e43 100644 --- a/frontend/lib/metrics.ts +++ b/frontend/lib/metrics.ts @@ -123,6 +123,32 @@ const clientErrors = counter( "Unhandled frontend errors reported by browsers.", ); +const httpRequests = counter( + "stellarwork_http_requests_total", + "HTTP requests handled by the Next.js server, by route and status code.", +); + +const httpRequestDuration = histogram( + "stellarwork_http_request_duration_milliseconds", + "HTTP request processing latency in milliseconds, by route.", + [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000], +); + +const httpErrors = counter( + "stellarwork_http_errors_total", + "HTTP responses with 4xx or 5xx status codes, by route.", +); + +const activeSessions = counter( + "stellarwork_active_sessions_total", + "Number of beacon requests received (proxy for concurrent visitors).", +); + +const jobViews = counter( + "stellarwork_job_views_total", + "Job detail page views reported by browsers.", +); + // ── recording API ─────────────────────────────────────────────────────────── /** Keeps label values low-cardinality and safe to render in the exposition format. */ @@ -168,6 +194,24 @@ export function recordClientError(kind: string, path: string) { incCounter(clientErrors, { kind: sanitizeLabel(kind), path: sanitizeLabel(path, "/") }); } +export function recordHttpRequest(route: string, statusCode: number, durationMs: number) { + const routeLabel = sanitizeLabel(route, "/"); + const statusLabel = String(statusCode); + incCounter(httpRequests, { route: routeLabel, status: statusLabel }); + observe(httpRequestDuration, { route: routeLabel }, durationMs); + if (statusCode >= 400) { + incCounter(httpErrors, { route: routeLabel }); + } +} + +export function recordActiveSession() { + incCounter(activeSessions, { type: "beacon" }); +} + +export function recordJobView(jobId: string) { + incCounter(jobViews, { job_id: sanitizeLabel(jobId) }); +} + /** Test hook — drops every recorded sample. */ export function resetMetrics() { for (const metric of registry.values()) metric.series.clear(); diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index c348f6a..bf5d0ec 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -296,26 +296,6 @@ async function invokeContract( throw new Error("Connect Freighter before calling contract."); } const account = await server.getAccount(source); - if (sent.hash) { - recordRecentContractInteraction({ - hash: sent.hash, - status: "PENDING", - timestamp: Date.now(), - method, - }); - } - - if (sent.status === "ERROR") { - if (sent.hash) { - recordRecentContractInteraction({ - hash: sent.hash, - status: "ERROR", - timestamp: Date.now(), - method, - }); - } - throw new Error(sent.errorResult?.toXDR().toString() ?? "Contract invocation failed."); - } const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -336,31 +316,25 @@ async function invokeContract( const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); const sent = await server.sendTransaction(signedTx); - if (sent.status === "ERROR") { - throw new Error(sent.errorResult?.toXDR().toString() ?? "Contract invocation failed."); - if (status.status === rpc.Api.GetTransactionStatus.SUCCESS) { - recordRecentContractInteraction({ - hash: sent.hash, - status: "SUCCESS", - timestamp: Date.now(), - method, - }); - return { status: "SUCCESS", hash: sent.hash }; - } + if (sent.hash) { + recordRecentContractInteraction({ + hash: sent.hash, + status: "PENDING", + timestamp: Date.now(), + method, + }); + } - if (status.status === rpc.Api.GetTransactionStatus.FAILED) { + if (sent.status === "ERROR") { + if (sent.hash) { recordRecentContractInteraction({ hash: sent.hash, status: "ERROR", timestamp: Date.now(), method, }); - return { - status: "ERROR", - hash: sent.hash, - errorResult: "Transaction failed.", - }; } + throw new Error(sent.errorResult?.toXDR().toString() ?? "Contract invocation failed."); } if (sent.status === "PENDING") { @@ -373,10 +347,22 @@ async function invokeContract( const status = await server.getTransaction(sent.hash); if (status.status === rpc.Api.GetTransactionStatus.SUCCESS) { + recordRecentContractInteraction({ + hash: sent.hash, + status: "SUCCESS", + timestamp: Date.now(), + method, + }); return { status: "SUCCESS", hash: sent.hash } as TransactionResult; } if (status.status === rpc.Api.GetTransactionStatus.FAILED) { + recordRecentContractInteraction({ + hash: sent.hash, + status: "ERROR", + timestamp: Date.now(), + method, + }); return { status: "ERROR", hash: sent.hash, @@ -391,17 +377,7 @@ async function invokeContract( } return { status: "SUCCESS", hash: sent.hash } as TransactionResult; - }, operationLabel); - if (sent.hash) { - recordRecentContractInteraction({ - hash: sent.hash, - status: "SUCCESS", - timestamp: Date.now(), - method, - }); - } - - return { status: "SUCCESS", hash: sent.hash }; + }, method); } export function decodeScVal(value: xdr.ScVal): T { diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 52eed52..63c6f1a 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -42,8 +42,6 @@ export interface Job { submitted_at: string; title?: string; category?: string; - /** Unix timestamp the freelancer last submitted work, when the contract exposes it. */ - submitted_at?: string; } /** A single milestone within a milestone-based job. */ diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 66e5a23..b9e5d1e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,5 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; -import { withSentryConfig } from "@sentry/nextjs"; import withBundleAnalyzer from "@next/bundle-analyzer"; const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); @@ -29,15 +28,6 @@ const nextConfig: NextConfig = { ], }; -export default withSentryConfig(withNextIntl(nextConfig), { - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - silent: true, - widenClientFileUpload: true, - hideSourceMaps: true, - disableLogger: true, - automaticVercelMonitors: false, -}); export default withBundleAnalyzer({ enabled: process.env.ANALYZE === "true", })(withNextIntl(nextConfig)); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3cf302d..c99f9a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14257,6 +14257,111 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", + "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", + "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", + "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", + "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", + "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", + "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", + "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts index faf54a3..cb0ff5c 100644 --- a/frontend/sentry.client.config.ts +++ b/frontend/sentry.client.config.ts @@ -1,16 +1 @@ -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_DEPLOY_ENV ?? "development", - tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, - replaysOnErrorSampleRate: 1.0, - replaysSessionSampleRate: 0.05, - integrations: [ - Sentry.replayIntegration({ - maskAllText: true, - blockAllMedia: true, - }), - ], - enabled: process.env.NODE_ENV === "production", -}); +export {}; diff --git a/frontend/sentry.edge.config.ts b/frontend/sentry.edge.config.ts index 0829632..cb0ff5c 100644 --- a/frontend/sentry.edge.config.ts +++ b/frontend/sentry.edge.config.ts @@ -1,8 +1 @@ -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_DEPLOY_ENV ?? "development", - tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, - enabled: process.env.NODE_ENV === "production", -}); +export {}; diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts index 0829632..cb0ff5c 100644 --- a/frontend/sentry.server.config.ts +++ b/frontend/sentry.server.config.ts @@ -1,8 +1 @@ -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_DEPLOY_ENV ?? "development", - tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, - enabled: process.env.NODE_ENV === "production", -}); +export {}; diff --git a/monitoring/grafana/dashboards/stellarwork-overview.json b/monitoring/grafana/dashboards/stellarwork-overview.json index f1b53fd..d012caa 100644 --- a/monitoring/grafana/dashboards/stellarwork-overview.json +++ b/monitoring/grafana/dashboards/stellarwork-overview.json @@ -389,6 +389,165 @@ "legendFormat": "{{path}}" } ] + }, + { + "id": 12, + "type": "timeseries", + "title": "HTTP requests by route", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 36 }, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { "drawStyle": "line", "fillOpacity": 15, "lineWidth": 2, "stacking": { "mode": "normal" } } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "expr": "sum by (route) (rate(stellarwork_http_requests_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 13, + "type": "timeseries", + "title": "HTTP request latency (p95)", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 36 }, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { "drawStyle": "line", "fillOpacity": 0, "lineWidth": 2 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 3000 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "expr": "histogram_quantile(0.95, sum by (le, route) (rate(stellarwork_http_request_duration_milliseconds_bucket{instance=~\"$instance\"}[$__rate_interval])))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 14, + "type": "stat", + "title": "HTTP error rate (1h)", + "description": "Percentage of HTTP responses with 4xx/5xx status codes.", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 44 }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "colorMode": "background", + "graphMode": "area", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "decimals": 2, + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 0.01 }, + { "color": "red", "value": 0.05 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "expr": "sum(rate(stellarwork_http_errors_total{instance=~\"$instance\"}[1h])) / clamp_min(sum(rate(stellarwork_http_requests_total{instance=~\"$instance\"}[1h])), 0.001)", + "legendFormat": "error rate" + } + ] + }, + { + "id": 15, + "type": "stat", + "title": "Active sessions (beacon)", + "description": "Approximate concurrent visitors based on beacon pings.", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 44 }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "colorMode": "background", + "graphMode": "area", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "blue", "value": null } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "expr": "sum(increase(stellarwork_active_sessions_total{instance=~\"$instance\"}[5m]))", + "legendFormat": "sessions" + } + ] + }, + { + "id": 16, + "type": "timeseries", + "title": "Job views by job ID", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 44 }, + "options": { + "legend": { "displayMode": "table", "placement": "right", "calcs": ["sum", "max"] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1, "stacking": { "mode": "normal" } } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" }, + "expr": "topk(10, sum by (job_id) (increase(stellarwork_job_views_total{instance=~\"$instance\"}[$__rate_interval])))", + "legendFormat": "job {{job_id}}" + } + ] } ] } diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml index 3444342..f582579 100644 --- a/monitoring/prometheus/alerts.yml +++ b/monitoring/prometheus/alerts.yml @@ -111,3 +111,38 @@ groups: description: >- {{ $value | printf "%.2f" }} client errors/sec over the last 5 minutes. Check the error breakdown panel by kind and path. + + - name: stellarwork-http-health + rules: + - alert: HttpErrorRateHigh + expr: | + ( + sum(rate(stellarwork_http_errors_total[10m])) + / + clamp_min(sum(rate(stellarwork_http_requests_total[10m])), 0.001) + ) > 0.01 + for: 10m + labels: + severity: warning + team: platform + annotations: + summary: HTTP error rate above 1% + description: >- + More than 1% of HTTP responses returned 4xx/5xx status codes over + the last 10 minutes. Check the HTTP errors panel by route. + + - alert: HttpLatencyHigh + expr: | + histogram_quantile( + 0.95, + sum by (le) (rate(stellarwork_http_request_duration_milliseconds_bucket[10m])) + ) > 3000 + for: 15m + labels: + severity: warning + team: platform + annotations: + summary: p95 HTTP request latency above 3s + description: >- + Server-side HTTP request processing at p95 exceeds 3 seconds. + Investigate slow API routes or upstream Stellar RPC latency.