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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export enum NotificationType {
LARGE_TRANSACTION = 'LARGE_TRANSACTION',
ERROR = 'ERROR',
INSURANCE = 'INSURANCE',
APPROVAL = 'APPROVAL',
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Vault } from './vault.entity';
import { User } from './user.entity';

@Entity('vault_approvals')
@Index('idx_vault_approvals_vault', ['vaultId'])
@Index('idx_vault_approvals_user', ['userId'])
export class VaultApproval {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'vault_id' })
vaultId: string;

@Column({ name: 'user_id' })
userId: string;

@Column({ type: 'enum', enum: ['PENDING', 'APPROVED', 'REJECTED'], default: 'PENDING' })
status: 'PENDING' | 'APPROVED' | 'REJECTED';

@Column({ type: 'text', nullable: true })
comment: string | null;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;

@ManyToOne(() => Vault, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'vault_id' })
vault: Vault;

@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}
25 changes: 25 additions & 0 deletions harvest-finance/backend/src/database/entities/vault.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import {
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
ManyToMany,
} from 'typeorm';
import { User } from './user.entity';
import { Deposit } from './deposit.entity';
import { VaultApproval } from './vault-approval.entity';

export enum VaultType {
CROP_PRODUCTION = 'CROP_PRODUCTION',
Expand Down Expand Up @@ -125,6 +128,15 @@ export class Vault {
@Column({ name: 'is_public', default: true })
isPublic: boolean;

@Column({ name: 'requires_multi_signature', default: false })
requiresMultiSignature: boolean;

@Column({ name: 'approval_threshold', type: 'int', default: 1 })
approvalThreshold: number;

@Column({ name: 'current_approvals', type: 'int', default: 0 })
currentApprovals: number;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

Expand All @@ -138,6 +150,9 @@ export class Vault {
@OneToMany(() => Deposit, (deposit) => deposit.vault)
deposits: Deposit[];

@OneToMany(() => VaultApproval, (approval) => approval.vault)
approvals: VaultApproval[];

get availableCapacity(): number {
return Number(this.maxCapacity) - Number(this.totalDeposits);
}
Expand All @@ -150,4 +165,14 @@ export class Vault {
get isFullCapacity(): boolean {
return Number(this.totalDeposits) >= Number(this.maxCapacity);
}

get requiresApproval(): boolean {
return this.requiresMultiSignature && this.currentApprovals < this.approvalThreshold;
}

get approvalStatus(): string {
if (!this.requiresMultiSignature) return 'NOT_REQUIRED';
if (this.currentApprovals >= this.approvalThreshold) return 'APPROVED';
return 'PENDING';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddMultiSignatureToVaults1700000000014 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE vaults
ADD COLUMN requires_multi_signature BOOLEAN DEFAULT FALSE,
ADD COLUMN approval_threshold INTEGER DEFAULT 1,
ADD COLUMN current_approvals INTEGER DEFAULT 0`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE vaults
DROP COLUMN requires_multi_signature,
DROP COLUMN approval_threshold,
DROP COLUMN current_approvals`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateVaultApprovals1700000000015 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE vault_approvals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vault_id UUID NOT NULL,
user_id UUID NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING',
comment TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
FOREIGN KEY (vault_id) REFERENCES vaults(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE vault_approvals`);
}
}
24 changes: 24 additions & 0 deletions harvest-finance/backend/src/vaults/dto/vault-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ export class VaultResponseDto {
})
isPublic: boolean;

@ApiProperty({
example: false,
description: 'Whether vault requires multi-signature approval',
})
requiresMultiSignature: boolean;

@ApiProperty({
example: 2,
description: 'Number of approvals required for operations',
})
approvalThreshold: number;

@ApiProperty({
example: 1,
description: 'Number of current approvals',
})
currentApprovals: number;

@ApiProperty({
example: 'PENDING',
description: 'Current approval status (NOT_REQUIRED, PENDING, APPROVED)',
})
approvalStatus: string;

@ApiProperty({
example: '2023-01-01T00:00:00Z',
description: 'Vault creation date',
Expand Down
175 changes: 175 additions & 0 deletions harvest-finance/backend/src/vaults/vaults.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,179 @@ export class VaultsController {
): Promise<any[]> {
return this.vaultsService.getApyHistory(vaultId, timeRange);
}

@Post(':vaultId/multi-signature-config')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update multi-signature configuration for a vault' })
@ApiParam({
name: 'vaultId',
description: 'Vault ID (UUID)',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@ApiBody({
schema: {
type: 'object',
properties: {
requiresMultiSignature: { type: 'boolean', example: true },
approvalThreshold: { type: 'number', example: 2 },
},
required: ['requiresMultiSignature', 'approvalThreshold'],
},
})
@ApiResponse({
status: 200,
description: 'Multi-signature configuration updated successfully',
type: VaultResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid configuration or validation error',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Only vault owner or admin can update configuration',
})
@ApiResponse({ status: 404, description: 'Vault not found' })
async updateVaultMultiSignatureConfig(
@Param('vaultId') vaultId: string,
@Body('requiresMultiSignature') requiresMultiSignature: boolean,
@Body('approvalThreshold') approvalThreshold: number,
@Request() req: any,
): Promise<VaultResponseDto> {
return this.vaultsService.updateVaultMultiSignatureConfig(
vaultId,
req.user.id,
requiresMultiSignature,
approvalThreshold,
);
}

@Post(':vaultId/request-approval')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Request approval from another user for vault operations' })
@ApiParam({
name: 'vaultId',
description: 'Vault ID (UUID)',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@ApiBody({
schema: {
type: 'object',
properties: {
approverUserId: { type: 'string', example: '456e7890-e89b-12d3-a456-426614174111' },
},
required: ['approverUserId'],
},
})
@ApiResponse({
status: 200,
description: 'Approval request sent successfully',
})
@ApiResponse({
status: 400,
description: 'Invalid approver or validation error',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Only vault owner or admin can request approvals',
})
@ApiResponse({ status: 404, description: 'Vault not found' })
async requestVaultApproval(
@Param('vaultId') vaultId: string,
@Body('approverUserId') approverUserId: string,
@Request() req: any,
): Promise<void> {
return this.vaultsService.requestVaultApproval(
vaultId,
req.user.id,
approverUserId,
);
}

@Post(':vaultId/approve')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Approve vault operations' })
@ApiParam({
name: 'vaultId',
description: 'Vault ID (UUID)',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@ApiResponse({
status: 200,
description: 'Vault operation approved successfully',
})
@ApiResponse({
status: 400,
description: 'No pending approval request found or invalid state',
})
@ApiResponse({ status: 404, description: 'Vault not found' })
async approveVaultOperation(
@Param('vaultId') vaultId: string,
@Request() req: any,
): Promise<{ success: boolean; message: string }> {
return this.vaultsService.approveVaultOperation(vaultId, req.user.id);
}

@Post(':vaultId/pause')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Pause a vault (freeze operations)' })
@ApiParam({
name: 'vaultId',
description: 'Vault ID (UUID)',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@ApiResponse({
status: 200,
description: 'Vault paused successfully',
type: VaultResponseDto,
})
@ApiResponse({
status: 400,
description: 'Vault is already paused',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Only vault owner or admin can pause vault',
})
@ApiResponse({ status: 404, description: 'Vault not found' })
async pauseVault(
@Param('vaultId') vaultId: string,
@Request() req: any,
): Promise<VaultResponseDto> {
return this.vaultsService.pauseVault(vaultId, req.user.id);
}

@Post(':vaultId/resume')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Resume a paused vault' })
@ApiParam({
name: 'vaultId',
description: 'Vault ID (UUID)',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@ApiResponse({
status: 200,
description: 'Vault resumed successfully',
type: VaultResponseDto,
})
@ApiResponse({
status: 400,
description: 'Vault is not paused',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Only vault owner or admin can resume vault',
})
@ApiResponse({ status: 404, description: 'Vault not found' })
async resumeVault(
@Param('vaultId') vaultId: string,
@Request() req: any,
): Promise<VaultResponseDto> {
return this.vaultsService.resumeVault(vaultId, req.user.id);
}
}
Loading