From 2f89dc9e0e9c33869166a54cc292b1edd890b3aa Mon Sep 17 00:00:00 2001
From: neumattock <152253273+newmattock@users.noreply.github.com>
Date: Tue, 26 May 2026 09:23:18 -0700
Subject: [PATCH] feat: add Dutch auction rate ticker
---
src/components/AuctionRateTicker.tsx | 60 +++++++++++++
src/components/LPDashboard.tsx | 9 +-
.../__tests__/AuctionRateTicker.test.tsx | 73 ++++++++++++++++
src/utils/__tests__/dutchAuction.test.ts | 77 +++++++++++++++++
src/utils/dutchAuction.ts | 84 +++++++++++++++++++
src/utils/soroban.ts | 16 ++++
vitest.config.ts | 2 +-
7 files changed, 314 insertions(+), 7 deletions(-)
create mode 100644 src/components/AuctionRateTicker.tsx
create mode 100644 src/components/__tests__/AuctionRateTicker.test.tsx
create mode 100644 src/utils/__tests__/dutchAuction.test.ts
create mode 100644 src/utils/dutchAuction.ts
diff --git a/src/components/AuctionRateTicker.tsx b/src/components/AuctionRateTicker.tsx
new file mode 100644
index 0000000..63bc324
--- /dev/null
+++ b/src/components/AuctionRateTicker.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import type { Invoice } from "@/utils/soroban";
+import {
+ calculateDutchAuctionState,
+ formatAuctionCountdown,
+ getDutchAuctionTerms,
+} from "@/utils/dutchAuction";
+
+export default function AuctionRateTicker({ invoice }: { invoice: Invoice }) {
+ const terms = useMemo(() => getDutchAuctionTerms(invoice), [invoice]);
+ const [nowSeconds, setNowSeconds] = useState(() => Math.floor(Date.now() / 1000));
+
+ useEffect(() => {
+ if (!terms) return;
+ const interval = window.setInterval(() => {
+ setNowSeconds(Math.floor(Date.now() / 1000));
+ }, 1000);
+ return () => window.clearInterval(interval);
+ }, [terms]);
+
+ if (!terms) {
+ return (
+
+ {(invoice.discount_rate / 100).toFixed(2)}%
+
+ );
+ }
+
+ const state = calculateDutchAuctionState(terms, nowSeconds);
+ const endTime = new Date(terms.endsAt * 1000).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+
+ return (
+
+
+
+ Current Rate: {state.currentRateBps} bps
+
+
+ decreasing to {state.minRateBps} bps by {endTime}
+
+
+
+ {state.isComplete
+ ? "Minimum rate reached"
+ : `Act now - rate decreases in ${formatAuctionCountdown(state.secondsUntilNextDecrease)}`}
+
+
+
+ );
+}
diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx
index 881a8af..b59959b 100644
--- a/src/components/LPDashboard.tsx
+++ b/src/components/LPDashboard.tsx
@@ -31,6 +31,7 @@ import LastUpdated from "./LastUpdated";
import InvoiceStatusBadge from "./InvoiceStatusBadge";
import FundConfirmModal from "./FundConfirmModal";
import type { DataTableColumn } from "./DataTable";
+import AuctionRateTicker from "./AuctionRateTicker";
type Tab = "discovery" | "my-funded" | "watchlist";
@@ -276,9 +277,7 @@ export default function LPDashboard() {
label: "Discount",
sortable: true,
renderCell: (inv) => (
-
- {(inv.discount_rate / 100).toFixed(2)}%
-
+
),
},
{
@@ -576,9 +575,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 67d9969..29860d1 100644
--- a/src/utils/soroban.ts
+++ b/src/utils/soroban.ts
@@ -40,6 +40,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 {
@@ -162,6 +170,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}`);
diff --git a/vitest.config.ts b/vitest.config.ts
index a222b59..f89dda0 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
- '@': path.resolve(__dirname, '.'),
+ '@': path.resolve(__dirname, 'src'),
},
},
test: {
|