diff --git a/ROADMAP.md b/ROADMAP.md
index 98976da..7ed1775 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,7 +4,7 @@
| | |
|---|---|
-| **Last updated** | 2026-11-01 (fix/scorecard-hardening: M3-33 DONE) |
+| **Last updated** | 2026-11-02 (feat/payee-csv-export-76: X-05 DONE) |
| **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) |
| **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) |
| **Readiness** | See [§3](#3-progress-snapshot) |
@@ -394,7 +394,7 @@ Resolve each with an ADR and link it here. Move resolved rows to the bottom with
| X-02 | `LCK-10` Payee "applied" acknowledgment (contract event) and UI | contracts | P1 | TODO | P-05 | Event emitted and shown on receipt; ADR if contract surface changes |
| X-03 | `REG-7` New-payee release delay in contract; set parameters via ADR | contracts | P1 | TODO | DEC-14 | Property tests updated; docs updated |
| X-04 | `SND-8` Email notifications (opt-in, provider-agnostic, `subscriptions` table) | sdk | P1 | TODO | P-05 | Opt-in and unsubscribe work; no personal data in logs |
-| X-05 | `PAY-6` Payee CSV export | app | P1 | TODO | M3-11 | Export matches dashboard |
+| X-05 | `PAY-6` Payee CSV export | app | P1 | DONE | M3-11 | Export matches dashboard |
| X-06 | `PLT-4` Per-lock and global caps: set values and test enforcement | contracts | P1 | TODO | M1-10 | Cap breach rejected in tests; values documented |
| X-07 | `PLT-5` Upgrade timelock before mainnet | contracts | P1 | TODO | M1-20 | Timelock enforced; upgrade runbook updated |
| X-08 | Re-run property, budget, and integration suites after P1 contract changes | contracts | P1 | TODO | X-02, X-03, X-06, X-07 | All green; budgets recorded |
@@ -542,6 +542,7 @@ Newest first. One entry per PR. Required for every contribution (see §2).
| Date | PR / ref | Repo | Rows touched | Summary |
|---|---|---|---|---|
+| 2026-11-02 | `feat/payee-csv-export-76` | app | X-05 DONE | Closes #76: added ExportCSVButton and lib/csv.ts for payee lock history export with RFC-4180 escaping, BigInt base units conversion to USDC decimals, and ISO 8601 UTC date formatting; externalized strings in messages/en.json. All 214 tests pass and build clean. |
| 2026-11-01 | `fix/scorecard-hardening` | app | M3-33 DONE | OpenSSF Scorecard hardening: pinned malware-scan.yml actions to commit SHAs and semgrep to 1.136.0 (Pinned-Dependencies); removed curl-pipe downloadThenRun pattern in e2e.yml; enriched SECURITY.md with vulnerability reporting instructions and response/disclosure timelines. All 187 tests pass. |
| 2026-11-01 | `docs/drips-wave-application` | app | no row changes | Added dedicated DRIPS_WAVE.md application and review summary document for the Drips Wave Stellar program review panel, detailing Next.js web application architecture, live Vercel testnet deployment, strict privacy boundaries, testing hygiene, and maintainers. |
| 2026-11-01 | `feat/route-loading-error-states` | app | M3-27 DONE | Closes #37: added route-level designed states (app/loading.tsx, app/locks/loading.tsx, app/payee/loading.tsx, app/error.tsx with autofocus retry, and honest app/not-found.tsx); added accessible Spinner and StatePanel in components/ui/; externalized strings to messages/en.json. |
diff --git a/app/payee/PayeeView.tsx b/app/payee/PayeeView.tsx
index ed5aa80..40b19ce 100644
--- a/app/payee/PayeeView.tsx
+++ b/app/payee/PayeeView.tsx
@@ -2,6 +2,7 @@
import { useId } from "react";
import { AmountDisplay } from "@/components/money/AmountDisplay";
+import { ExportCSVButton } from "@/components/payee/ExportCSVButton";
import { DateTimeDisplay } from "@/components/time/DateTimeDisplay";
import { ASSET_CODE } from "@/lib/constants";
import { shortAddress } from "@/lib/i18n/format";
@@ -94,6 +95,11 @@ export function PayeeView({ payees }: { payees: IndexedPayee[] }) {
{d.phase === "loading" ?
{t("pages.payee.loading")}
: null}
{d.phase === "readFailed" ? {t("pages.payee.readFailed")}
: null}
+ {d.phase === "ready" ? (
+
+
+
+ ) : null}
{d.phase === "ready" && d.locks.length === 0 ? {t("pages.payee.empty")}
: null}
{d.phase === "ready" && d.locks.length > 0 ? (
diff --git a/components/payee/ExportCSVButton.test.ts b/components/payee/ExportCSVButton.test.ts
new file mode 100644
index 0000000..9dbe9d3
--- /dev/null
+++ b/components/payee/ExportCSVButton.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi } from "vitest";
+import * as csvModule from "@/lib/csv";
+import { t } from "@/lib/i18n/messages";
+import { ExportCSVButton } from "./ExportCSVButton";
+
+describe("ExportCSVButton component", () => {
+ it("renders a button element with externalized export label", () => {
+ const element = ExportCSVButton({
+ locks: [
+ {
+ id: "1",
+ sender: "GBBM6WDFDWINJB4GO6GCT6EVK375WZWW4NPWBFFEBAMZTXFOWA3KITSK",
+ total: "10000000",
+ released: "0",
+ state: "Open",
+ expiresAt: "2026-12-31T00:00:00.000Z",
+ createdAt: "2026-10-01T00:00:00.000Z",
+ },
+ ],
+ });
+
+ expect(element.type).toBe("button");
+ expect(element.props.type).toBe("button");
+ expect(element.props.children).toBe(t("pages.payee.exportCsv"));
+ expect(element.props["aria-label"]).toBe(t("pages.payee.exportCsv"));
+ expect(element.props.disabled).toBe(false);
+ });
+
+ it("is disabled when locks array is empty", () => {
+ const element = ExportCSVButton({
+ locks: [],
+ });
+
+ expect(element.props.disabled).toBe(true);
+ });
+
+ it("is disabled when disabled prop is explicitly true even with locks", () => {
+ const element = ExportCSVButton({
+ locks: [
+ {
+ id: "1",
+ sender: "GABC",
+ total: "10000000",
+ released: "0",
+ state: "Open",
+ expiresAt: "2026-12-31T00:00:00.000Z",
+ createdAt: "2026-10-01T00:00:00.000Z",
+ },
+ ],
+ disabled: true,
+ });
+
+ expect(element.props.disabled).toBe(true);
+ });
+
+ it("triggers CSV formatting and download on click", () => {
+ const formatLocksSpy = vi.spyOn(csvModule, "formatLocksToCSV");
+ const downloadCSVSpy = vi.spyOn(csvModule, "downloadCSV").mockImplementation(() => {});
+
+ const mockLocks = [
+ {
+ id: "5",
+ sender: "GSENDER",
+ total: 50_000_000n,
+ released: 10_000_000n,
+ unlockDate: "2026-11-15T00:00:00.000Z",
+ status: "Open",
+ createdAt: "2026-10-15T00:00:00.000Z",
+ },
+ ];
+
+ const element = ExportCSVButton({
+ locks: mockLocks,
+ filename: "custom-export.csv",
+ });
+
+ element.props.onClick();
+
+ expect(formatLocksSpy).toHaveBeenCalledWith(mockLocks);
+ expect(downloadCSVSpy).toHaveBeenCalledWith(expect.any(String), "custom-export.csv");
+
+ formatLocksSpy.mockRestore();
+ downloadCSVSpy.mockRestore();
+ });
+});
diff --git a/components/payee/ExportCSVButton.tsx b/components/payee/ExportCSVButton.tsx
new file mode 100644
index 0000000..9d601ac
--- /dev/null
+++ b/components/payee/ExportCSVButton.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { downloadCSV, formatLocksToCSV, type LockSummary } from "@/lib/csv";
+import { t } from "@/lib/i18n/messages";
+
+export interface ExportCSVButtonProps {
+ locks: LockSummary[];
+ disabled?: boolean;
+ filename?: string;
+ className?: string;
+}
+
+/**
+ * Client-side CSV export button for payee lock history (Roadmap X-05 / PAY-6).
+ * Enables institutional payees (schools, landlords) to export locks for reconciliation.
+ */
+export function ExportCSVButton({
+ locks,
+ disabled = false,
+ filename,
+ className = "self-start rounded border px-3 py-1.5 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-900 disabled:opacity-50",
+}: ExportCSVButtonProps) {
+ function handleExport() {
+ const csv = formatLocksToCSV(locks);
+ downloadCSV(csv, filename);
+ }
+
+ return (
+
+ );
+}
diff --git a/lib/csv.test.ts b/lib/csv.test.ts
new file mode 100644
index 0000000..c2f4009
--- /dev/null
+++ b/lib/csv.test.ts
@@ -0,0 +1,265 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ CSV_COLUMNS,
+ downloadCSV,
+ escapeCSVCell,
+ formatAmount,
+ formatDate,
+ formatLocksToCSV,
+ type LockSummary,
+} from "./csv";
+
+describe("lib/csv", () => {
+ describe("CSV_COLUMNS", () => {
+ it("defines the 7 required columns in exact order", () => {
+ expect(CSV_COLUMNS).toEqual([
+ "lock_id",
+ "sender_address",
+ "total_usdc",
+ "released_usdc",
+ "unlock_date",
+ "status",
+ "created_at",
+ ]);
+ });
+ });
+
+ describe("escapeCSVCell", () => {
+ it("returns empty string for null and undefined", () => {
+ expect(escapeCSVCell(null)).toBe("");
+ expect(escapeCSVCell(undefined)).toBe("");
+ });
+
+ it("returns plain strings unmodified if no special characters are present", () => {
+ expect(escapeCSVCell("hello")).toBe("hello");
+ expect(escapeCSVCell("123")).toBe("123");
+ expect(escapeCSVCell("Open")).toBe("Open");
+ });
+
+ it("wraps cells containing commas in double quotes", () => {
+ expect(escapeCSVCell("Springfield, Elementary")).toBe('"Springfield, Elementary"');
+ });
+
+ it("wraps cells containing double quotes and escapes them with double quotes", () => {
+ expect(escapeCSVCell('School "Alpha"')).toBe('"School ""Alpha"""');
+ expect(escapeCSVCell('quote"inside')).toBe('"quote""inside"');
+ });
+
+ it("wraps cells containing newlines or carriage returns", () => {
+ expect(escapeCSVCell("line1\nline2")).toBe('"line1\nline2"');
+ expect(escapeCSVCell("line1\r\nline2")).toBe('"line1\r\nline2"');
+ });
+ });
+
+ describe("formatAmount", () => {
+ it("formats BigInt base units using 7 decimals (SDK helper)", () => {
+ expect(formatAmount(10_000_000n)).toBe("1");
+ expect(formatAmount(15_500_000n)).toBe("1.55");
+ expect(formatAmount(100n)).toBe("0.00001");
+ expect(formatAmount(0n)).toBe("0");
+ });
+
+ it("formats base unit integer strings as decimals", () => {
+ expect(formatAmount("10000000")).toBe("1");
+ expect(formatAmount("500000000")).toBe("50");
+ expect(formatAmount("0")).toBe("0");
+ });
+
+ it("preserves pre-formatted decimal strings", () => {
+ expect(formatAmount("150.25")).toBe("150.25");
+ expect(formatAmount("0.05")).toBe("0.05");
+ });
+
+ it("handles null or undefined by returning '0'", () => {
+ expect(formatAmount(null)).toBe("0");
+ expect(formatAmount(undefined)).toBe("0");
+ expect(formatAmount("")).toBe("0");
+ });
+ });
+
+ describe("formatDate", () => {
+ it("returns ISO 8601 UTC string for Date objects", () => {
+ const d = new Date("2026-11-01T15:30:00.000Z");
+ expect(formatDate(d)).toBe("2026-11-01T15:30:00.000Z");
+ });
+
+ it("returns ISO 8601 UTC string for parseable string dates", () => {
+ expect(formatDate("2026-10-15T08:00:00Z")).toBe("2026-10-15T08:00:00.000Z");
+ });
+
+ it("returns empty string for null, undefined, or empty values", () => {
+ expect(formatDate(null)).toBe("");
+ expect(formatDate(undefined)).toBe("");
+ expect(formatDate("")).toBe("");
+ });
+ });
+
+ describe("formatLocksToCSV", () => {
+ it("generates header-only CSV when lock list is empty", () => {
+ const csv = formatLocksToCSV([]);
+ expect(csv).toBe(
+ "lock_id,sender_address,total_usdc,released_usdc,unlock_date,status,created_at",
+ );
+ });
+
+ it("formats locks using explicit LockSummary fields", () => {
+ const locks: LockSummary[] = [
+ {
+ lock_id: "42",
+ sender_address: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ",
+ total_usdc: 100_000_000n,
+ released_usdc: 25_000_000n,
+ unlock_date: "2026-12-01T00:00:00.000Z",
+ status: "Open",
+ created_at: "2026-11-01T12:00:00.000Z",
+ },
+ ];
+
+ const csv = formatLocksToCSV(locks, "\n");
+ const lines = csv.split("\n");
+ expect(lines).toHaveLength(2);
+ expect(lines[0]).toBe(
+ "lock_id,sender_address,total_usdc,released_usdc,unlock_date,status,created_at",
+ );
+ expect(lines[1]).toBe(
+ "42,GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ,10,2.5,2026-12-01T00:00:00.000Z,Open,2026-11-01T12:00:00.000Z",
+ );
+ });
+
+ it("formats IndexedLock compatible objects with base unit strings and state", () => {
+ const indexedLocks: LockSummary[] = [
+ {
+ id: "101",
+ sender: "GBBM6WDFDWINJB4GO6GCT6EVK375WZWW4NPWBFFEBAMZTXFOWA3KITSK",
+ total: "50000000",
+ released: "0",
+ state: "Open",
+ expiresAt: "2026-12-31T23:59:59.000Z",
+ createdAt: "2026-10-20T10:00:00.000Z",
+ },
+ ];
+
+ const csv = formatLocksToCSV(indexedLocks, "\n");
+ const lines = csv.split("\n");
+ expect(lines[1]).toBe(
+ "101,GBBM6WDFDWINJB4GO6GCT6EVK375WZWW4NPWBFFEBAMZTXFOWA3KITSK,5,0,2026-12-31T23:59:59.000Z,Open,2026-10-20T10:00:00.000Z",
+ );
+ });
+
+ it("properly escapes fields with commas and double quotes", () => {
+ const locks: LockSummary[] = [
+ {
+ lock_id: 'ID,"Special"',
+ sender_address: "Address, with comma",
+ total_usdc: "100.00",
+ released_usdc: "0.00",
+ unlock_date: "2026-11-10T00:00:00.000Z",
+ status: 'Status "Pending"',
+ created_at: "2026-11-01T00:00:00.000Z",
+ },
+ ];
+
+ const csv = formatLocksToCSV(locks, "\n");
+ const lines = csv.split("\n");
+ expect(lines[1]).toBe(
+ '"ID,""Special"""' +
+ "," +
+ '"Address, with comma"' +
+ ",100.00,0.00,2026-11-10T00:00:00.000Z," +
+ '"Status ""Pending"""' +
+ ",2026-11-01T00:00:00.000Z",
+ );
+ });
+
+ it("handles multiple locks and maintains correct column order", () => {
+ const locks: LockSummary[] = [
+ {
+ id: "1",
+ sender: "SENDER1",
+ total: 10_000_000n,
+ released: 10_000_000n,
+ unlockDate: "2026-11-01T00:00:00Z",
+ status: "Completed",
+ createdAt: "2026-10-01T00:00:00Z",
+ },
+ {
+ id: "2",
+ sender: "SENDER2",
+ total: 20_000_000n,
+ released: 0n,
+ unlockDate: "2026-11-02T00:00:00Z",
+ status: "Declined",
+ createdAt: "2026-10-02T00:00:00Z",
+ },
+ ];
+
+ const csv = formatLocksToCSV(locks);
+ expect(csv).toContain("\r\n");
+ const lines = csv.split("\r\n");
+ expect(lines).toHaveLength(3);
+ const line1 = lines[1] ?? "";
+ const line2 = lines[2] ?? "";
+ expect(line1.split(",")[0]).toBe("1");
+ expect(line1.split(",")[5]).toBe("Completed");
+ expect(line2.split(",")[0]).toBe("2");
+ expect(line2.split(",")[5]).toBe("Declined");
+ });
+ });
+
+ describe("downloadCSV", () => {
+ it("safely no-ops in non-browser environment", () => {
+ expect(() => downloadCSV("test")).not.toThrow();
+ });
+
+ it("creates an object URL, clicks anchor, and revokes URL in DOM environment", () => {
+ const fakeUrl = "blob:https://kinlock.app/fake-uuid";
+ const createObjectURLMock = vi.fn().mockReturnValue(fakeUrl);
+ const revokeObjectURLMock = vi.fn();
+ globalThis.URL.createObjectURL = createObjectURLMock;
+ globalThis.URL.revokeObjectURL = revokeObjectURLMock;
+
+ const clickMock = vi.fn();
+ const mockElement = {
+ setAttribute: vi.fn(),
+ style: {},
+ click: clickMock,
+ };
+
+ const appendChildMock = vi.fn();
+ const removeChildMock = vi.fn();
+ const createElementMock = vi.fn().mockReturnValue(mockElement);
+
+ const fakeDoc = {
+ body: {
+ appendChild: appendChildMock,
+ removeChild: removeChildMock,
+ },
+ createElement: createElementMock,
+ };
+
+ // Temporarily attach window and document to globalThis
+ const originalWindow = globalThis.window;
+ const originalDocument = globalThis.document;
+ // @ts-expect-error test mock
+ globalThis.window = {};
+ // @ts-expect-error test mock
+ globalThis.document = fakeDoc;
+
+ try {
+ downloadCSV("lock_id,sender_address\n1,GABC", "test-locks.csv");
+
+ expect(createObjectURLMock).toHaveBeenCalledTimes(1);
+ expect(createElementMock).toHaveBeenCalledWith("a");
+ expect(mockElement.setAttribute).toHaveBeenCalledWith("href", fakeUrl);
+ expect(mockElement.setAttribute).toHaveBeenCalledWith("download", "test-locks.csv");
+ expect(appendChildMock).toHaveBeenCalledWith(mockElement);
+ expect(clickMock).toHaveBeenCalledTimes(1);
+ expect(removeChildMock).toHaveBeenCalledWith(mockElement);
+ expect(revokeObjectURLMock).toHaveBeenCalledWith(fakeUrl);
+ } finally {
+ globalThis.window = originalWindow;
+ globalThis.document = originalDocument;
+ }
+ });
+ });
+});
diff --git a/lib/csv.ts b/lib/csv.ts
new file mode 100644
index 0000000..2446631
--- /dev/null
+++ b/lib/csv.ts
@@ -0,0 +1,137 @@
+/**
+ * CSV export for payee lock history. Roadmap row X-05 (PAY-6).
+ * Fulfills institutional reconciliation requirements.
+ */
+
+import { fromBaseUnits } from "@/lib/sdk";
+
+export interface LockSummary {
+ lock_id?: string | number | bigint;
+ id?: string | number | bigint;
+
+ sender_address?: string;
+ sender?: string;
+
+ total_usdc?: string | bigint | number;
+ total?: string | bigint | number;
+
+ released_usdc?: string | bigint | number;
+ released?: string | bigint | number;
+
+ unlock_date?: string | Date | number | null;
+ unlockDate?: string | Date | number | null;
+ expires_at?: string | Date | number | null;
+ expiresAt?: string | Date | number | null;
+
+ status?: string;
+ state?: string;
+
+ created_at?: string | Date | number | null;
+ createdAt?: string | Date | number | null;
+
+ [key: string]: unknown;
+}
+
+export const CSV_COLUMNS = [
+ "lock_id",
+ "sender_address",
+ "total_usdc",
+ "released_usdc",
+ "unlock_date",
+ "status",
+ "created_at",
+] as const;
+
+export function escapeCSVCell(value: unknown): string {
+ if (value === null || value === undefined) return "";
+ const str = String(value);
+ if (str.includes('"') || str.includes(",") || str.includes("\n") || str.includes("\r")) {
+ return `"${str.replaceAll('"', '""')}"`;
+ }
+ return str;
+}
+
+export function formatAmount(value: unknown): string {
+ if (value === null || value === undefined) return "0";
+ if (typeof value === "bigint") {
+ return fromBaseUnits(value);
+ }
+ if (typeof value === "number") {
+ return fromBaseUnits(BigInt(Math.trunc(value)));
+ }
+ const s = String(value).trim();
+ if (!s) return "0";
+ if (s.includes(".")) {
+ return s;
+ }
+ try {
+ return fromBaseUnits(BigInt(s));
+ } catch {
+ return s;
+ }
+}
+
+export function formatDate(value: unknown): string {
+ if (value === null || value === undefined || value === "") return "";
+ const d = value instanceof Date ? value : new Date(value as string | number);
+ if (Number.isNaN(d.getTime())) return String(value);
+ return d.toISOString();
+}
+
+/**
+ * Formats a list of locks into an RFC-4180 compliant CSV string.
+ * Columns: lock_id,sender_address,total_usdc,released_usdc,unlock_date,status,created_at
+ */
+export function formatLocksToCSV(locks: LockSummary[], eol = "\r\n"): string {
+ const header = CSV_COLUMNS.join(",");
+ const rows = locks.map((lock) => {
+ const lockId = lock.lock_id ?? lock.id ?? "";
+ const sender = lock.sender_address ?? lock.sender ?? "";
+ const total = formatAmount(lock.total_usdc ?? lock.total);
+ const released = formatAmount(lock.released_usdc ?? lock.released);
+
+ const rawUnlock =
+ lock.unlock_date !== undefined
+ ? lock.unlock_date
+ : lock.unlockDate !== undefined
+ ? lock.unlockDate
+ : (lock.expiresAt ?? lock.expires_at ?? "");
+ const unlockDate = formatDate(rawUnlock);
+
+ const status = lock.status ?? lock.state ?? "";
+ const createdAt = formatDate(lock.created_at ?? lock.createdAt);
+
+ return [
+ escapeCSVCell(lockId),
+ escapeCSVCell(sender),
+ escapeCSVCell(total),
+ escapeCSVCell(released),
+ escapeCSVCell(unlockDate),
+ escapeCSVCell(status),
+ escapeCSVCell(createdAt),
+ ].join(",");
+ });
+
+ return [header, ...rows].join(eol);
+}
+
+/**
+ * Triggers a client-side browser download of the CSV content.
+ */
+export function downloadCSV(csvContent: string, filename?: string): void {
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ return;
+ }
+ const dateStr = new Date().toISOString().slice(0, 10);
+ const name = filename || `kinlock-payee-locks-${dateStr}.csv`;
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.setAttribute("href", url);
+ link.setAttribute("download", name);
+ link.style.display = "none";
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
diff --git a/messages/en.json b/messages/en.json
index a4cff1d..466bc22 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -269,7 +269,8 @@
"lockLabel": "Payment",
"totalLabel": "Total",
"createdLabel": "Created",
- "viewLink": "View and claim"
+ "viewLink": "View and claim",
+ "exportCsv": "Export CSV"
},
"attester": {
"title": "Attester checks"