diff --git a/backend/src/api/controllers/yields.ts b/backend/src/api/controllers/yields.ts index f62aa26..5a38dfa 100644 --- a/backend/src/api/controllers/yields.ts +++ b/backend/src/api/controllers/yields.ts @@ -1,7 +1,9 @@ import type { Request, Response, NextFunction } from "express"; import { YieldService } from "../../services/yield.js"; +import { NotificationService } from "../../services/notifications.js"; const yieldService = new YieldService(); +const notificationService = new NotificationService(); function formatYieldPerShare(yieldAmount: string, totalShares: string): string { const yieldBig = BigInt(yieldAmount); @@ -86,6 +88,57 @@ export async function getEpochDetail(req: Request, res: Response, next: NextFunc } } +// ── Epoch comparison (#820) ────────────────────────────────────────────────── +export async function compareEpochs(req: Request, res: Response, next: NextFunction) { + try { + const contractId = String(req.params["contractId"]); + const epochA = Number(req.query.a); + const epochB = Number(req.query.b); + + if (!Number.isInteger(epochA) || epochA <= 0 || !Number.isInteger(epochB) || epochB <= 0) { + res.status(400).json({ error: "BadRequest", message: "Both a and b must be positive integers" }); + return; + } + + const result = await yieldService.compareEpochs(contractId, epochA, epochB); + if (!result) { + res.status(404).json({ error: "NotFound", message: "One or both epochs not found" }); + return; + } + + res.json(result); + } catch (err) { + next(err); + } +} + +// ── Next epoch projection (#821) ───────────────────────────────────────────── +export async function getNextEpochProjection(req: Request, res: Response, next: NextFunction) { + try { + const contractId = String(req.params["contractId"]); + const projection = await yieldService.getNextEpochProjection(contractId); + res.json(projection); + } catch (err) { + next(err); + } +} + +// ── Epoch closed webhook (#819) ────────────────────────────────────────────── +export async function handleEpochClosed( + contractId: string, + epoch: number, +): Promise { + const result = await yieldService.closeEpochIfFullyClaimed(contractId, epoch); + if (result) { + await notificationService.notify("epoch.closed", { + contractId, + epoch, + yieldAmount: result.epochData.yieldAmount, + closedAt: result.epochData.closedAt, + }); + } +} + export async function getUserPendingYield(req: Request, res: Response, next: NextFunction) { try { const result = await yieldService.getUserPendingYield( diff --git a/backend/src/api/routes/yields.ts b/backend/src/api/routes/yields.ts index 937de0d..f8c7b35 100644 --- a/backend/src/api/routes/yields.ts +++ b/backend/src/api/routes/yields.ts @@ -8,6 +8,8 @@ import { getYieldSummary, getYieldPerShareHistory, getYieldTimeline, + compareEpochs, + getNextEpochProjection, } from "../controllers/yields.js"; import { getYieldsStream } from "../controllers/yields-stream.js"; import { validateQuery, validateParams } from "../middleware/validate.js"; @@ -63,6 +65,17 @@ yieldsRouter.get( validateParams(epochDetailParamsSchema), getEpochDetail, ); + +// ── Epoch comparison (#820) ────────────────────────────────────────────────── +const epochCompareQuerySchema = z.object({ + a: z.coerce.number().int().positive(), + b: z.coerce.number().int().positive(), +}); +yieldsRouter.get("/:contractId/epochs/compare", validateQuery(epochCompareQuerySchema), compareEpochs); + +// ── Next epoch projection (#821) ───────────────────────────────────────────── +yieldsRouter.get("/:contractId/next-epoch-projection", getNextEpochProjection); + yieldsRouter.get("/:contractId/yield-per-share-history", validateQuery(yieldHistoryQuerySchema), getYieldPerShareHistory); yieldsRouter.get("/:contractId/pending/:userAddress", getUserPendingYield); diff --git a/backend/src/db/migrations/030_epoch_closed_at.sql b/backend/src/db/migrations/030_epoch_closed_at.sql new file mode 100644 index 0000000..931c1c8 --- /dev/null +++ b/backend/src/db/migrations/030_epoch_closed_at.sql @@ -0,0 +1,3 @@ +-- Add closed_at column to epochs for webhook idempotency (#819). +-- closed_at is set exactly once when totalClaimed >= yieldAmount. +ALTER TABLE epochs ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; diff --git a/backend/src/services/indexer.ts b/backend/src/services/indexer.ts index 1da8bd9..e429b39 100644 --- a/backend/src/services/indexer.ts +++ b/backend/src/services/indexer.ts @@ -11,6 +11,7 @@ import { } from "./stellar.js"; import { VaultService } from "./vault.js"; import { UserService } from "./user.js"; +import { YieldService } from "./yield.js"; import { NotificationService } from "./notifications.js"; import { indexerEventsProcessedTotal, indexerLastLedger } from "./metrics.js"; import { cacheDel } from "../cache/redis.js"; @@ -1010,6 +1011,24 @@ export class Indexer { ); await cacheDel(`pending-yield:${contractId}:${userAddress}`); logger.info({ contractId, userAddress, epoch }, "Processed yield_claimed event"); + + // Check if epoch is now fully claimed and fire webhook (#819) + try { + const yieldService = new YieldService(); + const result = await yieldService.closeEpochIfFullyClaimed(contractId, epoch); + if (result) { + const notificationService = new NotificationService(); + await notificationService.notify("epoch.closed", { + contractId, + epoch, + yieldAmount: result.epochData.yieldAmount, + closedAt: result.epochData.closedAt, + }); + logger.info({ contractId, epoch }, "Fired epoch.closed webhook"); + } + } catch (err) { + logger.warn({ contractId, epoch, err }, "Failed to check epoch close after yield claim"); + } } private async handleEarlyRedemptionProcessed( diff --git a/backend/src/services/yield.ts b/backend/src/services/yield.ts index bc8c985..a272f5a 100644 --- a/backend/src/services/yield.ts +++ b/backend/src/services/yield.ts @@ -98,8 +98,9 @@ export class YieldService { } /** - * Fetch a single epoch's detail for a vault (#815). + * Fetch a single epoch's detail for a vault (#815, #818). * Returns null if the vault or the epoch does not exist for it. + * Includes totalClaimed/totalUnclaimed for liquidity planning. */ async getEpochDetail( contractId: string, @@ -111,6 +112,8 @@ export class YieldService { yieldPerShare: string; netYield: string; distributedAt: string | null; + totalClaimed: string; + totalUnclaimed: string; } | null> { const rows = await query<{ epoch: number; @@ -138,6 +141,11 @@ export class YieldService { const row = rows[0]; if (!row) return null; + const claimStats = await this.getEpochClaimStats(contractId, epoch); + const totalYield = BigInt(row.yield_amount); + const claimed = BigInt(claimStats.claimedAmount); + const unclaimed = totalYield > claimed ? totalYield - claimed : BigInt(0); + return { epoch: row.epoch, yieldAmount: row.yield_amount, @@ -145,6 +153,8 @@ export class YieldService { yieldPerShare: this.formatYieldPerShare(row.yield_amount, row.total_shares), netYield: row.net_yield ?? row.yield_amount, distributedAt: row.distributed_at ? row.distributed_at.toISOString() : null, + totalClaimed: claimed.toString(), + totalUnclaimed: unclaimed.toString(), }; } @@ -511,6 +521,195 @@ export class YieldService { return { data, total }; } + // ── Epoch comparison (#820) ────────────────────────────────────────────────── + /** + * Compare two epochs side-by-side for a vault. + * Returns null if either epoch does not exist. + */ + async compareEpochs( + contractId: string, + epochA: number, + epochB: number, + ): Promise<{ + a: { + epoch: number; + yieldAmount: string; + totalShares: string; + yieldPerShare: string; + distributedAt: string | null; + participationRate: number; + }; + b: { + epoch: number; + yieldAmount: string; + totalShares: string; + yieldPerShare: string; + distributedAt: string | null; + participationRate: number; + }; + delta: { + yieldAmount: string; + yieldPerShare: string; + }; + } | null> { + const [rowA, rowB] = await Promise.all([ + this.getEpochDetail(contractId, epochA), + this.getEpochDetail(contractId, epochB), + ]); + + if (!rowA || !rowB) return null; + + const [claimA, claimB, holdersA, holdersB] = await Promise.all([ + this.getEpochClaimStats(contractId, epochA), + this.getEpochClaimStats(contractId, epochB), + this.getEpochHolderCount(contractId, epochA), + this.getEpochHolderCount(contractId, epochB), + ]); + + const participationA = this.calculateParticipationRate(claimA.uniqueClaimants, holdersA); + const participationB = this.calculateParticipationRate(claimB.uniqueClaimants, holdersB); + + const deltaYield = BigInt(rowB.yieldAmount) - BigInt(rowA.yieldAmount); + const deltaYps = (() => { + const ypsA = this.parseYieldPerShare(rowA.yieldPerShare); + const ypsB = this.parseYieldPerShare(rowB.yieldPerShare); + return ypsB - ypsA; + })(); + + return { + a: { + epoch: rowA.epoch, + yieldAmount: rowA.yieldAmount, + totalShares: rowA.totalShares, + yieldPerShare: rowA.yieldPerShare, + distributedAt: rowA.distributedAt, + participationRate: participationA, + }, + b: { + epoch: rowB.epoch, + yieldAmount: rowB.yieldAmount, + totalShares: rowB.totalShares, + yieldPerShare: rowB.yieldPerShare, + distributedAt: rowB.distributedAt, + participationRate: participationB, + }, + delta: { + yieldAmount: deltaYield.toString(), + yieldPerShare: deltaYps.toString(), + }, + }; + } + + /** Parse a "X.Y" yield-per-share string back to a BigInt of scaled units. */ + private parseYieldPerShare(yps: string): bigint { + const [intPart, fracPart = ""] = yps.split("."); + const frac = fracPart.padEnd(18, "0").slice(0, 18); + return BigInt(intPart + frac); + } + + // ── Next epoch projection (#821) ──────────────────────────────────────────── + /** + * Estimate the next epoch's yield using a rolling average of the last 3 epochs. + * Returns null for all fields if fewer than 2 epochs exist. + */ + async getNextEpochProjection( + contractId: string, + ): Promise<{ + estimatedYieldAmount: string | null; + estimatedDistributionDate: string | null; + basedOnEpochs: number; + }> { + const rows = await query<{ + epoch: number; + yield_amount: string; + distributed_at: Date | null; + }>( + `SELECT e.epoch, e.yield_amount, e.distributed_at + FROM epochs e + JOIN vaults v ON e.vault_id = v.id + WHERE v.contract_id = $1 + ORDER BY e.epoch DESC + LIMIT 3`, + [contractId], + ); + + if (rows.length < 2) { + return { estimatedYieldAmount: null, estimatedDistributionDate: null, basedOnEpochs: 0 }; + } + + const epochs = rows.reverse(); + const basedOnEpochs = epochs.length; + + // Rolling average of yield amounts + const totalYield = epochs.reduce((sum, e) => sum + BigInt(e.yield_amount), BigInt(0)); + const estimatedYieldAmount = (totalYield / BigInt(basedOnEpochs)).toString(); + + // Estimate next distribution date using average interval between epochs + let estimatedDistributionDate: string | null = null; + const distributedDates = epochs + .filter((e) => e.distributed_at !== null) + .map((e) => e.distributed_at!.getTime()); + + if (distributedDates.length >= 2) { + let totalInterval = 0; + for (let i = 1; i < distributedDates.length; i++) { + totalInterval += distributedDates[i] - distributedDates[i - 1]; + } + const avgIntervalMs = totalInterval / (distributedDates.length - 1); + const lastDate = distributedDates[distributedDates.length - 1]; + estimatedDistributionDate = new Date(lastDate + avgIntervalMs).toISOString(); + } + + return { estimatedYieldAmount, estimatedDistributionDate, basedOnEpochs }; + } + + // ── Epoch close webhook (#819) ───────────────────────────────────────────── + /** + * After a yield claim event, check if the epoch is now fully claimed. + * If so, set closed_at (idempotent) and return true so the caller can fire the webhook. + */ + async closeEpochIfFullyClaimed( + contractId: string, + epoch: number, + ): Promise<{ closed: true; epochData: { yieldAmount: string; closedAt: string } } | null> { + // Check if already closed + const existing = await query<{ closed_at: Date | null }>( + `SELECT e.closed_at + FROM epochs e + JOIN vaults v ON e.vault_id = v.id + WHERE v.contract_id = $1 AND e.epoch = $2`, + [contractId, epoch], + ); + + if (existing[0]?.closed_at) return null; + + const stats = await this.getEpochClaimStats(contractId, epoch); + const detail = await this.getEpochDetail(contractId, epoch); + if (!detail) return null; + + if (BigInt(stats.claimedAmount) < BigInt(detail.yieldAmount)) return null; + + // Set closed_at + await query( + `UPDATE epochs e + SET closed_at = NOW() + FROM vaults v + WHERE e.vault_id = v.id + AND v.contract_id = $1 + AND e.epoch = $2 + AND e.closed_at IS NULL`, + [contractId, epoch], + ); + + return { + closed: true, + epochData: { + yieldAmount: detail.yieldAmount, + closedAt: new Date().toISOString(), + }, + }; + } + // ── Epoch yield distribution timeline (#822) ──────────────────────────────── // Returns all epochs for a vault ordered by epoch number, optionally bounded // by ISO date filters on distributed_at. `totalInRange` is the exact sum of