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
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ Key environment variables:
- `DB_NAME`: Database name (default: vesting_vault)
- `DB_USER`: Database user (default: postgres)
- `DB_PASSWORD`: Database password (default: password)
- `STELLAR_RPC_URL`: Stellar RPC endpoint (e.g., https://soroban-testnet.stellar.org)
- `STELLAR_NETWORK_PASSPHRASE`: Passphrase for the configured network

## Project Structure

Expand Down
4 changes: 4 additions & 0 deletions RUN_LOCALLY.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ DB_PASSWORD=password

# Optional: CoinGecko API (for higher rate limits)
COINGECKO_API_KEY=your_api_key_here

# Stellar Network Configuration
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
```

### 4. Start the Application
Expand Down
9 changes: 9 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const pdfService = require('./services/pdfService');
const VaultService = require('./services/vaultService');
const monthlyReportJob = require('./jobs/monthlyReportJob');
const { VaultReconciliationJob } = require('./jobs/vaultReconciliationJob');
const vaultArchivalJob = require('./jobs/vaultArchivalJob');

// Import webhooks routes
const webhooksRoutes = require('./routes/webhooks');
Expand Down Expand Up @@ -922,6 +923,14 @@ const startServer = async () => {
console.log('Continuing without vault reconciliation...');
}

// Initialize Vault Archival Job
try {
vaultArchivalJob.start();
} catch (jobError) {
console.error('Failed to initialize Vault Archival Job:', jobError);
console.log('Continuing without vault archival...');
}

// Initialize Notification Service (includes cliff notification cron job)
try {
notificationService.start();
Expand Down
89 changes: 89 additions & 0 deletions backend/src/jobs/networkValidation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const { sequelize } = require('../database/connection');
const axios = require('axios');
const crypto = require('crypto');

/**
* Validates the network configuration on startup to ensure that
* the backend is strictly using environment variables and prevents
* cross-network contamination (e.g., Mainnet DB on Testnet RPC).
*/
async function validateNetworkOnStartup() {
const rpcUrl = process.env.STELLAR_RPC_URL;
const envPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE;

if (!rpcUrl) {
throw new Error('Startup Error: STELLAR_RPC_URL must be defined in environment variables.');
}
if (!envPassphrase) {
throw new Error('Startup Error: STELLAR_NETWORK_PASSPHRASE must be defined in environment variables.');
}

// 1. Fetch network details from the RPC to ensure it matches the ENV passphrase
let rpcPassphrase;
try {
// Try Soroban RPC first (since STELLAR_RPC_URL is usually a Soroban endpoint)
const rpcResponse = await axios.post(rpcUrl, {
jsonrpc: '2.0',
id: 1,
method: 'getNetwork'
});
if (rpcResponse.data && rpcResponse.data.result) {
rpcPassphrase = rpcResponse.data.result.passphrase;
}
} catch (error) {
// Fallback: Try Horizon endpoint if Soroban POST fails (e.g., 405 Method Not Allowed)
try {
const response = await axios.get(rpcUrl);
if (response.data && response.data.network_passphrase) {
rpcPassphrase = response.data.network_passphrase;
}
} catch (horizonError) {
console.warn(`Warning: Could not fetch network details from RPC URL (${rpcUrl}): ${horizonError.message}`);
}
}

if (rpcPassphrase && rpcPassphrase !== envPassphrase) {
throw new Error(`Configuration Mismatch: Environment STELLAR_NETWORK_PASSPHRASE is '${envPassphrase}' but RPC is configured for '${rpcPassphrase}'`);
}

// 2. Generate the network ID / genesis hash equivalent from the passphrase
// In Stellar, the network ID is the SHA-256 hash of the network passphrase
const genesisHash = crypto.createHash('sha256').update(envPassphrase).digest('hex');

// 3. Database validation to prevent Mainnet DB from connecting to Testnet RPC
try {
// Ensure the network configuration table exists
await sequelize.query(`
CREATE TABLE IF NOT EXISTS network_configuration (
id INT PRIMARY KEY DEFAULT 1,
network_passphrase VARCHAR(255) NOT NULL,
genesis_hash VARCHAR(64) NOT NULL
);
`);

const [results] = await sequelize.query(`SELECT network_passphrase, genesis_hash FROM network_configuration WHERE id = 1;`);

if (results && results.length > 0) {
const dbGenesisHash = results[0].genesis_hash;
if (dbGenesisHash !== genesisHash) {
throw new Error(`FATAL: DB Network Mismatch! The database was initialized for genesis hash ${dbGenesisHash}, but the current environment is configured for ${genesisHash} (${envPassphrase}). Connecting a DB to an incorrect Network RPC is prevented.`);
}
} else {
// First time startup on this DB, save the network hash state
await sequelize.query(`
INSERT INTO network_configuration (id, network_passphrase, genesis_hash)
VALUES (1, :passphrase, :genesisHash)
`, {
replacements: { passphrase: envPassphrase, genesisHash }
});
console.log(`[Network Validation] Initialized database with network: ${envPassphrase}`);
}
} catch (error) {
if (error.message && error.message.includes('DB Network Mismatch')) {
throw error; // Rethrow fatal mismatch errors immediately
}
console.warn('[Network Validation] Could not validate database network configuration:', error.message || error);
}
}

module.exports = { validateNetworkOnStartup };
126 changes: 126 additions & 0 deletions backend/src/jobs/vaultArchivalJob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const cron = require('node-cron');
const { sequelize } = require('../database/connection');
const { Vault, SubSchedule } = require('../models');

class VaultArchivalJob {
constructor() {
// Run every Sunday at 02:00 AM
this.cronSchedule = '0 2 * * 0';
}

start() {
console.log('Initializing Vault Archival Job...');

// Ensure the archived_vaults table exists before scheduling
this.initializeTable().then(() => {
cron.schedule(this.cronSchedule, async () => {
console.log('Running Vault Archival Job...');
try {
await this.archiveCompletedVaults();
} catch (error) {
console.error('Error running Vault Archival Job:', error);
}
});
console.log('Vault Archival Job scheduled successfully.');
}).catch(err => {
console.error('Failed to initialize archived_vaults table:', err);
});
}

async initializeTable() {
await sequelize.query(`
CREATE TABLE IF NOT EXISTS archived_vaults (
id SERIAL PRIMARY KEY,
original_vault_id INTEGER NOT NULL,
vault_address VARCHAR(255) NOT NULL,
vault_data JSONB NOT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
}

async archiveCompletedVaults() {
console.log('Starting vault archival process...');

try {
const vaults = await Vault.findAll();
let archivedCount = 0;

for (const vault of vaults) {
const subSchedule = await SubSchedule.findOne({ where: { vault_id: vault.id } });

// Need a subschedule to determine end_time and amounts accurately
if (!subSchedule) continue;

const totalAmount = parseFloat(vault.total_amount || 0);
const amountReleased = parseFloat(subSchedule.amount_released || 0);
const claimableBalance = totalAmount - amountReleased;

let isEndTimePassed = false;

// Check end_date directly on vault if it exists
if (vault.end_date) {
isEndTimePassed = new Date() > new Date(vault.end_date);
} else if (subSchedule.vesting_start_date && subSchedule.vesting_duration) {
const startDate = new Date(subSchedule.vesting_start_date);
const endDate = new Date(startDate.getTime() + (subSchedule.vesting_duration * 1000));
isEndTimePassed = new Date() > endDate;
}

// Allow for tiny floating point inaccuracies
if (claimableBalance <= 0.000001 && isEndTimePassed) {
const transaction = await sequelize.transaction();
try {
let fullVault;
try {
// Attempt to fetch all associated records for a complete archive payload
fullVault = await Vault.findOne({
where: { id: vault.id },
include: { all: true }
});
} catch (incErr) {
fullVault = vault;
}

const vaultData = JSON.stringify(fullVault ? fullVault.get({ plain: true }) : vault.get({ plain: true }));

await sequelize.query(`
INSERT INTO archived_vaults (original_vault_id, vault_address, vault_data)
VALUES (:id, :address, :data)
`, {
replacements: {
id: vault.id,
address: vault.address || 'unknown',
data: vaultData
},
transaction
});

// Clear known dependencies manually to prevent foreign key constraint errors
if (sequelize.models.Notification) {
await sequelize.models.Notification.destroy({ where: { vault_id: vault.id }, transaction });
}
if (sequelize.models.SubSchedule) {
await sequelize.models.SubSchedule.destroy({ where: { vault_id: vault.id }, transaction });
}

await Vault.destroy({ where: { id: vault.id }, transaction });

await transaction.commit();
archivedCount++;
console.log(`Archived vault: ${vault.address}`);
} catch (err) {
await transaction.rollback();
console.error(`Error archiving vault ${vault.address}:`, err);
}
}
}

console.log(`Vault archival process completed. Archived ${archivedCount} vaults.`);
} catch (error) {
console.error('Error during vault archival:', error);
}
}
}

