diff --git a/backend/package.json b/backend/package.json index d7a98e1a9..9f61a4fe7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,8 @@ "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", "migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts", "migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts", - "migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts" + "migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts", + "seed:demo": "SEED_DEMO_DATA=true ts-node -r tsconfig-paths/register src/database/seeds/demo-seed.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5652e5d31..3dc75c25e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,8 @@ import { PortfolioModule } from './portfolio/portfolio.module'; import { AvailabilityModule } from './availability/availability.module'; import { AvatarModule } from './avatar/avatar.module'; import { AdminModule } from './admin/admin.module'; +import { MonitoringModule } from './monitoring/monitoring.module'; +import { MetricsMiddleware } from './monitoring/metrics.middleware'; @Module({ imports: [ @@ -29,12 +31,13 @@ import { AdminModule } from './admin/admin.module'; PortfolioModule, AvailabilityModule, AvatarModule, + MonitoringModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { - consumer.apply(HttpLoggerMiddleware).forRoutes('*'); + consumer.apply(HttpLoggerMiddleware, MetricsMiddleware).forRoutes('*'); } } diff --git a/backend/src/database/seeds/demo-seed.ts b/backend/src/database/seeds/demo-seed.ts new file mode 100644 index 000000000..f7c49ebfd --- /dev/null +++ b/backend/src/database/seeds/demo-seed.ts @@ -0,0 +1,60 @@ +import 'reflect-metadata'; +import { AppDataSource } from '../../database/data-source'; +import { User } from '../../users/entities/user.entity'; +import { Role } from '../../users/entities/role.entity'; + +async function seedDemo() { + if (process.env.SEED_DEMO_DATA !== 'true') { + console.log('SEED_DEMO_DATA is not true; skipping demo seed.'); + return; + } + + await AppDataSource.initialize(); + const roleRepo = AppDataSource.getRepository(Role); + const userRepo = AppDataSource.getRepository(User); + + const mentorRole = + (await roleRepo.findOne({ where: { name: 'mentor' } })) ?? + (await roleRepo.save(roleRepo.create({ name: 'mentor', description: 'Demo mentor role' }))); + const menteeRole = + (await roleRepo.findOne({ where: { name: 'mentee' } })) ?? + (await roleRepo.save(roleRepo.create({ name: 'mentee', description: 'Demo mentee role' }))); + + for (let i = 1; i <= 5; i++) { + const mentorWallet = `demo_mentor_${i}@example.com`; + const menteeWallet = `demo_mentee_${i}@example.com`; + + const mentorExists = await userRepo.findOne({ where: { walletAddress: mentorWallet } }); + if (!mentorExists) { + await userRepo.save( + userRepo.create({ + walletAddress: mentorWallet, + displayName: `Demo Mentor ${i}`, + username: `demo_mentor_${i}`, + roles: [mentorRole], + }), + ); + } + + const menteeExists = await userRepo.findOne({ where: { walletAddress: menteeWallet } }); + if (!menteeExists) { + await userRepo.save( + userRepo.create({ + walletAddress: menteeWallet, + displayName: `Demo Mentee ${i}`, + username: `demo_mentee_${i}`, + roles: [menteeRole], + }), + ); + } + } + + console.log('Demo mentor/mentee accounts seeded.'); + await AppDataSource.destroy(); +} + +seedDemo().catch(async (e) => { + console.error(e); + if (AppDataSource.isInitialized) await AppDataSource.destroy(); + process.exit(1); +}); diff --git a/backend/src/monitoring/metrics.controller.ts b/backend/src/monitoring/metrics.controller.ts new file mode 100644 index 000000000..7444176b8 --- /dev/null +++ b/backend/src/monitoring/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metrics: MetricsService) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4') + getMetrics() { + return this.metrics.toPrometheus(); + } +} diff --git a/backend/src/monitoring/metrics.middleware.ts b/backend/src/monitoring/metrics.middleware.ts new file mode 100644 index 000000000..2ba4e949d --- /dev/null +++ b/backend/src/monitoring/metrics.middleware.ts @@ -0,0 +1,13 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { MetricsService } from './metrics.service'; + +@Injectable() +export class MetricsMiddleware implements NestMiddleware { + constructor(private readonly metrics: MetricsService) {} + + use(_req: Request, _res: Response, next: NextFunction) { + this.metrics.recordRequest(); + next(); + } +} diff --git a/backend/src/monitoring/metrics.service.ts b/backend/src/monitoring/metrics.service.ts new file mode 100644 index 000000000..bf8e31a0a --- /dev/null +++ b/backend/src/monitoring/metrics.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MetricsService { + private requestsTotal = 0; + private jwtVerificationFailures = 0; + + recordRequest() { + this.requestsTotal += 1; + } + + recordJwtVerificationFailure() { + this.jwtVerificationFailures += 1; + } + + toPrometheus(): string { + return [ + '# HELP http_requests_total Total HTTP requests handled', + '# TYPE http_requests_total counter', + `http_requests_total ${this.requestsTotal}`, + '# HELP http_request_duration_seconds HTTP request duration histogram placeholder', + '# TYPE http_request_duration_seconds histogram', + 'http_request_duration_seconds_bucket{le="1"} 0', + 'http_request_duration_seconds_bucket{le="+Inf"} 0', + 'http_request_duration_seconds_count 0', + 'http_request_duration_seconds_sum 0', + '# HELP jwt_verification_failures_total JWT verification failures', + '# TYPE jwt_verification_failures_total counter', + `jwt_verification_failures_total ${this.jwtVerificationFailures}`, + ].join('\n'); + } +} diff --git a/backend/src/monitoring/monitoring.module.ts b/backend/src/monitoring/monitoring.module.ts new file mode 100644 index 000000000..7a6403954 --- /dev/null +++ b/backend/src/monitoring/monitoring.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MonitoringModule {} diff --git a/contract/src/lib.rs b/contract/src/lib.rs index c6aa30f2f..bd15e8d78 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -209,6 +209,11 @@ impl EscrowContract { } s.state = SessionState::Approved; env.storage().persistent().set(&DataKey::Session(session_id), &s); + env.events().publish((Symbol::new(&env, "SessionApproved"),), session_id); + } + + pub fn approve_session(env: Env, session_id: u64, token_id: Address) { + Self::approve(env, session_id, token_id); } pub fn refund(env: Env, session_id: u64, token_id: Address) { @@ -219,6 +224,11 @@ impl EscrowContract { s.state = SessionState::Refunded; env.storage().persistent().set(&DataKey::Session(session_id), &s); env.events().publish((symbol_short!("REFUNDED"), session_id), s.amount); + env.events().publish((Symbol::new(&env, "SessionRefunded"),), session_id); + } + + pub fn refund_session(env: Env, session_id: u64, token_id: Address) { + Self::refund(env, session_id, token_id); } pub fn auto_refund(env: Env, session_id: u64, token_id: Address) { @@ -606,4 +616,4 @@ impl SkillSyncEscrow { } #[cfg(test)] -mod test; \ No newline at end of file +mod test; diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 837318094..611f80b01 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Bytes32, Env, Symbol}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Bytes32, Env, Symbol, String}; #[contract] pub struct EscrowContract; @@ -13,11 +13,37 @@ pub struct Session { pub seller: Address, pub amount: i128, pub timestamp: u64, + pub status: u32, + pub completed_at: u64, + pub dispute_opened_at: u64, } +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Session(Bytes32), + Admin, + Treasury, + FeeBps, + DisputeWindow, +} + +const STATUS_LOCKED: u32 = 0; +const STATUS_COMPLETED: u32 = 1; +const STATUS_DISPUTED: u32 = 2; +const STATUS_REFUNDED: u32 = 3; +const STATUS_RESOLVED: u32 = 4; + #[contractimpl] impl EscrowContract { - /// Locks funds into a new escrow session and emits the FundsLocked event. + pub fn init_config(e: Env, admin: Address, treasury: Address, fee_bps: u32, dispute_window: u64) { + admin.require_auth(); + e.storage().persistent().set(&DataKey::Admin, &admin); + e.storage().persistent().set(&DataKey::Treasury, &treasury); + e.storage().persistent().set(&DataKey::FeeBps, &fee_bps); + e.storage().persistent().set(&DataKey::DisputeWindow, &dispute_window); + } + pub fn lock_funds( e: Env, session_id: Bytes32, @@ -27,21 +53,108 @@ impl EscrowContract { ) { buyer.require_auth(); - let timestamp = e.ledger().timestamp(); + let now = e.ledger().timestamp(); let session_metadata = Session { id: session_id.clone(), buyer: buyer.clone(), seller: seller.clone(), amount, - timestamp, + timestamp: now, + status: STATUS_LOCKED, + completed_at: 0, + dispute_opened_at: 0, }; + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session_metadata); - // Emit the FundsLocked event as specified in the acceptance criteria - // Signature: event FundsLocked(session_id: Bytes32, buyer: Address, seller: Address, amount: i128, timestamp: u64) e.events().publish( (Symbol::new(&e, "FundsLocked"),), - (session_id, buyer, seller, amount, timestamp), + (session_id, buyer, seller, amount, now), + ); + } + + pub fn complete_session(e: Env, session_id: Bytes32) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + session.status = STATUS_COMPLETED; + session.completed_at = e.ledger().timestamp(); + e.storage().persistent().set(&DataKey::Session(session_id), &session); + } + + pub fn open_dispute(e: Env, session_id: Bytes32, reason: String) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + + if session.status != STATUS_COMPLETED && session.status != STATUS_LOCKED { + panic!("session not disputable"); + } + + session.status = STATUS_DISPUTED; + session.dispute_opened_at = e.ledger().timestamp(); + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session); + e.events().publish((Symbol::new(&e, "DisputeOpened"),), (session_id, reason)); + } + + pub fn resolve_dispute( + e: Env, + session_id: Bytes32, + resolution: u32, + buyer_share: i128, + seller_share: i128, + ) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + if session.status != STATUS_DISPUTED { + panic!("session not disputed"); + } + let admin: Address = e.storage().persistent().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + if buyer_share + seller_share != session.amount { + panic!("invalid shares"); + } + let (seller_after_fee, fee_amount) = Self::apply_fee(&e, seller_share); + let buyer_after_fee = buyer_share; + + session.status = STATUS_RESOLVED; + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session); + e.events().publish( + (Symbol::new(&e, "DisputeResolved"),), + (session_id, resolution, buyer_after_fee, seller_after_fee, fee_amount), ); } + + pub fn auto_refund(e: Env, session_id: Bytes32) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + if session.status != STATUS_COMPLETED { + panic!("session not completed"); + } + let dispute_window: u64 = e.storage().persistent().get(&DataKey::DisputeWindow).unwrap_or(0); + if e.ledger().timestamp() <= session.completed_at + dispute_window { + panic!("dispute window not elapsed"); + } + + session.status = STATUS_REFUNDED; + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session); + e.events().publish((Symbol::new(&e, "AutoRefundExecuted"),), (session_id, session.amount)); + } + + fn apply_fee(e: &Env, amount: i128) -> (i128, i128) { + let fee_bps: u32 = e.storage().persistent().get(&DataKey::FeeBps).unwrap_or(0); + let fee = (amount * (fee_bps as i128)) / 10_000; + (amount - fee, fee) + } }