Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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('*');
}
}
60 changes: 60 additions & 0 deletions backend/src/database/seeds/demo-seed.ts
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 13 additions & 0 deletions backend/src/monitoring/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
13 changes: 13 additions & 0 deletions backend/src/monitoring/metrics.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
32 changes: 32 additions & 0 deletions backend/src/monitoring/metrics.service.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
10 changes: 10 additions & 0 deletions backend/src/monitoring/monitoring.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
12 changes: 11 additions & 1 deletion contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -606,4 +616,4 @@ impl SkillSyncEscrow {
}

#[cfg(test)]
mod test;
mod test;
127 changes: 120 additions & 7 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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)
}
}
Loading