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: 1 addition & 1 deletion backend/src/services/indexerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export function serializeDeadLetterPayload(event: rpc.Api.EventResponse): string
ledgerClosedAt: event.ledgerClosedAt,
transactionIndex: event.transactionIndex,
operationIndex: event.operationIndex,
inSuccessfulContractCall: event.inSuccessfulContractCall,
inSuccessfulContractCall: event.inSuccessfulContractCall ?? false,
topic: event.topic.map((scval) => Buffer.from(scval.toXDR()).toString('base64')),
value: Buffer.from(event.value.toXDR()).toString('base64'),
};
Expand Down
191 changes: 191 additions & 0 deletions frontend/src/lib/api-types.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2577,6 +2577,197 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/analytics/tvl": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Current protocol TVL partitioned by token asset
* @description Read-only protocol statistics. No authentication is required because
* every payload here is public (the same numbers a block explorer
* would show). Subject to the API's global rate limiter.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description TVL snapshot */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example true */
success: boolean;
/** @description Current TVL snapshot partitioned by token asset */
data: Record<string, never>;
};
};
};
/** @description Too many requests */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description TVL snapshot unavailable */
500: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/analytics/historical": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Pre-aggregated time-series points for frontend charts
* @description Historical TVL and volume series at a fixed interval. No
* authentication is required; subject to the API's global rate limiter.
*/
get: {
parameters: {
query?: {
/** @description Lookback window */
period?: "7d" | "30d" | "90d" | "1y";
/** @description Granularity of each point */
interval?: "1h" | "1d";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Time-series points */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example true */
success: boolean;
/** @description Aggregated time-series points for the requested period and interval */
data: unknown[];
};
};
};
/** @description Invalid period or interval */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Too many requests */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Historical analytics unavailable */
500: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/analytics/defillama": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* DefiLlama adapter payload
* @description TVL and activity numbers in the shape DefiLlama's adapter format
* expects, so the protocol can be listed without a separate adapter.
* No authentication is required; subject to the global rate limiter.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description DefiLlama adapter payload */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example true */
success: boolean;
/** @description DefiLlama-shaped adapter payload */
data: Record<string, never>;
};
};
};
/** @description Too many requests */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description DefiLlama adapter unavailable */
500: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/admin/metrics": {
parameters: {
query?: never;
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/utils/cashflowCalculations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,109 @@ describe("cashflow calculations", () => {
it("does not accrue paused streams", () => { const result = projectCashflow([{ id: "1", direction: "incoming", token: "USDC", deposited: 100, withdrawn: 0, ratePerSecond: 1, isActive: true, isPaused: true }], 1, now); expect(result[1]?.projected).toBe(0); });
it("projects active flow in daily intervals", () => { const result = projectCashflow([{ id: "1", direction: "incoming", token: "USDC", deposited: 1000, withdrawn: 0, ratePerSecond: 0.001, isActive: true }], 1, now); expect(result[1]?.projected).toBeCloseTo(86.4); });
it("estimates outgoing runway from remaining balance", () => { expect(estimateRunwayDate([{ id: "1", direction: "outgoing", token: "USDC", deposited: 100, withdrawn: 0, ratePerSecond: 1 / 86400, isActive: true }], now)?.toISOString()).toBe("2026-04-11T00:00:00.000Z"); });

it("limits a one-day-funded incoming stream over 90 days and stops flow after depletion", () => {
const stream = {
id: "stream-1day",
direction: "incoming" as const,
token: "USDC",
deposited: 86400,
withdrawn: 0,
ratePerSecond: 1,
isActive: true,
};
const result = projectCashflow([stream], 90, now);

expect(result).toHaveLength(91);
expect(result[0]!.projected).toBe(0);
expect(result[1]!.projected).toBe(86400);
expect(result[1]!.dailyRate).toBe(86400);

// Day 2 and 3 should stop accruing and have 0 dailyRate
expect(result[2]!.projected).toBe(86400);
expect(result[2]!.dailyRate).toBe(0);
expect(result[3]!.projected).toBe(86400);
expect(result[3]!.dailyRate).toBe(0);

// By day 90, it remains capped at 86400
expect(result[90]!.projected).toBe(86400);
expect(result[90]!.dailyRate).toBe(0);
});

it("projects mixed streams that deplete on different days, including a partial final day", () => {
const streams = [
// Depletes after exactly 1 day (86400 units)
{
id: "s1",
direction: "incoming" as const,
token: "USDC",
deposited: 86400,
withdrawn: 0,
ratePerSecond: 1,
isActive: true,
},
// Depletes after 1.5 days (86400 on day 1, 43200 partial on day 2)
{
id: "s2",
direction: "incoming" as const,
token: "USDC",
deposited: 129600,
withdrawn: 0,
ratePerSecond: 1,
isActive: true,
},
// Depletes after 3 days (86400 * 3)
{
id: "s3",
direction: "incoming" as const,
token: "USDC",
deposited: 259200,
withdrawn: 0,
ratePerSecond: 1,
isActive: true,
},
];

const result = projectCashflow(streams, 5, now);

// Day 0: starting point
expect(result[0]!.projected).toBe(0);

// Day 1: all 3 streams flow full day -> 86400 * 3 = 259200
expect(result[1]!.dailyRate).toBe(259200);
expect(result[1]!.projected).toBe(259200);

// Day 2: s1 is depleted (0), s2 flows remaining 43200 (partial), s3 flows 86400 -> total 129600
expect(result[2]!.dailyRate).toBe(129600);
expect(result[2]!.projected).toBe(259200 + 129600);

// Day 3: s1 and s2 are depleted, s3 flows last 86400 -> total 86400
expect(result[3]!.dailyRate).toBe(86400);
expect(result[3]!.projected).toBe(259200 + 129600 + 86400);

// Day 4 & 5: all depleted -> dailyRate 0, projected flat
expect(result[4]!.dailyRate).toBe(0);
expect(result[4]!.projected).toBe(475200);
expect(result[5]!.dailyRate).toBe(0);
expect(result[5]!.projected).toBe(475200);
});

it("limits outgoing streams to their unvested remaining deposit", () => {
const stream = {
id: "out-1",
direction: "outgoing" as const,
token: "USDC",
deposited: 100000,
withdrawn: 13600, // remaining = 86400
ratePerSecond: 1,
isActive: true,
};
const result = projectCashflow([stream], 3, now);

expect(result[0]!.balance).toBe(86400);
expect(result[1]!.balance).toBe(0);
expect(result[1]!.dailyRate).toBe(-86400);
expect(result[2]!.balance).toBe(0);
expect(result[2]!.dailyRate).toBe(0);
});
});
24 changes: 19 additions & 5 deletions frontend/src/utils/cashflowCalculations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,30 @@ export function projectCashflow(streams: ProjectionStream[], horizonDays: number
const start = new Date(now); start.setHours(0, 0, 0, 0);
let balance = streams.filter((stream) => stream.direction === "outgoing").reduce((sum, stream) => sum + Math.max(0, stream.deposited - stream.withdrawn), 0);
let cumulative = streams.filter((stream) => stream.direction === "incoming").reduce((sum, stream) => sum + stream.withdrawn, 0);
const remaining = streams.map((stream) => Math.max(0, stream.deposited - stream.withdrawn));

return Array.from({ length: horizonDays + 1 }, (_, index) => {
const date = new Date(start); date.setDate(start.getDate() + index);
// The series starts at the beginning of today, so the first point has not
// had a full day to accrue yet. Crediting it one day of flow would shift the
// whole projection a day into the future.
const day = index === 0 ? 0 : streams.reduce((total, stream) => {
if (!stream.isActive || stream.isPaused || (stream.startTime && stream.startTime * 1000 > date.getTime())) return total;
const amount = stream.ratePerSecond * 86400;
return total + (stream.direction === "incoming" ? amount : -amount);
}, 0);
let day = 0;
if (index > 0) {
for (let i = 0; i < streams.length; i++) {
const stream = streams[i]!;
if (!stream.isActive || stream.isPaused || (stream.startTime && stream.startTime * 1000 > date.getTime())) {
continue;
}
const unvested = remaining[i]!;
if (unvested <= 0) continue;

const fullDayAmount = stream.ratePerSecond * 86400;
const amount = Math.min(fullDayAmount, unvested);
remaining[i] = unvested - amount;

day += stream.direction === "incoming" ? amount : -amount;
}
}
cumulative += Math.max(0, day);
balance = Math.max(0, balance + Math.min(0, day));
return { date, actual: index === 0 ? cumulative : 0, projected: cumulative, balance, dailyRate: day };
Expand Down
Loading