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
53 changes: 53 additions & 0 deletions backend/src/api/controllers/yields.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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<void> {
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(
Expand Down
13 changes: 13 additions & 0 deletions backend/src/api/routes/yields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions backend/src/db/migrations/030_epoch_closed_at.sql
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions backend/src/services/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
201 changes: 200 additions & 1 deletion backend/src/services/yield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -138,13 +141,20 @@ 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,
totalShares: row.total_shares,
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(),
};
}

Expand Down Expand Up @@ -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
Expand Down
Loading