From 7f0d9964bc7ec555fc68294a5249cd0d446673cc Mon Sep 17 00:00:00 2001 From: Muhammad Zayyad Mukhtar Date: Mon, 23 Feb 2026 18:17:39 +0100 Subject: [PATCH] feat(vault): add projection endpoint with vesting schedule calculations - Add GET /api/vault/:id/projection endpoint that returns vesting timeline - Extend SubSchedule model with cliff, vesting, and release tracking fields - Implement vestingService.getVaultProjection() with linear vesting calculations - Add test files for both mock and database integration testing --- backend/src/index.js | 15 ++++ backend/src/models/subSchedule.js | 36 +++++++- backend/src/services/vestingService.js | 117 ++++++++++++++++++++++--- backend/test-projection-mock.js | 43 +++++++++ backend/test-projection.js | 61 +++++++++++++ 5 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 backend/test-projection-mock.js create mode 100644 backend/test-projection.js diff --git a/backend/src/index.js b/backend/src/index.js index 4bca77fd..aac42c45 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -261,6 +261,21 @@ app.get('/api/stats/tvl', async (req, res) => { } }); +// Vault Projection Route +app.get('/api/vault/:id/projection', async (req, res) => { + try { + const { id } = req.params; + const projection = await vestingService.getVaultProjection(id); + res.json({ success: true, data: projection }); + } catch (error) { + console.error('Error fetching vault projection:', error); + res.status(error.message === 'Vault not found' ? 404 : 500).json({ + success: false, + error: error.message + }); + } +}); + // Vault Export Routes app.get('/api/vault/:id/export', async (req, res) => { try { diff --git a/backend/src/models/subSchedule.js b/backend/src/models/subSchedule.js index 45384af3..36e278c5 100644 --- a/backend/src/models/subSchedule.js +++ b/backend/src/models/subSchedule.js @@ -16,14 +16,44 @@ const SubSchedule = sequelize.define('SubSchedule', { }, onUpdate: 'CASCADE', onDelete: 'CASCADE', - }, top_up_amount: { type: DataTypes.DECIMAL(36, 18), allowNull: false, comment: 'Amount of tokens added in this top-up', }, - + top_up_transaction_hash: { + type: DataTypes.STRING, + allowNull: false, + }, + top_up_timestamp: { + type: DataTypes.DATE, + allowNull: false, + }, + cliff_duration: { + type: DataTypes.INTEGER, // in seconds + allowNull: true, + defaultValue: 0, + }, + cliff_date: { + type: DataTypes.DATE, + allowNull: true, + }, + vesting_start_date: { + type: DataTypes.DATE, + allowNull: false, + }, + vesting_duration: { + type: DataTypes.INTEGER, // in seconds + allowNull: false, + }, + amount_released: { + type: DataTypes.DECIMAL(36, 18), + defaultValue: 0, + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, }, created_at: { type: DataTypes.DATE, @@ -43,7 +73,7 @@ const SubSchedule = sequelize.define('SubSchedule', { fields: ['vault_id'], }, { - + fields: ['is_active'], }, ], }); diff --git a/backend/src/services/vestingService.js b/backend/src/services/vestingService.js index 5204282c..588ea547 100644 --- a/backend/src/services/vestingService.js +++ b/backend/src/services/vestingService.js @@ -1,34 +1,123 @@ +const { Vault, SubSchedule } = require('../models'); +const { Op } = require('sequelize'); - throw error; +class VestingService { + /** + * Calculate the vesting projection for a vault + * @param {string} vaultId - The UUID or Address of the vault + * @returns {Promise>} + */ + async getVaultProjection(vaultId) { + // Try to find by UUID first, then address + let whereClause = {}; + const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(vaultId); + + if (isUUID) { + whereClause.id = vaultId; + } else { + whereClause.vault_address = vaultId; } - } + const vault = await Vault.findOne({ + where: whereClause, + include: [{ + model: SubSchedule, + as: 'subSchedules', + // Assuming is_active is used as per IndexingService + required: false + }] + }); - throw error; + if (!vault) { + throw new Error('Vault not found'); } - } - - throw error; + const schedules = vault.subSchedules || []; + if (schedules.length === 0) { + return []; } - } + // Collect critical dates + const criticalDates = new Set(); + const scheduleData = schedules.map(schedule => { + const topUpDate = new Date(schedule.top_up_timestamp || schedule.created_at); + const cliffDate = schedule.cliff_date ? new Date(schedule.cliff_date) : null; + const vestingStartDate = new Date(schedule.vesting_start_date); + const vestingDuration = schedule.vesting_duration; // in seconds + const vestingEndDate = new Date(vestingStartDate.getTime() + vestingDuration * 1000); + + const amount = parseFloat(schedule.top_up_amount); - throw error; - } - } + // Add critical dates + criticalDates.add(topUpDate.toISOString().split('T')[0]); + if (cliffDate) { + criticalDates.add(cliffDate.toISOString().split('T')[0]); + } + criticalDates.add(vestingStartDate.toISOString().split('T')[0]); + criticalDates.add(vestingEndDate.toISOString().split('T')[0]); + + return { + topUpDate, + cliffDate, + vestingStartDate, + vestingEndDate, + vestingDuration, + amount + }; + }); + + // Sort unique dates + const sortedDates = Array.from(criticalDates).sort(); + // Calculate total vested at each date + const projection = sortedDates.map(dateStr => { + const date = new Date(dateStr); + // Set to end of day to ensure we capture the state at that date? + // Or beginning? Usually for charts, exact timestamps matter, but user asked for '2024-01-01'. + // Let's use the date object as parsed (UTC 00:00). + + let totalVested = 0; - }); + for (const schedule of scheduleData) { + totalVested += this.calculateVestedAmountForSchedule(schedule, date); + } return { - success: true, + date: dateStr, + amount: totalVested + }; + }); - throw error; - } + return projection; } + calculateVestedAmountForSchedule(schedule, date) { + // Logic matches IndexingService.calculateSubScheduleReleasable but for total vested (ignoring released) + + // 1. Before cliff (if exists) -> 0 + if (schedule.cliffDate && date < schedule.cliffDate) { + return 0; + } + + // 2. Before vesting start -> 0 + if (date < schedule.vestingStartDate) { + return 0; + } + + // 3. After vesting end -> Full amount + if (date >= schedule.vestingEndDate) { + return schedule.amount; + } + + // 4. During vesting -> Linear + const vestedTime = date.getTime() - schedule.vestingStartDate.getTime(); + const totalDuration = schedule.vestingDuration * 1000; + + if (totalDuration === 0) return schedule.amount; // Instant vesting + const ratio = vestedTime / totalDuration; + return schedule.amount * ratio; + } } module.exports = new VestingService(); diff --git a/backend/test-projection-mock.js b/backend/test-projection-mock.js new file mode 100644 index 00000000..acfe26d3 --- /dev/null +++ b/backend/test-projection-mock.js @@ -0,0 +1,43 @@ +const { Vault } = require('./src/models'); +const vestingService = require('./src/services/vestingService'); + +console.log('Vault:', Vault); + +// Mock Vault.findOne +Vault.findOne = async (options) => { + console.log('Mock Vault.findOne called with:', options); + return { + id: 'mock-vault-id', + subSchedules: [ + { + top_up_amount: '500', + top_up_timestamp: new Date('2024-01-01T00:00:00Z'), + cliff_date: new Date('2024-01-02T00:00:00Z'), // 1 day cliff + vesting_start_date: new Date('2024-01-02T00:00:00Z'), + vesting_duration: 86400 * 10, // 10 days + created_at: new Date('2024-01-01T00:00:00Z') + }, + { + top_up_amount: '500', + top_up_timestamp: new Date('2024-01-01T00:00:00Z'), + cliff_date: null, + vesting_start_date: new Date('2024-01-01T00:00:00Z'), + vesting_duration: 86400 * 5, // 5 days + created_at: new Date('2024-01-01T00:00:00Z') + } + ] + }; +}; + +async function test() { + console.log('Starting test...'); + try { + const projection = await vestingService.getVaultProjection('mock-vault-id'); + console.log('Projection Result:'); + console.log(JSON.stringify(projection, null, 2)); + } catch (err) { + console.error('Test Error:', err); + } +} + +test(); diff --git a/backend/test-projection.js b/backend/test-projection.js new file mode 100644 index 00000000..af70ffcc --- /dev/null +++ b/backend/test-projection.js @@ -0,0 +1,61 @@ +const { sequelize } = require('./src/database/connection'); +const { Vault, SubSchedule } = require('./src/models'); +const vestingService = require('./src/services/vestingService'); + +async function testProjection() { + try { + await sequelize.authenticate(); + console.log('Database connected'); + + // 1. Create a dummy vault + const vault = await Vault.create({ + vault_address: '0x' + Math.random().toString(16).slice(2), + token_address: '0x' + Math.random().toString(16).slice(2), + owner_address: '0x' + Math.random().toString(16).slice(2), + total_amount: 1000, + name: 'Test Projection Vault' + }); + console.log('Created Vault:', vault.id); + + // 2. Create sub-schedules + const now = new Date(); + const startDate = new Date(now.getTime()); + + // Schedule 1: 500 tokens, 1 day cliff, 10 days vesting + await SubSchedule.create({ + vault_id: vault.id, + top_up_amount: 500, + top_up_timestamp: startDate, + cliff_date: new Date(startDate.getTime() + 86400 * 1000), // 1 day + vesting_start_date: new Date(startDate.getTime() + 86400 * 1000), + vesting_duration: 86400 * 10, // 10 days + created_at: startDate + }); + + // Schedule 2: 500 tokens, no cliff, 5 days vesting + await SubSchedule.create({ + vault_id: vault.id, + top_up_amount: 500, + top_up_timestamp: startDate, + vesting_start_date: startDate, + vesting_duration: 86400 * 5, // 5 days + created_at: startDate + }); + + // 3. Get Projection + console.log('Fetching projection...'); + const projection = await vestingService.getVaultProjection(vault.id); + console.log('Projection Result:', JSON.stringify(projection, null, 2)); + + // Cleanup + await SubSchedule.destroy({ where: { vault_id: vault.id } }); + await Vault.destroy({ where: { id: vault.id } }); + + } catch (error) { + console.error('Test Failed:', error); + } finally { + await sequelize.close(); + } +} + +testProjection();