module.exports = new VaultArchivalJob();
11 changes: 9 additions & 2 deletions backend/src/jobs/vaultReconciliationJob.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ const axios = require('axios');
class VaultReconciliationJob {
constructor() {
this.cronSchedule = '0 */6 * * *'; // Run every 6 hours
this.contractAddress = process.env.VAULT_CONTRACT_ADDRESS || 'CD5QF6KBAURVUNZR2EVBJISWSEYGDGEEYVH2XYJJADKT7KFOXTTIXLHU';
this.stellarRpcUrl = process.env.STELLAR_RPC_URL || 'https://horizon-testnet.stellar.org';
this.contractAddress = process.env.VAULT_CONTRACT_ADDRESS;
this.stellarRpcUrl = process.env.STELLAR_RPC_URL;

if (!this.contractAddress) {
throw new Error('VAULT_CONTRACT_ADDRESS environment variable is required');
}
if (!this.stellarRpcUrl) {
throw new Error('STELLAR_RPC_URL environment variable is required');
}
}

start() {
Expand Down
5 changes: 4 additions & 1 deletion backend/src/services/balanceTracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ class BalanceTracker {
* @param {string} rpcUrl - Stellar RPC URL for querying balances
*/
constructor(rpcUrl = null) {
this.rpcUrl = rpcUrl || process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org';
this.rpcUrl = rpcUrl || process.env.STELLAR_RPC_URL;
if (!this.rpcUrl) {
throw new Error('STELLAR_RPC_URL environment variable is required');
}
}

/**
Expand Down
9 changes: 6 additions & 3 deletions backend/src/services/tokenMetadataWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ const { Token } = require('../models/token');
const Vault = require('../models/vault');
const axios = require('axios');

const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.testnet.stellar.org';

/**
* Worker to detect new token addresses and fetch/store their metadata.
*/
Expand Down Expand Up @@ -44,8 +42,13 @@ class TokenMetadataWorker {

async fetchTokenMetadata(address) {
// Example: Replace with actual Soroban RPC call
const rpcUrl = process.env.STELLAR_RPC_URL;
if (!rpcUrl) {
throw new Error('STELLAR_RPC_URL environment variable is required');
}

try {
const response = await axios.post(`${SOROBAN_RPC_URL}/getTokenMetadata`, { address: address });
const response = await axios.post(`${rpcUrl}/getTokenMetadata`, { address: address });
const symbol = response.data.symbol;
const name = response.data.name;
const decimals = response.data.decimals;
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ services:
DB_NAME: vesting_vault
DB_USER: postgres
DB_PASSWORD: password
STELLAR_RPC_URL: ${STELLAR_RPC_URL:-https://soroban-testnet.stellar.org}
STELLAR_NETWORK_PASSPHRASE: ${STELLAR_NETWORK_PASSPHRASE:-"Test SDF Network ; September 2015"}
ports:
- "3000:3000"
depends_on:
Expand Down
20 changes: 17 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const cors = require('cors');
require('dotenv').config();
const { Client } = require('pg');
const { Server } = require('stellar-sdk');
const { validateNetworkOnStartup } = require('./backend/src/jobs/networkValidation');

const app = express();
const port = process.env.PORT || 3000;
Expand Down Expand Up @@ -46,7 +47,10 @@ app.get('/health', async (req, res) => {

// Check Stellar RPC connection
try {
const stellarServer = new Server(process.env.STELLAR_RPC_URL || 'https://horizon-testnet.stellar.org');
if (!process.env.STELLAR_RPC_URL) {
throw new Error('STELLAR_RPC_URL is not configured');
}
const stellarServer = new Server(process.env.STELLAR_RPC_URL);
await stellarServer.root();
health.services.stellar = 'healthy';
} catch (error) {
Expand All @@ -71,8 +75,18 @@ app.get('/', (req, res) => {
res.json({
project: 'Vesting Vault',
status: 'Tracking Locked Tokens',
contract: 'CD5QF6KBAURVUNZR2EVBJISWSEYGDGEEYVH2XYJJADKT7KFOXTTIXLHU'
contract: process.env.VAULT_CONTRACT_ADDRESS
});
});

app.listen(port, () => console.log(`Vesting API running on port ${port}`));
async function startServer() {
try {
await validateNetworkOnStartup();
app.listen(port, () => console.log(`Vesting API running on port ${port}`));
} catch (error) {
console.error('\n❌ Fatal Startup Error:', error.message, '\n');
process.exit(1);
}
}

startServer();
Loading
Loading