Skip to content
Open
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
9 changes: 3 additions & 6 deletions src/components/LPDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -326,9 +327,7 @@ export default function LPDashboard() {
label: "Discount",
sortable: true,
renderCell: (inv) => (
<span className="bg-primary-container text-on-primary-container px-2 py-0.5 rounded text-xs font-bold">
{(inv.discount_rate / 100).toFixed(2)}%
</span>
<AuctionRateTicker invoice={inv} />
),
},
{
Expand Down Expand Up @@ -698,9 +697,7 @@ export default function LPDashboard() {
<TokenAwareAmount amount={invoice.amount} invoice={invoice} tokenMap={tokenMap} defaultToken={defaultToken} />
</td>
<td className="px-6 py-5">
<span className="bg-primary-container text-on-primary-container px-2 py-0.5 rounded text-xs font-bold">
{(invoice.discount_rate / 100).toFixed(2)}%
</span>
<AuctionRateTicker invoice={invoice} />
</td>
<td className="px-6 py-5 text-sm">{formatDate(invoice.due_date)}</td>
<td className="px-6 py-5 font-bold text-green-600">
Expand Down
73 changes: 73 additions & 0 deletions src/components/__tests__/AuctionRateTicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AuctionRateTicker invoice={fixedInvoice} />);

expect(screen.getByText("5.00%")).toBeInTheDocument();
});

it("renders the live Dutch auction rate display", () => {
const nowSeconds = Math.floor(Date.now() / 1000);

render(
<AuctionRateTicker
invoice={{
...fixedInvoice,
auction_mode: true,
start_rate: 900,
min_rate: 300,
auction_started_at: BigInt(nowSeconds - 300),
auction_ends_at: BigInt(nowSeconds + 300),
}}
/>
);

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(
<AuctionRateTicker
invoice={{
...fixedInvoice,
auction_mode: true,
start_rate: 900,
min_rate: 300,
auction_started_at: BigInt(nowSeconds - 600),
auction_ends_at: BigInt(nowSeconds - 1),
}}
/>
);

expect(screen.getByText("Current Rate: 300 bps")).toBeInTheDocument();
expect(screen.getByText("Minimum rate reached")).toBeInTheDocument();
});
});
77 changes: 77 additions & 0 deletions src/utils/__tests__/dutchAuction.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
84 changes: 84 additions & 0 deletions src/utils/dutchAuction.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
16 changes: 16 additions & 0 deletions src/utils/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -183,6 +191,14 @@ export async function getInvoice(id: bigint): Promise<Invoice> {
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}`);
Expand Down