diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx index da5df7b..3bcf861 100644 --- a/src/components/LPDashboard.tsx +++ b/src/components/LPDashboard.tsx @@ -40,6 +40,7 @@ import DynamicYieldAnalyticsChart from "./DynamicYieldAnalyticsChart"; import LPSettingsModal from "./LPSettingsModal"; import { useLPSettings } from "@/hooks/useLPSettings"; import type { DataTableColumn } from "./DataTable"; +import AuctionRateTicker from "./AuctionRateTicker"; type Tab = "discovery" | "my-funded" | "watchlist" | "earnings-history"; @@ -326,9 +327,7 @@ export default function LPDashboard() { label: "Discount", sortable: true, renderCell: (inv) => ( - - {(inv.discount_rate / 100).toFixed(2)}% - + ), }, { @@ -698,9 +697,7 @@ export default function LPDashboard() { - - {(invoice.discount_rate / 100).toFixed(2)}% - + {formatDate(invoice.due_date)} diff --git a/src/components/__tests__/AuctionRateTicker.test.tsx b/src/components/__tests__/AuctionRateTicker.test.tsx new file mode 100644 index 0000000..d3dd304 --- /dev/null +++ b/src/components/__tests__/AuctionRateTicker.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AuctionRateTicker from "@/components/AuctionRateTicker"; +import type { Invoice } from "@/utils/soroban"; + +const fixedInvoice: Invoice = { + id: 1n, + status: "Pending", + freelancer: "GFR", + payer: "GPY", + amount: 10_000_000n, + due_date: 1_900_000_000n, + discount_rate: 500, +}; + +describe("AuctionRateTicker", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-26T16:20:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders the standard discount badge for fixed-rate invoices", () => { + render(); + + expect(screen.getByText("5.00%")).toBeInTheDocument(); + }); + + it("renders the live Dutch auction rate display", () => { + const nowSeconds = Math.floor(Date.now() / 1000); + + render( + + ); + + expect(screen.getByLabelText("Dutch auction rate")).toBeInTheDocument(); + expect(screen.getByText("Current Rate: 600 bps")).toBeInTheDocument(); + expect(screen.getByText(/decreasing to 300 bps by/i)).toBeInTheDocument(); + expect(screen.getByText(/Act now - rate decreases in/i)).toBeInTheDocument(); + }); + + it("shows the minimum-rate state after the auction completes", () => { + const nowSeconds = Math.floor(Date.now() / 1000); + + render( + + ); + + expect(screen.getByText("Current Rate: 300 bps")).toBeInTheDocument(); + expect(screen.getByText("Minimum rate reached")).toBeInTheDocument(); + }); +}); diff --git a/src/utils/__tests__/dutchAuction.test.ts b/src/utils/__tests__/dutchAuction.test.ts new file mode 100644 index 0000000..15bf105 --- /dev/null +++ b/src/utils/__tests__/dutchAuction.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + calculateDutchAuctionState, + formatAuctionCountdown, + getDutchAuctionTerms, +} from "@/utils/dutchAuction"; +import type { Invoice } from "@/utils/soroban"; + +const baseInvoice: Invoice = { + id: 1n, + status: "Pending", + freelancer: "GFR", + payer: "GPY", + amount: 10_000_000n, + due_date: 1_900_000_000n, + discount_rate: 500, +}; + +describe("Dutch auction utilities", () => { + it("returns null for fixed-rate invoices", () => { + expect(getDutchAuctionTerms(baseInvoice)).toBeNull(); + }); + + it("extracts valid auction terms from invoice metadata", () => { + expect( + getDutchAuctionTerms({ + ...baseInvoice, + auction_mode: true, + start_rate: 800, + min_rate: 300, + auction_started_at: 1_000n, + auction_ends_at: 2_000n, + }) + ).toEqual({ + startRateBps: 800, + minRateBps: 300, + startedAt: 1_000, + endsAt: 2_000, + }); + }); + + it("calculates the current rate from elapsed ledger time", () => { + const state = calculateDutchAuctionState( + { + startRateBps: 900, + minRateBps: 300, + startedAt: 1_000, + endsAt: 1_600, + }, + 1_300 + ); + + expect(state.currentRateBps).toBe(600); + expect(state.progressPercent).toBe(50); + expect(state.isComplete).toBe(false); + }); + + it("clamps the rate at the minimum once the auction ends", () => { + const state = calculateDutchAuctionState( + { + startRateBps: 900, + minRateBps: 300, + startedAt: 1_000, + endsAt: 1_600, + }, + 1_700 + ); + + expect(state.currentRateBps).toBe(300); + expect(state.progressPercent).toBe(100); + expect(state.isComplete).toBe(true); + }); + + it("formats the next decrease countdown", () => { + expect(formatAuctionCountdown(125)).toBe("2m 5s"); + }); +}); diff --git a/src/utils/dutchAuction.ts b/src/utils/dutchAuction.ts new file mode 100644 index 0000000..bac8429 --- /dev/null +++ b/src/utils/dutchAuction.ts @@ -0,0 +1,84 @@ +import type { Invoice } from "@/utils/soroban"; + +export interface DutchAuctionTerms { + startRateBps: number; + minRateBps: number; + startedAt: number; + endsAt: number; +} + +export interface DutchAuctionState extends DutchAuctionTerms { + currentRateBps: number; + progressPercent: number; + secondsUntilNextDecrease: number; + isComplete: boolean; +} + +function toNumber(value: bigint | number | string | undefined): number | null { + if (value === undefined) return null; + const numberValue = typeof value === "bigint" ? Number(value) : Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +} + +export function getDutchAuctionTerms(invoice: Invoice): DutchAuctionTerms | null { + const isAuctionMode = + invoice.auction_mode === true || + invoice.auctionMode === true || + invoice.mode === "auction" || + invoice.rate_mode === "auction"; + + if (!isAuctionMode) return null; + + const startRateBps = toNumber(invoice.start_rate); + const minRateBps = toNumber(invoice.min_rate); + const startedAt = toNumber(invoice.auction_started_at); + const endsAt = toNumber(invoice.auction_ends_at); + + if ( + startRateBps === null || + minRateBps === null || + startedAt === null || + endsAt === null || + endsAt <= startedAt || + startRateBps < minRateBps + ) { + return null; + } + + return { startRateBps, minRateBps, startedAt, endsAt }; +} + +export function calculateDutchAuctionState( + terms: DutchAuctionTerms, + nowSeconds = Math.floor(Date.now() / 1000) +): DutchAuctionState { + const totalDuration = terms.endsAt - terms.startedAt; + const elapsed = Math.min(Math.max(nowSeconds - terms.startedAt, 0), totalDuration); + const rateDelta = terms.startRateBps - terms.minRateBps; + const progress = totalDuration === 0 ? 1 : elapsed / totalDuration; + const currentRateBps = Math.max( + terms.minRateBps, + Math.round(terms.startRateBps - rateDelta * progress) + ); + const secondsPerBps = rateDelta > 0 ? totalDuration / rateDelta : totalDuration; + const elapsedSinceLastDecrease = rateDelta > 0 ? elapsed % secondsPerBps : 0; + const secondsUntilNextDecrease = + currentRateBps <= terms.minRateBps + ? 0 + : Math.max(1, Math.ceil(secondsPerBps - elapsedSinceLastDecrease)); + + return { + ...terms, + currentRateBps, + progressPercent: Math.min(100, Math.max(0, progress * 100)), + secondsUntilNextDecrease, + isComplete: nowSeconds >= terms.endsAt || currentRateBps <= terms.minRateBps, + }; +} + +export function formatAuctionCountdown(seconds: number): string { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safeSeconds / 60); + const remainingSeconds = safeSeconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts index 02aa7a8..bf90e5d 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -45,6 +45,14 @@ export interface Invoice { funder?: string; funded_at?: bigint; token?: string; + auction_mode?: boolean; + auctionMode?: boolean; + rate_mode?: string; + mode?: string; + start_rate?: number; + min_rate?: number; + auction_started_at?: bigint; + auction_ends_at?: bigint; } export interface SubmittedInvoiceResult { @@ -183,6 +191,14 @@ export async function getInvoice(id: bigint): Promise { funder: native.funder, funded_at: native.funded_at, token: native.token, + auction_mode: native.auction_mode, + auctionMode: native.auctionMode, + rate_mode: native.rate_mode, + mode: native.mode, + start_rate: native.start_rate, + min_rate: native.min_rate, + auction_started_at: native.auction_started_at, + auction_ends_at: native.auction_ends_at, }; } throw new Error(`Failed to get invoice ${id}`);