diff --git a/backend/src/services/indexerService.ts b/backend/src/services/indexerService.ts index abb7cda3..1eca8781 100644 --- a/backend/src/services/indexerService.ts +++ b/backend/src/services/indexerService.ts @@ -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'), }; diff --git a/frontend/src/lib/api-types.generated.ts b/frontend/src/lib/api-types.generated.ts index bb0db700..02d2c791 100644 --- a/frontend/src/lib/api-types.generated.ts +++ b/frontend/src/lib/api-types.generated.ts @@ -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; + }; + }; + }; + /** @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; + }; + }; + }; + /** @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; diff --git a/frontend/src/utils/cashflowCalculations.test.ts b/frontend/src/utils/cashflowCalculations.test.ts index bd55e506..6ece61ac 100644 --- a/frontend/src/utils/cashflowCalculations.test.ts +++ b/frontend/src/utils/cashflowCalculations.test.ts @@ -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); + }); }); \ No newline at end of file diff --git a/frontend/src/utils/cashflowCalculations.ts b/frontend/src/utils/cashflowCalculations.ts index a63869e8..bebfd607 100644 --- a/frontend/src/utils/cashflowCalculations.ts +++ b/frontend/src/utils/cashflowCalculations.ts @@ -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 };