diff --git a/app/governance/page.tsx b/app/governance/page.tsx
index a810b13..fec8162 100644
--- a/app/governance/page.tsx
+++ b/app/governance/page.tsx
@@ -31,6 +31,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" },
Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" },
+ Vetoed: { color: "bg-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" },
};
const { color, icon } = config[status];
return (
@@ -202,10 +203,13 @@ export default function GovernancePage() {
}, []);
useEffect(() => {
- load();
+ const timeout = window.setTimeout(load, 0);
// Refresh every 30 s for real-time vote counts
- const interval = setInterval(load, 30_000);
- return () => clearInterval(interval);
+ const interval = window.setInterval(load, 30_000);
+ return () => {
+ window.clearTimeout(timeout);
+ window.clearInterval(interval);
+ };
}, [load]);
useEffect(() => {
diff --git a/src/components/StepIndicator.tsx b/src/components/StepIndicator.tsx
new file mode 100644
index 0000000..2a2fbb9
--- /dev/null
+++ b/src/components/StepIndicator.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+interface StepIndicatorProps {
+ steps: readonly string[];
+ currentStep: number;
+}
+
+export default function StepIndicator({ steps, currentStep }: StepIndicatorProps) {
+ return (
+
+ {steps.map((step, index) => {
+ const stepNumber = index + 1;
+ const isCurrent = index === currentStep;
+ const isComplete = index < currentStep;
+
+ return (
+ -
+
+ {isComplete ? (
+ check
+ ) : (
+ stepNumber
+ )}
+
+ {step}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/SubmitInvoiceForm.tsx b/src/components/SubmitInvoiceForm.tsx
index 18fbbea..0c01162 100644
--- a/src/components/SubmitInvoiceForm.tsx
+++ b/src/components/SubmitInvoiceForm.tsx
@@ -175,19 +175,13 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
- const nextErrors = validateInvoiceForm(
- { ...form, tokenId: effectiveTokenId },
- isConnected,
- selectedToken?.decimals ?? 7,
- selectedToken?.symbol ?? "token",
- );
- if (networkMismatch) {
- nextErrors.wallet = t("submitForm.walletError", { network: NETWORK_NAME });
- }
- if (!selectedToken && !tokensLoading) {
- nextErrors.tokenId = t("submitForm.noTokensAvailable");
+ if (currentStep !== 2) {
+ goToNextStep();
+ return;
}
+ const nextErrors = getValidationErrors();
+
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
@@ -364,9 +358,7 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn
)}
- )}
-
-
+
): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}
-function getTokenIconLabel(token: TokenLike): string {
+function getTokenIconLabel(token: Pick): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}
diff --git a/src/components/__tests__/SubmitInvoiceForm.test.tsx b/src/components/__tests__/SubmitInvoiceForm.test.tsx
index 2fe1172..d4d9da0 100644
--- a/src/components/__tests__/SubmitInvoiceForm.test.tsx
+++ b/src/components/__tests__/SubmitInvoiceForm.test.tsx
@@ -12,7 +12,7 @@
*/
import React from "react";
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SubmitInvoiceForm from "../SubmitInvoiceForm";
@@ -77,6 +77,43 @@ function connectWallet(address = VALID_STELLAR_FREELANCER) {
walletState.isConnected = true;
}
+function clickNext() {
+ fireEvent.click(screen.getByRole("button", { name: /next/i }));
+}
+
+function fillInvoiceDetails({
+ payer = VALID_STELLAR_PAYER,
+ amount = "2000",
+ dueDate = "2099-06-15",
+} = {}) {
+ fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: payer } });
+ fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: amount } });
+ fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: dueDate } });
+}
+
+function goToTokenAndRate() {
+ clickNext();
+ expect(screen.getByRole("region", { name: "Token & Rate" })).toBeInTheDocument();
+}
+
+function goToReviewAndSubmit(discountRate = "3.5") {
+ fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: discountRate } });
+ clickNext();
+ expect(screen.getByRole("region", { name: "Review & Submit" })).toBeInTheDocument();
+}
+
+function completeStepperAndSubmit({
+ payer = VALID_STELLAR_PAYER,
+ amount = "2000",
+ dueDate = "2099-06-15",
+ discountRate = "3.5",
+} = {}) {
+ fillInvoiceDetails({ payer, amount, dueDate });
+ goToTokenAndRate();
+ goToReviewAndSubmit(discountRate);
+ fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+}
+
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("SubmitInvoiceForm", () => {
@@ -102,7 +139,7 @@ describe("SubmitInvoiceForm", () => {
it("shows the wallet error banner when submitting without a connected wallet", async () => {
render();
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ completeStepperAndSubmit();
expect(
await screen.findByText(/connect your freighter wallet to submit an invoice/i),
@@ -117,7 +154,7 @@ describe("SubmitInvoiceForm", () => {
render();
// Amount and dueDate are also empty so multiple errors fire – we only care about payer
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ clickNext();
expect(await screen.findByText(/payer stellar address is required/i)).toBeInTheDocument();
});
@@ -128,7 +165,7 @@ describe("SubmitInvoiceForm", () => {
fireEvent.change(screen.getByPlaceholderText("G..."), {
target: { value: "not-a-stellar-key" },
});
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ clickNext();
expect(
await screen.findByText(/enter a valid stellar public key for the payer/i),
@@ -142,7 +179,7 @@ describe("SubmitInvoiceForm", () => {
fireEvent.change(screen.getByPlaceholderText("5000.00"), {
target: { value: "not-a-number" },
});
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ clickNext();
expect(await screen.findByText(/enter a valid invoice amount in usdc/i)).toBeInTheDocument();
});
@@ -152,7 +189,7 @@ describe("SubmitInvoiceForm", () => {
render();
fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "0" } });
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ clickNext();
expect(await screen.findByText(/enter a valid invoice amount in usdc/i)).toBeInTheDocument();
});
@@ -162,7 +199,7 @@ describe("SubmitInvoiceForm", () => {
render();
// Leave dueDate empty
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ clickNext();
expect(await screen.findByText(/select a valid due date/i)).toBeInTheDocument();
});
@@ -170,8 +207,10 @@ describe("SubmitInvoiceForm", () => {
connectWallet();
render();
+ fillInvoiceDetails();
+ goToTokenAndRate();
fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: "0" } });
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ clickNext();
expect(
await screen.findByText(/discount rate must be between 0\.01% and 50%/i),
@@ -182,8 +221,10 @@ describe("SubmitInvoiceForm", () => {
connectWallet();
render();
+ fillInvoiceDetails();
+ goToTokenAndRate();
fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: "51" } });
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ clickNext();
expect(
await screen.findByText(/discount rate must be between 0\.01% and 50%/i),
@@ -197,7 +238,7 @@ describe("SubmitInvoiceForm", () => {
walletState.networkMismatch = true;
render();
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ completeStepperAndSubmit();
expect(
await screen.findByText(/freighter must be connected to testnet/i),
@@ -218,7 +259,10 @@ describe("SubmitInvoiceForm", () => {
it("updates the live yield preview as the user types amount and discount rate", () => {
render();
+ fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } });
fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "10000" } });
+ fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-06-15" } });
+ goToTokenAndRate();
fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: "5" } });
// Face value
@@ -238,6 +282,27 @@ describe("SubmitInvoiceForm", () => {
expect(screen.getAllByText("0 USDC").length).toBeGreaterThanOrEqual(3);
});
+ it("moves through steps, supports Back, and keeps entered details", () => {
+ connectWallet();
+ render();
+
+ fillInvoiceDetails({ amount: "2500", dueDate: "2099-08-20" });
+ goToTokenAndRate();
+ fireEvent.change(screen.getByDisplayValue("3.00"), { target: { value: "4.25" } });
+ goToReviewAndSubmit("4.25");
+
+ expect(screen.getByText("You will receive")).toBeInTheDocument();
+ expect(screen.getAllByText(/4\.25%/).length).toBeGreaterThan(0);
+ expect(screen.getByText(/LP yield is/i)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /back/i }));
+ expect(screen.getByDisplayValue("4.25")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: /back/i }));
+ expect(screen.getByDisplayValue(VALID_STELLAR_PAYER)).toBeInTheDocument();
+ expect(screen.getByDisplayValue("2500")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("2099-08-20")).toBeInTheDocument();
+ });
+
// ── Successful submission ─────────────────────────────────────────────────
it("submits a fully valid invoice and displays the returned invoice ID and tx hash", async () => {
@@ -250,8 +315,9 @@ describe("SubmitInvoiceForm", () => {
target: { value: VALID_STELLAR_PAYER },
});
fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "2000" } });
- fireEvent.change(screen.getByDisplayValue("3.00"), { target: { value: "3.5" } });
fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-06-15" } });
+ goToTokenAndRate();
+ goToReviewAndSubmit("3.5");
fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
// Contract call is made with correctly parsed values
@@ -279,11 +345,7 @@ describe("SubmitInvoiceForm", () => {
render();
- fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } });
- fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "500" } });
- fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-01-01" } });
-
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ completeStepperAndSubmit({ amount: "500", dueDate: "2099-01-01" });
await waitFor(() =>
expect(screen.getByRole("button", { name: /submitting invoice/i })).toBeDisabled(),
@@ -298,11 +360,7 @@ describe("SubmitInvoiceForm", () => {
render();
- fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } });
- fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "1000" } });
- fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-03-01" } });
-
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ completeStepperAndSubmit({ amount: "1000", dueDate: "2099-03-01" });
expect(await screen.findByText("contract: insufficient gas")).toBeInTheDocument();
expect(updateToast).toHaveBeenCalledWith(
@@ -317,16 +375,12 @@ describe("SubmitInvoiceForm", () => {
render();
- fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } });
- fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "1200" } });
- fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-09-09" } });
-
- fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
+ completeStepperAndSubmit({ amount: "1200", dueDate: "2099-09-09" });
await waitFor(() => expect(updateToast).toHaveBeenCalled());
expect(addToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: "pending", title: /submitting invoice/i }),
+ expect.objectContaining({ type: "pending", title: expect.stringMatching(/submitting invoice/i) }),
);
expect(updateToast).toHaveBeenCalledWith(
"toast-id-1",
diff --git a/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx b/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx
index e3822c2..949dc24 100644
--- a/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx
+++ b/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SubmitInvoiceForm from "../SubmitInvoiceForm";
@@ -24,12 +24,11 @@ const mockAddressBook = [
{ id: "1", address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABC", nickname: "Acme Corp" },
{ id: "2", address: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBD", nickname: "Beta LLC" },
];
+let currentAddressBook = mockAddressBook;
vi.mock("../../context/WalletContext", () => ({
useWallet: () => ({
- ...walletAddress,
- address: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6", // Freelancer address
- isConnected: true,
+ ...walletState,
}),
}));
@@ -41,9 +40,9 @@ vi.mock("../../hooks/useAddressBook", () => ({
default: () => ({
addressBook: mockAddressBook,
searchAddresses: (query: string) => {
- if (!query) return mockAddressBook;
+ if (!query) return currentAddressBook;
const lowerQuery = query.toLowerCase();
- return mockAddressBook.filter(
+ return currentAddressBook.filter(
(entry) =>
entry.nickname.toLowerCase().includes(lowerQuery) ||
entry.address.toLowerCase().includes(lowerQuery)
@@ -73,6 +72,7 @@ vi.mock("../../utils/soroban", () => ({
describe("SubmitInvoiceForm Address Book Integration", () => {
beforeEach(() => {
+ currentAddressBook = mockAddressBook;
walletState.address = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6";
walletState.isConnected = true;
walletState.error = null;
@@ -92,7 +92,7 @@ describe("SubmitInvoiceForm Address Book Integration", () => {
// Should show dropdown with matching addresses
expect(screen.getByText("Acme Corp")).toBeInTheDocument();
- expect(screen.getByText("G...ABC")).toBeInTheDocument();
+ expect(screen.getByText("GAAAAA...AABC")).toBeInTheDocument();
});
it("selects address from dropdown when clicking", async () => {
@@ -118,14 +118,18 @@ describe("SubmitInvoiceForm Address Book Integration", () => {
// Press ArrowDown to highlight first item
fireEvent.keyDown(payerInput, { key: "ArrowDown" });
- expect(screen.getByText("Acme Corp")).toHaveClass("bg-primary text-surface-container-lowest");
+ const acmeOption = screen.getByText("Acme Corp").closest(".cursor-pointer");
+ if (!acmeOption) {
+ throw new Error("Acme option was not rendered");
+ }
+ expect(acmeOption).toHaveClass("bg-primary", "text-surface-container-lowest");
// Press Enter to select
fireEvent.keyDown(payerInput, { key: "Enter" });
expect(payerInput).toHaveValue("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABC");
// Dropdown should be closed
- expect(screen.queryByText("Acme Corp")).not.toHaveClass("bg-primary text-surface-container-lowest");
+ expect(screen.queryByText("Acme Corp")).not.toBeInTheDocument();
});
it("clears address book query when Escape is pressed", async () => {
@@ -137,23 +141,14 @@ describe("SubmitInvoiceForm Address Book Integration", () => {
// Press Escape
fireEvent.keyDown(payerInput, { key: "Escape" });
- // Dropdown should be closed and query cleared
- expect(payerInput).toHaveValue("");
+ // Dropdown should be closed while preserving the typed payer text.
+ expect(payerInput).toHaveValue("acme");
+ expect(screen.queryByText("Acme Corp")).not.toBeInTheDocument();
});
it("does not show dropdown when address book is empty", async () => {
- // Mock empty address book
- vi.mock("../../hooks/useAddressBook", () => ({
- default: () => ({
- addressBook: [],
- searchAddresses: () => [],
- }),
- }));
-
- // Need to re-render with new mock
- await waitFor(() => {
- render();
- });
+ currentAddressBook = [];
+ render();
const payerInput = screen.getByPlaceholderText("G...");
fireEvent.change(payerInput, { target: { value: "acme" } });
@@ -161,4 +156,4 @@ describe("SubmitInvoiceForm Address Book Integration", () => {
// Should not show any dropdown items
expect(screen.queryByText("Acme Corp")).not.toBeInTheDocument();
});
-});
\ No newline at end of file
+});
diff --git a/src/utils/evidence.ts b/src/utils/evidence.ts
index 55396ad..c661f8f 100644
--- a/src/utils/evidence.ts
+++ b/src/utils/evidence.ts
@@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise {
if (typeof crypto !== "undefined" && crypto.subtle) {
const encoded = new TextEncoder().encode(normalized);
- const digest = await crypto.subtle.digest("SHA-256", encoded);
+ const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
diff --git a/src/utils/federation.ts b/src/utils/federation.ts
index 11922eb..d1b78c2 100644
--- a/src/utils/federation.ts
+++ b/src/utils/federation.ts
@@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants";
const horizonServer = new rpc.Server(RPC_URL);
const federationCache = new Map();
+interface AccountHomeDomain {
+ home_domain?: string;
+ homeDomain?: string;
+}
+
export async function resolveFederatedAddress(address: string): Promise {
if (!address) return address;
const cached = federationCache.get(address);
@@ -11,7 +16,8 @@ export async function resolveFederatedAddress(address: string): Promise
try {
const account = await horizonServer.getAccount(address);
- const homeDomain = account.home_domain ?? (account as any).homeDomain;
+ const { home_domain: homeDomainSnake, homeDomain: homeDomainCamel } = account as AccountHomeDomain;
+ const homeDomain = homeDomainSnake ?? homeDomainCamel;
if (!homeDomain) return address;
const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`);