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}`);
|