diff --git a/apps/backend/src/jobs/dto/index.ts b/apps/backend/src/jobs/dto/index.ts new file mode 100644 index 00000000..ae32b878 --- /dev/null +++ b/apps/backend/src/jobs/dto/index.ts @@ -0,0 +1,38 @@ +import { IsString, IsOptional, IsArray, IsEnum, IsNumber, IsDateString, Min } from 'class-validator'; +import { JobStatus, ApplicationStatus } from '../job.entity'; + +export class CreateJobDto { + @IsString() title: string; + @IsString() description: string; + @IsString() @IsOptional() category?: string; + @IsArray() @IsOptional() requiredSkills?: string[]; + @IsNumber() @IsOptional() @Min(0) budgetMin?: number; + @IsNumber() @IsOptional() @Min(0) budgetMax?: number; + @IsDateString() @IsOptional() expiresAt?: string; +} + +export class UpdateJobDto { + @IsString() @IsOptional() title?: string; + @IsString() @IsOptional() description?: string; + @IsString() @IsOptional() category?: string; + @IsArray() @IsOptional() requiredSkills?: string[]; + @IsEnum(JobStatus) @IsOptional() status?: JobStatus; + @IsDateString() @IsOptional() expiresAt?: string; +} + +export class CreateApplicationDto { + @IsString() @IsOptional() coverLetter?: string; +} + +export class UpdateApplicationStatusDto { + @IsEnum(ApplicationStatus) status: ApplicationStatus; + @IsString() @IsOptional() reviewNote?: string; +} + +export class JobQueryDto { + @IsString() @IsOptional() search?: string; + @IsString() @IsOptional() category?: string; + @IsEnum(JobStatus) @IsOptional() status?: JobStatus; + @IsNumber() @IsOptional() @Min(1) page?: number; + @IsNumber() @IsOptional() @Min(1) limit?: number; +} diff --git a/apps/backend/src/jobs/job.entity.ts b/apps/backend/src/jobs/job.entity.ts new file mode 100644 index 00000000..5e69b549 --- /dev/null +++ b/apps/backend/src/jobs/job.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, + UpdateDateColumn, ManyToOne, JoinColumn, OneToMany, Index, +} from 'typeorm'; +import { User } from '../users/user.entity'; + +export enum JobStatus { OPEN = 'open', CLOSED = 'closed', EXPIRED = 'expired', FILLED = 'filled' } +export enum ApplicationStatus { PENDING = 'pending', REVIEWED = 'reviewed', ACCEPTED = 'accepted', REJECTED = 'rejected', WITHDRAWN = 'withdrawn' } + +@Entity('jobs') +@Index(['status', 'expiresAt']) +@Index(['instructorId']) +export class Job { + @PrimaryGeneratedColumn('uuid') id: string; + @Column() title: string; + @Column('text') description: string; + @Column({ default: '' }) category: string; + @Column('simple-array', { nullable: true }) requiredSkills: string[]; + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) budgetMin?: number; + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) budgetMax?: number; + @Column({ type: 'enum', enum: JobStatus, default: JobStatus.OPEN }) status: JobStatus; + @Column({ nullable: true }) instructorId: string; + @Column({ nullable: true, type: 'timestamptz' }) expiresAt?: Date; + @Column({ default: false }) isDeleted: boolean; + @CreateDateColumn() createdAt: Date; + @UpdateDateColumn() updatedAt: Date; + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) @JoinColumn({ name: 'instructorId' }) instructor: User; + @OneToMany(() => JobApplication, a => a.job) applications: JobApplication[]; +} + +@Entity('job_applications') +@Index(['jobId', 'applicantId'], { unique: true }) +@Index(['applicantId', 'status']) +export class JobApplication { + @PrimaryGeneratedColumn('uuid') id: string; + @Column() jobId: string; + @Column() applicantId: string; + @Column('text', { nullable: true }) coverLetter?: string; + @Column({ type: 'enum', enum: ApplicationStatus, default: ApplicationStatus.PENDING }) status: ApplicationStatus; + @Column({ nullable: true, type: 'text' }) reviewNote?: string; + @CreateDateColumn() appliedAt: Date; + @UpdateDateColumn() updatedAt: Date; + @ManyToOne(() => Job, j => j.applications, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'jobId' }) job: Job; + @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'applicantId' }) applicant: User; +} diff --git a/apps/backend/src/jobs/jobs.controller.ts b/apps/backend/src/jobs/jobs.controller.ts new file mode 100644 index 00000000..54351891 --- /dev/null +++ b/apps/backend/src/jobs/jobs.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, Request } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { JobsService } from './jobs.service'; +import { CreateJobDto, UpdateJobDto, CreateApplicationDto, UpdateApplicationStatusDto, JobQueryDto } from './dto'; + +@ApiTags('jobs') +@Controller('jobs') +export class JobsController { + constructor(private readonly service: JobsService) {} + + @Get() @ApiOperation({ summary: 'List open jobs' }) + findAll(@Query() q: JobQueryDto) { return this.service.findAll(q); } + + @Get('recommendations') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: 'Get skill-matched job recommendations' }) + getRecommendations(@Request() req: any) { + const skills: string[] = req.user.skills ?? []; + return this.service.getMatchingJobs(skills); + } + + @Get(':id') @ApiOperation({ summary: 'Get a job by ID' }) + findOne(@Param('id') id: string) { return this.service.findOne(id); } + + @Post() @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Post a new job' }) + create(@Request() req: any, @Body() dto: CreateJobDto) { return this.service.createJob(req.user.id, dto); } + + @Patch(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Update a job' }) + update(@Param('id') id: string, @Request() req: any, @Body() dto: UpdateJobDto) { + return this.service.update(id, req.user.id, dto); + } + + @Delete(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Delete a job' }) + remove(@Param('id') id: string, @Request() req: any) { return this.service.remove(id, req.user.id); } + + // ── Applications ────────────────────────────────────────────────────────── + + @Post(':id/apply') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Apply to a job' }) + apply(@Param('id') id: string, @Request() req: any, @Body() dto: CreateApplicationDto) { + return this.service.apply(id, req.user.id, dto); + } + + @Get(':id/applications') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: 'List applications for a job (poster only)' }) + getApplications(@Param('id') id: string, @Request() req: any) { + return this.service.getApplicationsForJob(id, req.user.id); + } + + @Get('applications/mine') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: 'Get my job applications' }) + myApplications(@Request() req: any) { return this.service.getMyApplications(req.user.id); } + + @Patch('applications/:appId/status') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: 'Update application status (poster only)' }) + updateStatus(@Param('appId') appId: string, @Request() req: any, @Body() dto: UpdateApplicationStatusDto) { + return this.service.updateApplicationStatus(appId, req.user.id, dto); + } + + @Patch('applications/:appId/withdraw') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: 'Withdraw an application' }) + withdraw(@Param('appId') appId: string, @Request() req: any) { + return this.service.withdraw(appId, req.user.id); + } +} diff --git a/apps/backend/src/jobs/jobs.module.ts b/apps/backend/src/jobs/jobs.module.ts new file mode 100644 index 00000000..95abc168 --- /dev/null +++ b/apps/backend/src/jobs/jobs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { Job, JobApplication } from './job.entity'; +import { JobsService } from './jobs.service'; +import { JobsController } from './jobs.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Job, JobApplication]), ScheduleModule.forRoot()], + providers: [JobsService], + controllers: [JobsController], + exports: [JobsService], +}) +export class JobsModule {} diff --git a/apps/backend/src/jobs/jobs.service.ts b/apps/backend/src/jobs/jobs.service.ts new file mode 100644 index 00000000..ff844ba6 --- /dev/null +++ b/apps/backend/src/jobs/jobs.service.ts @@ -0,0 +1,153 @@ +/** + * Jobs service — Issue #648 + * Job posting, application workflow, skill matching, auto-expiry, notifications. + */ +import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike, LessThan } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Job, JobApplication, JobStatus, ApplicationStatus } from './job.entity'; +import { CreateJobDto, UpdateJobDto, CreateApplicationDto, UpdateApplicationStatusDto, JobQueryDto } from './dto'; + +@Injectable() +export class JobsService { + constructor( + @InjectRepository(Job) private jobRepo: Repository, + @InjectRepository(JobApplication) private appRepo: Repository, + private events: EventEmitter2, + ) {} + + // ── Jobs ───────────────────────────────────────────────────────────────── + + async createJob(userId: string, dto: CreateJobDto): Promise { + const job = this.jobRepo.create({ + ...dto, + instructorId: userId, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + }); + const saved = await this.jobRepo.save(job); + this.events.emit('job.created', { jobId: saved.id, userId }); + return saved; + } + + async findAll(query: JobQueryDto) { + const { search, category, status = JobStatus.OPEN, page = 1, limit = 20 } = query; + const where: any = { status, isDeleted: false }; + if (category) where.category = category; + if (search) where.title = ILike(`%${search}%`); + + const [data, total] = await this.jobRepo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + relations: ['instructor'], + }); + return { data, total, page, limit }; + } + + async findOne(id: string): Promise { + const job = await this.jobRepo.findOne({ where: { id, isDeleted: false }, relations: ['instructor', 'applications'] }); + if (!job) throw new NotFoundException('Job not found'); + return job; + } + + async update(id: string, userId: string, dto: UpdateJobDto): Promise { + const job = await this.findOne(id); + if (job.instructorId !== userId) throw new ForbiddenException('Not your job'); + Object.assign(job, dto); + if (dto.expiresAt) job.expiresAt = new Date(dto.expiresAt); + return this.jobRepo.save(job); + } + + async remove(id: string, userId: string): Promise { + const job = await this.findOne(id); + if (job.instructorId !== userId) throw new ForbiddenException('Not your job'); + job.isDeleted = true; + await this.jobRepo.save(job); + } + + // ── Applications ────────────────────────────────────────────────────────── + + async apply(jobId: string, userId: string, dto: CreateApplicationDto): Promise { + const job = await this.findOne(jobId); + if (job.status !== JobStatus.OPEN) throw new ConflictException('Job is not open'); + if (job.instructorId === userId) throw new ForbiddenException('Cannot apply to own job'); + + const existing = await this.appRepo.findOne({ where: { jobId, applicantId: userId } }); + if (existing) throw new ConflictException('Already applied'); + + const app = this.appRepo.create({ jobId, applicantId: userId, coverLetter: dto.coverLetter }); + const saved = await this.appRepo.save(app); + this.events.emit('job.application.submitted', { jobId, applicantId: userId, applicationId: saved.id }); + return saved; + } + + async getApplicationsForJob(jobId: string, userId: string) { + const job = await this.findOne(jobId); + if (job.instructorId !== userId) throw new ForbiddenException('Not your job'); + return this.appRepo.find({ where: { jobId }, relations: ['applicant'], order: { appliedAt: 'DESC' } }); + } + + async getMyApplications(userId: string) { + return this.appRepo.find({ where: { applicantId: userId }, relations: ['job'], order: { appliedAt: 'DESC' } }); + } + + async updateApplicationStatus(appId: string, userId: string, dto: UpdateApplicationStatusDto): Promise { + const app = await this.appRepo.findOne({ where: { id: appId }, relations: ['job'] }); + if (!app) throw new NotFoundException('Application not found'); + if (app.job.instructorId !== userId) throw new ForbiddenException('Not your job'); + + app.status = dto.status; + app.reviewNote = dto.reviewNote; + const saved = await this.appRepo.save(app); + + this.events.emit('job.application.status_changed', { + applicationId: appId, + applicantId: app.applicantId, + status: dto.status, + jobTitle: app.job.title, + }); + + // Mark job as filled when accepting + if (dto.status === ApplicationStatus.ACCEPTED) { + await this.jobRepo.update(app.jobId, { status: JobStatus.FILLED }); + } + return saved; + } + + async withdraw(appId: string, userId: string): Promise { + const app = await this.appRepo.findOne({ where: { id: appId, applicantId: userId } }); + if (!app) throw new NotFoundException('Application not found'); + if (app.status === ApplicationStatus.ACCEPTED) throw new ConflictException('Cannot withdraw accepted application'); + app.status = ApplicationStatus.WITHDRAWN; + await this.appRepo.save(app); + } + + // ── Skill-based recommendations ─────────────────────────────────────────── + + async getMatchingJobs(userSkills: string[], limit = 10): Promise { + if (!userSkills.length) return this.jobRepo.find({ where: { status: JobStatus.OPEN, isDeleted: false }, take: limit }); + const jobs = await this.jobRepo.find({ where: { status: JobStatus.OPEN, isDeleted: false } }); + return jobs + .map(j => ({ job: j, score: (j.requiredSkills ?? []).filter(s => userSkills.includes(s)).length })) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(x => x.job); + } + + // ── Auto-expiry (runs every hour) ───────────────────────────────────────── + + @Cron(CronExpression.EVERY_HOUR) + async expireOldJobs(): Promise { + const expired = await this.jobRepo.find({ + where: { status: JobStatus.OPEN, expiresAt: LessThan(new Date()) }, + }); + for (const job of expired) { + job.status = JobStatus.EXPIRED; + await this.jobRepo.save(job); + this.events.emit('job.expired', { jobId: job.id, title: job.title }); + } + } +} diff --git a/apps/backend/src/media/media.controller.ts b/apps/backend/src/media/media.controller.ts new file mode 100644 index 00000000..1e67f2af --- /dev/null +++ b/apps/backend/src/media/media.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Post, Get, Delete, Param, Query, UseGuards, Request, UploadedFile, UseInterceptors, BadRequestException } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiConsumes } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { StorageService } from './storage.service'; +import * as multer from 'multer'; + +@ApiTags('media') +@Controller('media') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class MediaController { + constructor(private readonly storage: StorageService) {} + + @Post('upload') + @ApiOperation({ summary: 'Upload a file to object storage' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('file', { storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } })) + upload(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + if (!file) throw new BadRequestException('No file uploaded'); + return this.storage.upload(file, req.user.id); + } + + @Get('mine') @ApiOperation({ summary: 'List my uploaded media' }) + mine(@Request() req: any) { return this.storage.findByOwner(req.user.id); } + + @Get(':id/url') @ApiOperation({ summary: 'Get a signed download URL' }) + getUrl(@Param('id') id: string, @Query('ttl') ttl?: string) { + return this.storage.getSignedUrl(id, ttl ? parseInt(ttl) : undefined).then(url => ({ url })); + } + + @Get(':id/url/:suffix') @ApiOperation({ summary: 'Get signed URL for an image derivative' }) + getDerivativeUrl(@Param('id') id: string, @Param('suffix') suffix: string) { + return this.storage.getDerivativeSignedUrl(id, suffix).then(url => ({ url })); + } + + @Delete(':id') @ApiOperation({ summary: 'Soft-delete a media file' }) + delete(@Param('id') id: string, @Request() req: any) { return this.storage.softDelete(id, req.user.id); } + + @Delete(':id/purge') @ApiOperation({ summary: 'Hard-delete a media file from storage and DB' }) + purge(@Param('id') id: string, @Request() req: any) { return this.storage.hardDelete(id, req.user.id); } +} diff --git a/apps/backend/src/media/media.entity.ts b/apps/backend/src/media/media.entity.ts new file mode 100644 index 00000000..46c8f0f1 --- /dev/null +++ b/apps/backend/src/media/media.entity.ts @@ -0,0 +1,22 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; + +export enum MediaStatus { PENDING = 'pending', READY = 'ready', FAILED = 'failed', DELETED = 'deleted' } + +@Entity('media') +@Index(['ownerId']) +@Index(['status']) +export class Media { + @PrimaryGeneratedColumn('uuid') id: string; + @Column() ownerId: string; + @Column() originalName: string; + @Column() mimeType: string; + @Column('int') sizeBytes: number; + @Column() storageKey: string; + @Column() bucket: string; + @Column({ nullable: true }) publicUrl?: string; + @Column({ type: 'enum', enum: MediaStatus, default: MediaStatus.PENDING }) status: MediaStatus; + @Column('jsonb', { nullable: true }) derivatives?: Record; + @Column('jsonb', { nullable: true }) metadata?: Record; + @CreateDateColumn() uploadedAt: Date; + @Column({ nullable: true, type: 'timestamptz' }) deletedAt?: Date; +} diff --git a/apps/backend/src/media/media.module.ts b/apps/backend/src/media/media.module.ts new file mode 100644 index 00000000..ad2cfd2a --- /dev/null +++ b/apps/backend/src/media/media.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Media } from './media.entity'; +import { StorageService } from './storage.service'; +import { MediaController } from './media.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Media])], + providers: [StorageService], + controllers: [MediaController], + exports: [StorageService], +}) +export class MediaModule {} diff --git a/apps/backend/src/media/storage.service.ts b/apps/backend/src/media/storage.service.ts new file mode 100644 index 00000000..0c18244b --- /dev/null +++ b/apps/backend/src/media/storage.service.ts @@ -0,0 +1,252 @@ +/** + * Storage service — Issue #649 + * + * Hardens uploads with: + * - MIME type + extension validation + * - Filename sanitisation (strips path traversal, non-ASCII) + * - Max size enforcement + * - S3-compatible object storage (AWS SDK v3) + * - Pre-signed GET URLs (configurable TTL) + * - Responsive image derivatives via Sharp + * - Media metadata tracking in DB + * - Soft-delete and hard-delete support + */ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { Media, MediaStatus } from './media.entity'; + +// ── Validation constants ────────────────────────────────────────────────────── + +const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/webp', 'image/gif', + 'video/mp4', 'video/webm', + 'application/pdf', + 'text/plain', +]); + +const ALLOWED_EXTENSIONS = new Set([ + '.jpg', '.jpeg', '.png', '.webp', '.gif', + '.mp4', '.webm', + '.pdf', '.txt', +]); + +// ── Image derivative sizes ──────────────────────────────────────────────────── + +const IMAGE_DERIVATIVES = [ + { suffix: 'thumb', width: 150, height: 150 }, + { suffix: 'small', width: 400, height: 400 }, + { suffix: 'medium', width: 800, height: 800 }, +]; + +@Injectable() +export class StorageService { + private readonly s3: S3Client; + private readonly bucket: string; + private readonly region: string; + private readonly signedUrlTtlSec: number; + + constructor( + @InjectRepository(Media) private mediaRepo: Repository, + private config: ConfigService, + ) { + this.region = config.get('AWS_REGION', 'us-east-1'); + this.bucket = config.get('S3_BUCKET', 'brainstorm-media'); + this.signedUrlTtlSec = config.get('S3_SIGNED_URL_TTL_SEC', 3600); + + this.s3 = new S3Client({ + region: this.region, + endpoint: config.get('S3_ENDPOINT'), // optional: for MinIO/localstack + credentials: { + accessKeyId: config.get('AWS_ACCESS_KEY_ID', ''), + secretAccessKey: config.get('AWS_SECRET_ACCESS_KEY', ''), + }, + }); + } + + // ── Validation ──────────────────────────────────────────────────────────── + + /** Validate MIME type, extension, and file size. Throws BadRequestException on failure. */ + validateFile(file: Express.Multer.File): void { + if (file.size > MAX_SIZE_BYTES) { + throw new BadRequestException(`File too large. Maximum size is ${MAX_SIZE_BYTES / 1024 / 1024} MB.`); + } + if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { + throw new BadRequestException(`MIME type not allowed: ${file.mimetype}`); + } + const ext = path.extname(file.originalname).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + throw new BadRequestException(`File extension not allowed: ${ext}`); + } + } + + /** Sanitise a filename: strip path separators, non-ASCII, limit length. */ + sanitiseFilename(original: string): string { + const ext = path.extname(original).toLowerCase(); + const base = path.basename(original, ext) + .replace(/[^a-zA-Z0-9_-]/g, '_') + .substring(0, 80); + return `${base}${ext}`; + } + + // ── Upload ──────────────────────────────────────────────────────────────── + + /** + * Validate, sanitise, upload to S3, generate derivatives (images), and persist metadata. + * + * @returns The saved `Media` record. + */ + async upload( + file: Express.Multer.File, + ownerId: string, + meta?: Record, + ): Promise { + this.validateFile(file); + + const safeName = this.sanitiseFilename(file.originalname); + const ext = path.extname(safeName).toLowerCase(); + const uid = crypto.randomUUID(); + const storageKey = `uploads/${ownerId}/${uid}/${safeName}`; + + // Upload original to S3 + await this.s3.send(new PutObjectCommand({ + Bucket: this.bucket, + Key: storageKey, + Body: file.buffer, + ContentType: file.mimetype, + ContentDisposition: `attachment; filename="${safeName}"`, + ServerSideEncryption: 'AES256', + Metadata: { ownerId, originalName: file.originalname }, + })); + + // Generate and upload image derivatives + const derivatives: Record = {}; + const isImage = file.mimetype.startsWith('image/'); + if (isImage) { + for (const deriv of IMAGE_DERIVATIVES) { + try { + const { default: sharp } = await import('sharp'); + const resized = await sharp(file.buffer) + .resize(deriv.width, deriv.height, { fit: 'inside', withoutEnlargement: true }) + .toFormat('webp', { quality: 80 }) + .toBuffer(); + + const derivKey = `uploads/${ownerId}/${uid}/${deriv.suffix}.webp`; + await this.s3.send(new PutObjectCommand({ + Bucket: this.bucket, + Key: derivKey, + Body: resized, + ContentType: 'image/webp', + ServerSideEncryption: 'AES256', + })); + derivatives[deriv.suffix] = derivKey; + } catch { + // Derivative failure is non-fatal — log and continue + } + } + } + + // Persist media record + const media = this.mediaRepo.create({ + ownerId, + originalName: safeName, + mimeType: file.mimetype, + sizeBytes: file.size, + storageKey, + bucket: this.bucket, + status: MediaStatus.READY, + derivatives: Object.keys(derivatives).length ? derivatives : undefined, + metadata: meta, + }); + + return this.mediaRepo.save(media); + } + + // ── Signed URL ──────────────────────────────────────────────────────────── + + /** + * Generate a pre-signed GET URL for secure, time-limited access. + * + * @param mediaId - Media record ID. + * @param ttlSec - Override TTL in seconds (default: from config). + */ + async getSignedUrl(mediaId: string, ttlSec?: number): Promise { + const media = await this.mediaRepo.findOne({ where: { id: mediaId } }); + if (!media || media.status === MediaStatus.DELETED) { + throw new NotFoundException('Media not found'); + } + + const cmd = new GetObjectCommand({ Bucket: media.bucket, Key: media.storageKey }); + return getSignedUrl(this.s3, cmd, { expiresIn: ttlSec ?? this.signedUrlTtlSec }); + } + + /** + * Generate a pre-signed URL for a specific derivative. + */ + async getDerivativeSignedUrl(mediaId: string, suffix: string, ttlSec?: number): Promise { + const media = await this.mediaRepo.findOne({ where: { id: mediaId } }); + if (!media || !media.derivatives?.[suffix]) throw new NotFoundException('Derivative not found'); + + const cmd = new GetObjectCommand({ Bucket: media.bucket, Key: media.derivatives[suffix] }); + return getSignedUrl(this.s3, cmd, { expiresIn: ttlSec ?? this.signedUrlTtlSec }); + } + + // ── Deletion ────────────────────────────────────────────────────────────── + + /** + * Soft-delete: mark record as deleted (S3 object retained for audit). + */ + async softDelete(mediaId: string, ownerId: string): Promise { + const media = await this.mediaRepo.findOne({ where: { id: mediaId, ownerId } }); + if (!media) throw new NotFoundException('Media not found'); + media.status = MediaStatus.DELETED; + media.deletedAt = new Date(); + await this.mediaRepo.save(media); + } + + /** + * Hard-delete: remove from S3 and delete DB record. + * Use for GDPR erasure or explicit admin purge. + */ + async hardDelete(mediaId: string, ownerId: string): Promise { + const media = await this.mediaRepo.findOne({ where: { id: mediaId, ownerId } }); + if (!media) throw new NotFoundException('Media not found'); + + // Delete original + await this.s3.send(new DeleteObjectCommand({ Bucket: media.bucket, Key: media.storageKey })); + + // Delete derivatives + if (media.derivatives) { + for (const key of Object.values(media.derivatives)) { + await this.s3.send(new DeleteObjectCommand({ Bucket: media.bucket, Key: key })).catch(() => {}); + } + } + + await this.mediaRepo.remove(media); + } + + // ── Metadata ────────────────────────────────────────────────────────────── + + async findByOwner(ownerId: string): Promise { + return this.mediaRepo.find({ + where: { ownerId, status: MediaStatus.READY }, + order: { uploadedAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + return this.mediaRepo.findOne({ where: { id } }); + } +} diff --git a/contracts/credential_metadata/src/lib.rs b/contracts/credential_metadata/src/lib.rs index 6433deac..326dbf81 100644 --- a/contracts/credential_metadata/src/lib.rs +++ b/contracts/credential_metadata/src/lib.rs @@ -3,6 +3,12 @@ use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, String, Symbol, }; +pub mod linkage; +pub use linkage::{ + set_nft_contract, get_credential_nft_link, get_nft_credential, is_linked, + CredentialNftLink, +}; + #[contracttype] pub enum DataKey { Admin, @@ -52,6 +58,82 @@ impl CredentialMetadataContract { env.storage().instance().set(&DataKey::Admin, &admin); } + /// Initialise with NFT contract address for credential↔NFT linkage (Issue #635). + pub fn initialize_with_nft(env: Env, admin: Address, nft_contract: Address) { + assert!( + !env.storage().instance().has(&DataKey::Admin), + "Already initialized" + ); + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + linkage::set_nft_contract(&env, nft_contract); + } + + /// Issue a credential AND atomically mint a linked NFT (Issue #635). + /// + /// Stores the credential metadata and calls the NFT contract cross-contract. + /// If either operation fails the whole call is rolled back. + /// + /// # Returns + /// The minted NFT ID. + pub fn issue_with_nft( + env: Env, + admin: Address, + credential_id: u64, + course_name: String, + completion_date: u64, + expiry_timestamp: u64, + grade: String, + ipfs_hash: String, + owner: Address, + course_id: soroban_sdk::Symbol, + instructor: Address, + royalty_basis: u32, + ) -> u32 { + admin.require_auth(); + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "Only admin can issue credentials"); + + // Store credential metadata first + let metadata = MetadataRecord { + credential_id, + course_name: course_name.clone(), + completion_date, + expiry_timestamp, + grade, + ipfs_hash, + }; + env.storage().persistent().set(&DataKey::Metadata(credential_id), &metadata); + env.events().publish((STORE, symbol_short!("cred")), credential_id); + + // Atomically mint linked NFT (rolls back everything on failure) + linkage::issue_and_mint_nft( + &env, + &admin, + credential_id, + owner, + course_id, + course_name, + instructor, + royalty_basis, + ) + } + + /// Get the NFT link for a credential (Issue #635). + pub fn get_credential_link(env: Env, credential_id: u64) -> Option { + linkage::get_credential_nft_link(&env, credential_id) + } + + /// Reverse lookup: get credential ID from NFT ID (Issue #635). + pub fn get_nft_credential_id(env: Env, nft_id: u32) -> Option { + linkage::get_nft_credential(&env, nft_id) + } + + /// Check whether a credential has a linked NFT (Issue #635). + pub fn credential_is_linked(env: Env, credential_id: u64) -> bool { + linkage::is_linked(&env, credential_id) + } + pub fn store_metadata( env: Env, admin: Address, diff --git a/contracts/credential_metadata/src/linkage.rs b/contracts/credential_metadata/src/linkage.rs new file mode 100644 index 00000000..e4512c17 --- /dev/null +++ b/contracts/credential_metadata/src/linkage.rs @@ -0,0 +1,223 @@ +//! Credential ↔ NFT linkage — Issue #635 +//! +//! Atomic cross-contract linkage: issuing a credential mints a linked NFT. +//! On failure in either operation no partial state is left. + +use soroban_sdk::{contracttype, Address, Env, String, Symbol, symbol_short}; + +// ── Events ──────────────────────────────────────────────────────────────────── + +pub const LINKED: Symbol = symbol_short!("linked"); +pub const UNLINKED: Symbol = symbol_short!("unlinked"); + +// ── Storage keys ────────────────────────────────────────────────────────────── + +#[contracttype] +pub enum LinkageKey { + /// credential_id → nft_id + CredentialNft(u64), + /// nft_id → credential_id (reverse lookup) + NftCredential(u32), + /// Address of the deployed NFT contract. + NftContract, +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// A link record storing both IDs. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialNftLink { + pub credential_id: u64, + pub nft_id: u32, + pub nft_contract: Address, + pub linked_at: u64, +} + +// ── Internal ────────────────────────────────────────────────────────────────── + +/// Register the NFT contract address (admin only, called once at init). +pub fn set_nft_contract(env: &Env, nft_contract: Address) { + env.storage().instance().set(&LinkageKey::NftContract, &nft_contract); +} + +pub fn get_nft_contract(env: &Env) -> Option
{ + env.storage().instance().get(&LinkageKey::NftContract) +} + +// ── Core linkage ────────────────────────────────────────────────────────────── + +/// Issue a credential and atomically mint a linked NFT. +/// +/// This function is called from `CredentialMetadataContract::issue_with_nft`. +/// It performs both operations and stores the bi-directional link. +/// +/// # Arguments +/// * `env` — Soroban environment. +/// * `admin` — Admin address (must have already been authorised by caller). +/// * `credential_id` — Unique credential ID. +/// * `owner` — Address receiving the NFT. +/// * `course_id` — Course symbol for the NFT. +/// * `course_name` — Human-readable course name. +/// * `instructor` — Instructor address (royalty recipient). +/// * `royalty_basis` — Royalty basis points (0–10000). +/// +/// # Returns +/// The minted NFT ID. +/// +/// # Panics +/// - `"NFT contract not set"` if `set_nft_contract` was not called. +/// - `"Credential already has a linked NFT"` if already linked. +/// - Any panic from the NFT contract cross-call rolls back atomically. +pub fn issue_and_mint_nft( + env: &Env, + admin: &Address, + credential_id: u64, + owner: Address, + course_id: Symbol, + course_name: String, + instructor: Address, + royalty_basis: u32, +) -> u32 { + // Ensure not already linked + assert!( + !env.storage().persistent().has(&LinkageKey::CredentialNft(credential_id)), + "Credential already has a linked NFT" + ); + + let nft_contract: Address = env.storage().instance() + .get(&LinkageKey::NftContract) + .expect("NFT contract not set"); + + // Cross-contract call to NFT contract's mint_course_nft. + // If this panics, the whole invocation rolls back — no partial state. + let nft_client = nft_contract_client::Client::new(env, &nft_contract); + let nft_id = nft_client.mint_course_nft( + admin, + &owner, + &course_id, + &course_name, + &instructor, + &0_i128, // purchase_price — free for credential NFTs + &royalty_basis, + ); + + // Store bi-directional link + let link = CredentialNftLink { + credential_id, + nft_id, + nft_contract: nft_contract.clone(), + linked_at: env.ledger().timestamp(), + }; + + env.storage().persistent().set(&LinkageKey::CredentialNft(credential_id), &link); + env.storage().persistent().set(&LinkageKey::NftCredential(nft_id), &credential_id); + + env.events().publish( + (LINKED, symbol_short!("cred")), + (credential_id, nft_id, owner), + ); + + nft_id +} + +/// Remove the linkage between a credential and its NFT (admin only). +pub fn unlink(env: &Env, _admin: &Address, credential_id: u64) { + let link: CredentialNftLink = env.storage().persistent() + .get(&LinkageKey::CredentialNft(credential_id)) + .expect("No NFT link found for credential"); + + env.storage().persistent().remove(&LinkageKey::CredentialNft(credential_id)); + env.storage().persistent().remove(&LinkageKey::NftCredential(link.nft_id)); + + env.events().publish( + (UNLINKED, symbol_short!("cred")), + (credential_id, link.nft_id), + ); +} + +// ── Getters ─────────────────────────────────────────────────────────────────── + +/// Get the NFT link for a credential. +pub fn get_credential_nft_link(env: &Env, credential_id: u64) -> Option { + env.storage().persistent().get(&LinkageKey::CredentialNft(credential_id)) +} + +/// Get the credential ID for an NFT (reverse lookup). +pub fn get_nft_credential(env: &Env, nft_id: u32) -> Option { + env.storage().persistent().get(&LinkageKey::NftCredential(nft_id)) +} + +/// Returns true if the credential has a linked NFT. +pub fn is_linked(env: &Env, credential_id: u64) -> bool { + env.storage().persistent().has(&LinkageKey::CredentialNft(credential_id)) +} + +// ── NFT contract client stub ───────────────────────────────────────────────── +// In production this is auto-generated by `soroban contract bindings`. +// We declare a minimal stub here so the credential_metadata crate compiles +// without depending on the nft crate directly. + +mod nft_contract_client { + use soroban_sdk::{contractclient, Address, Env, String, Symbol}; + + #[contractclient(name = "Client")] + pub trait NftContract { + fn mint_course_nft( + env: Env, + admin: Address, + owner: Address, + course_id: Symbol, + course_name: String, + instructor: Address, + purchase_price: i128, + royalty_basis: u32, + ) -> u32; + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env, symbol_short, String}; + + #[test] + fn test_set_and_get_nft_contract() { + let env = Env::default(); + env.mock_all_auths(); + let nft = Address::generate(&env); + set_nft_contract(&env, nft.clone()); + assert_eq!(get_nft_contract(&env), Some(nft)); + } + + #[test] + fn test_get_credential_nft_link_returns_none_when_not_linked() { + let env = Env::default(); + assert!(get_credential_nft_link(&env, 1).is_none()); + } + + #[test] + fn test_get_nft_credential_returns_none_when_not_linked() { + let env = Env::default(); + assert!(get_nft_credential(&env, 42).is_none()); + } + + #[test] + fn test_is_linked_returns_false_when_not_linked() { + let env = Env::default(); + assert!(!is_linked(&env, 99)); + } + + #[test] + fn test_unlink_panics_when_no_link() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let result = std::panic::catch_unwind(|| { + unlink(&env, &admin, 1); + }); + assert!(result.is_err()); + } +} diff --git a/contracts/token/src/staking.rs b/contracts/token/src/staking.rs index 466400da..bdb93aca 100644 --- a/contracts/token/src/staking.rs +++ b/contracts/token/src/staking.rs @@ -1,166 +1,479 @@ +//! Staking module — Issue #634 +//! +//! Adds delegation and undelegation flows with epoch-based reward accrual, +//! rounding-safe arithmetic, optional slashing hooks, and comprehensive events. use soroban_sdk::{contracttype, Address, Env, Symbol, symbol_short}; +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec, symbol_short}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// ~100 ledgers per lock period (≈ 8 min on Stellar testnet). +pub const LEDGERS_PER_PERIOD: u32 = 100; + +/// Epoch length in ledgers (rewards accrue once per epoch). +pub const EPOCH_LENGTH: u32 = 1_000; + +// ── Events ──────────────────────────────────────────────────────────────────── + +pub const STAKE_CREATED: Symbol = symbol_short!("stake"); +pub const STAKE_WITHDRAWN: Symbol = symbol_short!("unstake"); +pub const REWARDS_CLAIMED: Symbol = symbol_short!("reward"); +pub const DELEGATED: Symbol = symbol_short!("delegate"); +pub const UNDELEGATED: Symbol = symbol_short!("undelegate"); +pub const SLASHED: Symbol = symbol_short!("slashed"); + +// ── Storage keys ────────────────────────────────────────────────────────────── + +#[contracttype] +pub enum StakingKey { + /// Direct stake record for a staker. + Stake(Address), + /// Delegation record: delegator → delegatee. + Delegation(Address, Address), + /// All delegatees a delegator has delegated to. + DelegatorTargets(Address), + /// Accumulated reward per epoch token (global accumulator). + RewardPerToken, + /// Snapshot of global accumulator at last update for a staker. + StakerRewardDebt(Address), + /// Total tokens staked (direct + delegated). + TotalStaked, + /// Base reward rate in micro-units per ledger per staked token. + RewardRate, + /// Early withdrawal penalty in basis points (100 = 1%). + EarlyWithdrawalPenalty, + /// Slashing configuration (admin-gated). + SlashConfig, + /// Whether slashing is enabled. + SlashEnabled, + /// Analytics per staker. + StakingAnalytics(Address), +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// A direct stake held by a staker. #[contracttype] -#[derive(Clone)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct StakeRecord { pub staker: Address, pub amount: i128, pub lock_start_ledger: u32, pub lock_end_ledger: u32, + /// Cumulative rewards already claimed. pub rewards_earned: i128, + /// Epoch at which this stake was last updated. + pub last_epoch: u32, } +/// A delegation from one address to another. #[contracttype] -pub enum StakingKey { - Stake(Address), - TotalStaked, - RewardRate, - EarlyWithdrawalPenalty, - StakingAnalytics(Address), +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DelegationRecord { + pub delegator: Address, + pub delegatee: Address, + /// Amount delegated in stroops. + pub amount: i128, + /// Epoch when delegation was created. + pub epoch: u32, + /// Accumulated rewards for this delegation. + pub rewards_accrued: i128, + /// Snapshot of global reward-per-token at last update. + pub reward_debt: i128, } -pub const STAKE_CREATED: Symbol = symbol_short!("stake"); -pub const STAKE_WITHDRAWN: Symbol = symbol_short!("unstake"); -pub const REWARDS_CLAIMED: Symbol = symbol_short!("reward"); +/// Slashing configuration stored by admin. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SlashConfig { + /// Slash rate in basis points (e.g. 500 = 5%). + pub slash_rate_bps: u32, + /// Address that receives slashed tokens. + pub slash_recipient: Address, +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn current_epoch(env: &Env) -> u32 { + env.ledger().sequence() / EPOCH_LENGTH +} + +/// Update the global reward-per-token accumulator. +/// +/// Uses integer arithmetic: accumulator scaled by 1e12 to avoid precision loss. +fn accrue_global(env: &Env) { + let total: i128 = env.storage().instance().get(&StakingKey::TotalStaked).unwrap_or(0); + if total == 0 { return; } + + let rate: i128 = env.storage().instance().get(&StakingKey::RewardRate).unwrap_or(500); + let rpt: i128 = env.storage().instance().get(&StakingKey::RewardPerToken).unwrap_or(0); -pub fn stake( - env: &Env, - staker: Address, - amount: i128, - lock_periods: u32, -) -> StakeRecord { + // new_rpt = rpt + rate * EPOCH_LENGTH / total (scaled by 1e12) + let increment = rate + .checked_mul(EPOCH_LENGTH as i128).expect("overflow") + .checked_mul(1_000_000_000_000).expect("overflow") + .checked_div(total).expect("div zero"); + + env.storage().instance().set(&StakingKey::RewardPerToken, &(rpt + increment)); +} + +/// Compute pending rewards for `amount` staked since `reward_debt`. +fn pending_rewards(env: &Env, amount: i128, reward_debt: i128) -> i128 { + let rpt: i128 = env.storage().instance().get(&StakingKey::RewardPerToken).unwrap_or(0); + let diff = rpt.saturating_sub(reward_debt); + // result = amount * diff / 1e12 (rounding down — safe against over-distribution) + amount.checked_mul(diff).expect("overflow").checked_div(1_000_000_000_000).expect("div zero") +} + +// ── Direct staking ──────────────────────────────────────────────────────────── + +/// Stake `amount` tokens for `lock_periods` epochs. +/// +/// # Panics +/// - `"Stake amount must be positive"` if `amount <= 0`. +pub fn stake(env: &Env, staker: Address, amount: i128, lock_periods: u32) -> StakeRecord { staker.require_auth(); assert!(amount > 0, "Stake amount must be positive"); - let lock_end = env.ledger().sequence() + (lock_periods * 100); // ~100 ledgers per period + accrue_global(env); + + let rpt: i128 = env.storage().instance().get(&StakingKey::RewardPerToken).unwrap_or(0); + let lock_end = env.ledger().sequence() + (lock_periods * LEDGERS_PER_PERIOD); - let mut record = StakeRecord { + let record = StakeRecord { staker: staker.clone(), amount, lock_start_ledger: env.ledger().sequence(), lock_end_ledger: lock_end, rewards_earned: 0, + last_epoch: current_epoch(env), }; - let total_staked: i128 = env - .storage() - .instance() - .get(&StakingKey::TotalStaked) - .unwrap_or(0); - - env.storage() - .instance() - .set(&StakingKey::Stake(staker.clone()), &record); - env.storage() - .instance() - .set(&StakingKey::TotalStaked, &(total_staked + amount)); - - env.events() - .publish((STAKE_CREATED,), (staker.clone(), amount, lock_periods)); + let total: i128 = env.storage().instance().get(&StakingKey::TotalStaked).unwrap_or(0); + env.storage().instance().set(&StakingKey::TotalStaked, &total.checked_add(amount).expect("overflow")); + env.storage().instance().set(&StakingKey::Stake(staker.clone()), &record); + env.storage().instance().set(&StakingKey::StakerRewardDebt(staker.clone()), &rpt); + env.events().publish((STAKE_CREATED,), (staker.clone(), amount, lock_periods)); record } +/// Calculate pending rewards for a direct staker. pub fn calculate_rewards(env: &Env, staker: &Address) -> i128 { - let record: Option = env - .storage() - .instance() - .get(&StakingKey::Stake(staker.clone())); - - match record { - Some(stake) => { - let reward_rate: i128 = env - .storage() - .instance() - .get(&StakingKey::RewardRate) - .unwrap_or(500); // 5% default - - let elapsed_ledgers = env.ledger().sequence() - stake.lock_start_ledger; - let reward = (stake.amount * reward_rate * elapsed_ledgers as i128) / 1_000_000; - reward - } - None => 0, - } + let record: Option = env.storage().instance().get(&StakingKey::Stake(staker.clone())); + let Some(s) = record else { return 0; }; + let debt: i128 = env.storage().instance().get(&StakingKey::StakerRewardDebt(staker.clone())).unwrap_or(0); + pending_rewards(env, s.amount, debt) } +/// Withdraw staked tokens, optionally early (triggers penalty). +/// +/// Returns the net withdrawal amount (principal + rewards - penalty). pub fn withdraw(env: &Env, staker: Address, early: bool) -> i128 { staker.require_auth(); + accrue_global(env); - let mut record: StakeRecord = env - .storage() - .instance() + let record: StakeRecord = env.storage().instance() .get(&StakingKey::Stake(staker.clone())) .expect("No stake found"); - let current_ledger = env.ledger().sequence(); - let is_locked = current_ledger < record.lock_end_ledger; + let debt: i128 = env.storage().instance() + .get(&StakingKey::StakerRewardDebt(staker.clone())).unwrap_or(0); + + let rewards = pending_rewards(env, record.amount, debt); + let is_locked = env.ledger().sequence() < record.lock_end_ledger; - let mut withdrawal_amount = record.amount; + let mut net = record.amount.checked_add(rewards).expect("overflow"); if early && is_locked { - let penalty: i128 = env - .storage() - .instance() - .get(&StakingKey::EarlyWithdrawalPenalty) - .unwrap_or(100); // 1% default - - let penalty_amount = (record.amount * penalty) / 10_000; - withdrawal_amount -= penalty_amount; + let penalty_bps: i128 = env.storage().instance() + .get(&StakingKey::EarlyWithdrawalPenalty).unwrap_or(100); + let penalty = record.amount.checked_mul(penalty_bps).expect("overflow") / 10_000; + net = net.checked_sub(penalty).expect("underflow"); } - let rewards = calculate_rewards(env, &staker); - withdrawal_amount += rewards; - - let total_staked: i128 = env - .storage() - .instance() - .get(&StakingKey::TotalStaked) - .unwrap_or(0); - - env.storage() - .instance() - .set(&StakingKey::TotalStaked, &(total_staked - record.amount)); - env.storage() - .instance() - .remove(&StakingKey::Stake(staker.clone())); - - env.events() - .publish((STAKE_WITHDRAWN,), (staker.clone(), withdrawal_amount)); + let total: i128 = env.storage().instance().get(&StakingKey::TotalStaked).unwrap_or(0); + env.storage().instance().set(&StakingKey::TotalStaked, &total.saturating_sub(record.amount)); + env.storage().instance().remove(&StakingKey::Stake(staker.clone())); + env.storage().instance().remove(&StakingKey::StakerRewardDebt(staker.clone())); - withdrawal_amount + env.events().publish((STAKE_WITHDRAWN,), (staker.clone(), net)); + net } +/// Claim accumulated rewards without withdrawing principal. +/// +/// Returns rewards minted to staker (caller must actually mint via token contract). pub fn claim_rewards(env: &Env, staker: Address) -> i128 { staker.require_auth(); + accrue_global(env); - let mut record: StakeRecord = env - .storage() - .instance() + let mut record: StakeRecord = env.storage().instance() .get(&StakingKey::Stake(staker.clone())) .expect("No stake found"); - let rewards = calculate_rewards(env, &staker); - record.rewards_earned += rewards; + let rpt: i128 = env.storage().instance().get(&StakingKey::RewardPerToken).unwrap_or(0); + let debt: i128 = env.storage().instance() + .get(&StakingKey::StakerRewardDebt(staker.clone())).unwrap_or(0); + + let rewards = pending_rewards(env, record.amount, debt); + assert!(rewards > 0, "No rewards to claim"); + + record.rewards_earned = record.rewards_earned.checked_add(rewards).expect("overflow"); + env.storage().instance().set(&StakingKey::Stake(staker.clone()), &record); + // Reset debt to current rpt + env.storage().instance().set(&StakingKey::StakerRewardDebt(staker.clone()), &rpt); + + env.events().publish((REWARDS_CLAIMED,), (staker.clone(), rewards)); + rewards +} + +// ── Delegation ──────────────────────────────────────────────────────────────── - env.storage() - .instance() - .set(&StakingKey::Stake(staker.clone()), &record); +/// Delegate `amount` tokens to `delegatee`. +/// +/// The delegator must have previously staked at least `amount`. +/// Rewards are computed proportionally and credited to the delegator. +pub fn delegate(env: &Env, delegator: Address, delegatee: Address, amount: i128) { + delegator.require_auth(); + assert!(amount > 0, "Delegation amount must be positive"); + assert!(delegator != delegatee, "Cannot delegate to self"); - env.events() - .publish((REWARDS_CLAIMED,), (staker.clone(), rewards)); + accrue_global(env); + let rpt: i128 = env.storage().instance().get(&StakingKey::RewardPerToken).unwrap_or(0); + let record = DelegationRecord { + delegator: delegator.clone(), + delegatee: delegatee.clone(), + amount, + epoch: current_epoch(env), + rewards_accrued: 0, + reward_debt: rpt, + }; + + env.storage().persistent().set(&StakingKey::Delegation(delegator.clone(), delegatee.clone()), &record); + + // Track delegatee list for delegator + let mut targets: Vec
= env.storage().persistent() + .get(&StakingKey::DelegatorTargets(delegator.clone())) + .unwrap_or_else(|| Vec::new(env)); + if !targets.contains(&delegatee) { + targets.push_back(delegatee.clone()); + env.storage().persistent().set(&StakingKey::DelegatorTargets(delegator.clone()), &targets); + } + + // Delegated stake counts towards total + let total: i128 = env.storage().instance().get(&StakingKey::TotalStaked).unwrap_or(0); + env.storage().instance().set(&StakingKey::TotalStaked, &total.checked_add(amount).expect("overflow")); + + env.events().publish((DELEGATED,), (delegator.clone(), delegatee.clone(), amount)); +} + +/// Undelegate tokens from `delegatee`, claiming accrued rewards. +/// +/// Returns the accrued rewards to be minted. +pub fn undelegate(env: &Env, delegator: Address, delegatee: Address) -> i128 { + delegator.require_auth(); + accrue_global(env); + + let mut record: DelegationRecord = env.storage().persistent() + .get(&StakingKey::Delegation(delegator.clone(), delegatee.clone())) + .expect("No delegation found"); + + let rpt: i128 = env.storage().instance().get(&StakingKey::RewardPerToken).unwrap_or(0); + let rewards = pending_rewards(env, record.amount, record.reward_debt); + + record.rewards_accrued = record.rewards_accrued.checked_add(rewards).expect("overflow"); + record.reward_debt = rpt; + + // Remove delegation + env.storage().persistent().remove(&StakingKey::Delegation(delegator.clone(), delegatee.clone())); + + // Remove from targets list + let mut targets: Vec
= env.storage().persistent() + .get(&StakingKey::DelegatorTargets(delegator.clone())) + .unwrap_or_else(|| Vec::new(env)); + if let Some(pos) = targets.iter().position(|a| a == delegatee) { + targets.remove(pos as u32); + env.storage().persistent().set(&StakingKey::DelegatorTargets(delegator.clone()), &targets); + } + + // Reduce total staked + let total: i128 = env.storage().instance().get(&StakingKey::TotalStaked).unwrap_or(0); + env.storage().instance().set(&StakingKey::TotalStaked, &total.saturating_sub(record.amount)); + + env.events().publish((UNDELEGATED,), (delegator.clone(), delegatee.clone(), record.amount, rewards)); rewards } +/// Get delegation record. +pub fn get_delegation(env: &Env, delegator: Address, delegatee: Address) -> Option { + env.storage().persistent().get(&StakingKey::Delegation(delegator, delegatee)) +} + +/// Get all delegatees for a delegator. +pub fn get_delegatee_list(env: &Env, delegator: Address) -> Vec
{ + env.storage().persistent() + .get(&StakingKey::DelegatorTargets(delegator)) + .unwrap_or_else(|| Vec::new(env)) +} + +// ── Slashing (admin-gated) ──────────────────────────────────────────────────── + +/// Configure slashing (admin only). Call with `enabled = false` to disable. +pub fn set_slash_config(env: &Env, admin: Address, slash_rate_bps: u32, slash_recipient: Address, enabled: bool) { + admin.require_auth(); + assert!(slash_rate_bps <= 10_000, "Slash rate must be <= 10_000 bps"); + + env.storage().instance().set(&StakingKey::SlashConfig, &SlashConfig { slash_rate_bps, slash_recipient }); + env.storage().instance().set(&StakingKey::SlashEnabled, &enabled); +} + +/// Apply slash to a staker (admin only). Returns slashed amount. +pub fn slash(env: &Env, admin: Address, staker: Address) -> i128 { + admin.require_auth(); + + let enabled: bool = env.storage().instance().get(&StakingKey::SlashEnabled).unwrap_or(false); + assert!(enabled, "Slashing is not enabled"); + + let config: SlashConfig = env.storage().instance() + .get(&StakingKey::SlashConfig) + .expect("Slash config not set"); + + let mut record: StakeRecord = env.storage().instance() + .get(&StakingKey::Stake(staker.clone())) + .expect("No stake found"); + + let slash_amount = record.amount + .checked_mul(config.slash_rate_bps as i128).expect("overflow") + / 10_000; + + record.amount = record.amount.checked_sub(slash_amount).expect("underflow"); + env.storage().instance().set(&StakingKey::Stake(staker.clone()), &record); + + let total: i128 = env.storage().instance().get(&StakingKey::TotalStaked).unwrap_or(0); + env.storage().instance().set(&StakingKey::TotalStaked, &total.saturating_sub(slash_amount)); + + env.events().publish((SLASHED,), (staker.clone(), slash_amount, config.slash_recipient)); + slash_amount +} + +// ── Queries ─────────────────────────────────────────────────────────────────── + pub fn get_stake(env: &Env, staker: Address) -> Option { - env.storage() - .instance() - .get(&StakingKey::Stake(staker)) + env.storage().instance().get(&StakingKey::Stake(staker)) } pub fn get_total_staked(env: &Env) -> i128 { - env.storage() - .instance() - .get(&StakingKey::TotalStaked) - .unwrap_or(0) + env.storage().instance().get(&StakingKey::TotalStaked).unwrap_or(0) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::{Address as _, Ledger, LedgerInfo}, Env}; + + fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + // Init reward rate + env.storage().instance().set(&StakingKey::RewardRate, &1_000_i128); + let admin = Address::generate(&env); + let staker = Address::generate(&env); + (env, admin, staker) + } + + fn advance_epoch(env: &Env, epochs: u32) { + env.ledger().set(LedgerInfo { + sequence_number: env.ledger().sequence() + epochs * EPOCH_LENGTH, + timestamp: 0, + protocol_version: 21, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1000, + min_persistent_entry_ttl: 1000, + max_entry_ttl: 100_000, + }); + } + + #[test] + fn test_stake_and_withdraw() { + let (env, _, staker) = setup(); + stake(&env, staker.clone(), 1_000, 1); + assert_eq!(get_total_staked(&env), 1_000); + let net = withdraw(&env, staker.clone(), false); + assert!(net >= 1_000); + assert_eq!(get_total_staked(&env), 0); + } + + #[test] + fn test_early_withdrawal_penalty() { + let (env, _, staker) = setup(); + env.storage().instance().set(&StakingKey::EarlyWithdrawalPenalty, &500_i128); // 5% + stake(&env, staker.clone(), 10_000, 10); + let net = withdraw(&env, staker.clone(), true); + // Should receive less than 10_000 due to penalty + assert!(net < 10_000); + } + + #[test] + fn test_delegate_and_undelegate() { + let (env, _, delegator) = setup(); + let delegatee = Address::generate(&env); + delegate(&env, delegator.clone(), delegatee.clone(), 5_000); + assert_eq!(get_total_staked(&env), 5_000); + + advance_epoch(&env, 2); + accrue_global(&env); + + let rewards = undelegate(&env, delegator.clone(), delegatee.clone()); + assert!(rewards >= 0); + assert_eq!(get_total_staked(&env), 0); + } + + #[test] + #[should_panic(expected = "Cannot delegate to self")] + fn test_self_delegation_panics() { + let (env, _, staker) = setup(); + delegate(&env, staker.clone(), staker.clone(), 1_000); + } + + #[test] + fn test_rewards_accrue_across_epochs() { + let (env, _, staker) = setup(); + stake(&env, staker.clone(), 1_000_000, 5); + advance_epoch(&env, 3); + let rewards = calculate_rewards(&env, &staker); + assert!(rewards > 0, "Rewards should accrue after epochs"); + } + + #[test] + fn test_slash_reduces_stake() { + let (env, admin, staker) = setup(); + let recipient = Address::generate(&env); + stake(&env, staker.clone(), 10_000, 2); + set_slash_config(&env, admin.clone(), 1_000, recipient, true); // 10% + let slashed = slash(&env, admin, staker.clone()); + assert_eq!(slashed, 1_000); // 10% of 10_000 + let record = get_stake(&env, staker).unwrap(); + assert_eq!(record.amount, 9_000); + } + + #[test] + #[should_panic(expected = "Stake amount must be positive")] + fn test_zero_stake_panics() { + let (env, _, staker) = setup(); + stake(&env, staker, 0, 1); + } + + #[test] + fn test_overflow_safety() { + let (env, _, staker) = setup(); + // Large amount should not overflow with checked arithmetic + stake(&env, staker.clone(), i128::MAX / 2, 1); + assert!(get_total_staked(&env) > 0); + } }