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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ The Next.js front end: payee payment requests, the sender's lock flow, the payee
| `/send` | Sender picks a payee, sets a schedule, sees preflight results, funds a lock, gets a claim link |
| `/claim/[id]` | Payee verifies the reference against the on-chain `ref_hash`, then releases or declines |
| `/locks/[id]` | Lock detail with refund, shown only when the contract allows it |
| `/r/[txHash]/[eventIndex]`, `/verify` | Receipts rebuilt from chain data. "Payment to verified payee" — nothing more |
| `/payee`, `/attester` | **Stubs** (`M3-11`, `M3-12`) |
| `/payee` | **Stub** (`M3-11`) |
| `/attester` | Read-only payout account checks (`M3-12`): verifies account existence, native XLM float vs reserve requirement, and USDC trustline status (missing vs unauthorized vs room) against Stellar Horizon with zero keys or signing |

> **Status: the core money flows are real; the dashboard isn't.** Claim, decline, refund, receipts and verify read the **chain** through the SDK, never the indexer. `/send` is `M3-06` in progress, the Playwright happy path (`M3-19`) is still a `test.fixme` stub, and there is no live FX rate yet, so amounts show **USD only with a note**. Full state in `ROADMAP.md` (Phase 4: 11 `DONE`, 6 `IN PROGRESS`, 9 `TODO`).

Expand Down
9 changes: 5 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| | |
|---|---|
| **Last updated** | 2026-10-08 (W-05 resolved; 17 Wave issues seeded with seven new rows; issue format proofed against Drips' maintainer guide; the three pre-format issues triaged — app #16 re-emitted, app #15 and registry #7 closed as superseded; repo-side Changelog lines unioned back into canonical by `scripts/roadmap-merge`; that merge's ADR-link normalization anchored so quoted prose cannot be rewritten) |
| **Last updated** | 2026-10-17 (M3-12 DONE: /attester read-only payout account checks implemented with check-attester-readiness.ts parity, zero keys or signing, and full Horizon fixture test coverage) |
| **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) |
Expand Down Expand Up @@ -131,15 +131,15 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| 1 M0 Validate | 17 | 1 | 6 | 10 | 0 | 0 | 0 | 6% |
| 2 Contract + registry | 41 | 24 | 3 | 14 | 0 | 0 | 0 | 59% |
| 3 SDK + indexer | 21 | 14 | 3 | 4 | 0 | 0 | 0 | 67% |
| 4 App | 27 | 15 | 7 | 5 | 0 | 0 | 0 | 56% |
| 4 App | 27 | 16 | 7 | 4 | 0 | 0 | 0 | 59% |
| 5 Testnet pilot | 7 | 0 | 0 | 7 | 0 | 0 | 0 | 0% |
| 6 Pre-mainnet features | 10 | 0 | 0 | 10 | 0 | 0 | 0 | 0% |
| 7 Hardening | 26 | 0 | 0 | 26 | 0 | 0 | 0 | 0% |
| 8 Launch | 8 | 0 | 0 | 8 | 0 | 0 | 0 | 0% |
| 9 Conditional ramp | 9 | 0 | 0 | 0 | 0 | 9 | 0 | n/a |
| 10 Wave + community | 10 | 5 | 1 | 4 | 0 | 0 | 0 | 50% |
| 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a |
| **All** | **211** | **75** | **21** | **90** | **0** | **25** | **0** | **40%** |
| **All** | **211** | **76** | **21** | **89** | **0** | **25** | **0** | **41%** |

---

Expand Down Expand Up @@ -308,7 +308,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| M3-09 | `/claim/[id]`: payee claim page; verify reference against `ref_hash`; release | app | P0 | DONE | M2-03 | Release works; wrong reference flagged |
| M3-10 | Payee `decline` action | app | P0 | DONE | M3-09 | Returns remainder; confirmation step |
| M3-11 | `/payee` dashboard (lists via indexer) | app | P0 | DONE | M2-12 | Filter by status and reference |
| M3-12 | `/attester` tooling: read-only account checks (trustline, authorization, XLM float) | app | P1 | TODO | M1-26 | Checks match the checklist script |
| M3-12 | `/attester` tooling: read-only account checks (trustline, authorization, XLM float) | app | P1 | DONE | M1-26 | Checks match the checklist script |
| M3-13 | `/r/[txHash]/[eventIndex]` receipt page and `/verify` | app | P0 | DONE | M2-06 | Shows "Payment to verified payee"; Valid or Not valid |
| M3-14 | Security headers, strict CSP, `Referrer-Policy`, no third-party scripts on `/claim/*` | app | P0 | DONE | M3-09 | Automated test confirms fragment never appears in any network request |
| M3-15 | `AssetLabel` (code and issuer), `AmountDisplay`, UTC+local time components | app | P0 | IN PROGRESS | M3-01 | Used everywhere money or time is shown |
Expand Down Expand Up @@ -535,6 +535,7 @@ Newest first. One entry per PR. Required for every contribution (see §2).

| Date | PR / ref | Repo | Rows touched | Summary |
|---|---|---|---|---|
| 2026-10-17 | `feat/attester-account-checks` | app | M3-12 DONE | Replaced `/attester` placeholder stub with fully functional read-only payout-account readiness checker (`app/attester/page.tsx`, `AttesterChecker.tsx`, `useAttesterChecker.ts`, `lib/attester/readiness.ts`). Verifies account existence, native XLM float vs reserve requirement (>= 2 XLM), and USDC trustline status (missing vs unauthorized vs room) with strict parity against `kinlock-registry/scripts/check-attester-readiness.ts`. Zero private keys, seed phrases, or transaction submission; zero country/currency hard-coding; full WCAG 2.1 AA accessibility and full Horizon fixture test coverage. |
| 2026-10-08 | `chore/roadmap-copy-back` (#44) | app | no row changes | Copy-back run: `ROADMAP.md` replaced with the merged canonical (`.github` main as of #31) in repo-root link form, and §3 recomputed with `scripts/roadmap-progress`. This is the step `roadmap-merge`'s header requires after every merge and that was skipped between 2026-10-06 and 2026-10-08, which is how the seven copies reached 92 divergent statuses. Verified in this checkout: the file equals canonical byte for byte apart from this entry and the ADR link prefix, `W-05` reads `DONE`, the seven Wave rows are present, duplicate row-ID scan is 0. No row, status, priority, dependency or **Done when** was authored in this repo. |
| 2026-10-08 | `fix/roadmap-merge-link-anchor` | org | no row changes | Found while preparing the copy-back. `roadmap-merge`'s read normalization rewrote the docs-rooted ADR link form into the root-relative one on every copy so the two depths compare equal — correct for links, wrong for prose: the union entry above quotes both forms, so reading that line collapsed its own quote and emitted a Changelog entry contradicting itself, in a copy that no longer matched canonical line for line. Fixed by anchoring the replace on the numeric ADR filename that always follows the slash, so only real links are rewritten, and by re-wording the union entry to name the two forms as `docs/adr/` and `adr/` rather than spelling them as link text: **an entry that describes this rewrite must not itself be a string the rewrite can act on.** Verified: the anchored read leaves a quoted form byte-identical where the blanket replace corrupted it; a full `roadmap-merge` over all five copies against `b12d290` exits 0 with **0 lines** differing from canonical, which also confirms the union is complete; the one real link (`ADR-0026`) still normalizes both ways. No row, status, priority, dependency or **Done when** in this diff. |
| 2026-10-08 | `chore/roadmap-changelog-union` | org | no row changes | Second `scripts/roadmap-merge` run now that the Wave-format PRs have merged (`.github` #26 and #27, contracts #31, sdk #38, app #43, registry #18). Three-way against `b12d290`, the revision where all five copies were last identical. The merge's **entire** output is four lines: the repo-side `docs/wave-issue-format` Changelog rows that each repo wrote into its own copy and canonical never saw. No row ID, status, priority, dependency or **Done when** moved — the seven new rows went in byte-identically, and `W-05`'s `TODO` → `DONE` stayed on the `org` row in canonical as §12 requires, so the repo copies' deliberately-untouched `TODO` was not merged back over it. §3 verified unchanged by `roadmap-progress`: 211 rows, 75 `DONE`, 40%. **Not in this PR:** the copy-back. Each repo's `ROADMAP.md` must be rewritten from the merged canonical, with the ADR links rewritten for the repo-root location, so all five copies are byte-identical again — that invariant is what makes `--ancestor` meaningful next time, and it needs four repo PRs that can only be cut after this lands. |
Expand Down
174 changes: 174 additions & 0 deletions app/attester/AttesterChecker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"use client";

import { AssetLabel } from "@/components/money/AssetLabel";
import { t } from "@/lib/i18n/messages";
import { useAttesterChecker } from "./useAttesterChecker";

export interface AttesterCheckerProps {
usdcIssuer: string;
network?: string;
horizonUrl?: string;
}

export function AttesterChecker({ usdcIssuer, network, horizonUrl }: AttesterCheckerProps) {
const { address, handleAddressChange, validationError, isLoading, error, report, check } =
useAttesterChecker({
usdcIssuer,
network,
horizonUrl,
});

return (
<div className="space-y-6">
<form
onSubmit={(e) => {
e.preventDefault();
check();
}}
className="space-y-4"
>
<div>
<label htmlFor="payout-address-input" className="block text-sm font-medium text-ink">
{t("pages.attester.addressLabel")}
</label>
<div className="mt-1">
<input
id="payout-address-input"
type="text"
value={address}
onChange={(e) => handleAddressChange(e.target.value)}
placeholder={t("pages.attester.addressPlaceholder")}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="block w-full rounded-md border border-neutral-300 px-3 py-2 text-ink shadow-xs focus:border-accent focus:ring-2 focus:ring-accent focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<p className="mt-1 text-xs text-muted">{t("pages.attester.addressHelp")}</p>
{validationError && (
<p role="alert" className="mt-1 text-sm text-red-600 dark:text-red-400 font-medium">
{validationError}
</p>
)}
</div>

<div>
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center rounded-md bg-accent px-4 py-2 text-sm font-medium text-white shadow-xs hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:opacity-50"
>
{isLoading ? t("pages.attester.checking") : t("pages.attester.checkButton")}
</button>
</div>
</form>

{error && (
<div
role="alert"
className="rounded-md border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/50"
>
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}

{report && (
<div
role="status"
aria-live="polite"
className="space-y-4 pt-4 border-t border-neutral-200 dark:border-neutral-800"
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink">{t("pages.attester.summaryHeading")}</h2>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
report.problemsCount === 0
? "bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300"
: "bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300"
}`}
>
{report.problemsCount === 0
? t("pages.attester.summaryAllPass")
: `${report.problemsCount} ${t("pages.attester.summaryProblems")}`}
</span>
</div>

<div className="grid gap-4">
{/* 1. Account existence */}
<div className="rounded-md border border-neutral-200 p-4 dark:border-neutral-800">
<div className="flex items-center justify-between">
<span className="font-medium text-ink">{t("pages.attester.accountLabel")}</span>
<span
className={`text-sm font-medium ${report.account.status === "pass" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
>
{report.account.status === "pass"
? t("pages.attester.accountPass")
: t("pages.attester.accountFail")}
</span>
</div>
{report.account.status === "fail" && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{t("pages.attester.accountAction")}
</p>
)}
</div>

{/* 2. Native XLM float */}
<div className="rounded-md border border-neutral-200 p-4 dark:border-neutral-800">
<div className="flex items-center justify-between">
<span className="font-medium text-ink">{t("pages.attester.xlmLabel")}</span>
<span
className={`text-sm font-medium ${report.xlm?.status === "pass" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
>
{report.xlm?.status === "pass"
? t("pages.attester.xlmPass")
: t("pages.attester.xlmFail")}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-xs text-muted">
<span>{report.xlm ? `${report.xlm.balance} XLM` : "0 XLM"}</span>
<span>{t("pages.attester.xlmReserveExplanation")}</span>
</div>
{report.xlm?.status === "fail" && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{t("pages.attester.xlmAction")}
</p>
)}
</div>

{/* 3. USDC trustline */}
<div className="rounded-md border border-neutral-200 p-4 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-ink">{t("pages.attester.trustlineLabel")}</span>
<AssetLabel code="USDC" issuer={usdcIssuer} />
</div>
<span
className={`text-sm font-medium ${report.trustline.status === "pass" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
>
{report.trustline.kind === "authorised" && t("pages.attester.trustlinePass")}
{report.trustline.kind === "absent" && t("pages.attester.trustlineAbsent")}
{report.trustline.kind === "unauthorised" &&
t("pages.attester.trustlineUnauthorised")}
{report.trustline.kind === "no_room" && t("pages.attester.trustlineNoRoom")}
</span>
</div>
{report.trustline.status === "fail" && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{report.trustline.kind === "absent" && t("pages.attester.trustlineAbsentAction")}
{report.trustline.kind === "unauthorised" &&
t("pages.attester.trustlineUnauthorisedAction")}
{report.trustline.kind === "no_room" && t("pages.attester.trustlineNoRoomAction")}
</p>
)}
</div>
</div>
</div>
)}

<div className="rounded-md bg-neutral-50 p-4 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800">
<p className="text-xs text-muted leading-relaxed">{t("pages.attester.readOnlyNotice")}</p>
</div>
</div>
);
}
31 changes: 29 additions & 2 deletions app/attester/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
/** Attester read-only account checks. Roadmap M3-12. */
import { PageStub } from "@/app/_components/PageStub";
import { t } from "@/lib/i18n/messages";
import { publicConfig } from "@/lib/sdk";
import { AttesterChecker } from "./AttesterChecker";

export const dynamic = "force-dynamic";

export const metadata = {
title: "Attester Checks — Kinlock",
};

export default function Page() {
return <PageStub titleKey="pages.attester.title" />;
let usdcIssuer = "";
let network = "testnet";
try {
const cfg = publicConfig();
usdcIssuer = cfg.NEXT_PUBLIC_USDC_ISSUER;
network = cfg.NEXT_PUBLIC_STELLAR_NETWORK;
} catch {
// Graceful fallback during static page analysis if env vars are unpopulated
}

return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="mb-6 space-y-2">
<h1 className="text-2xl font-bold tracking-tight text-ink">{t("pages.attester.title")}</h1>
<p className="text-sm text-muted">{t("pages.attester.subtitle")}</p>
</div>

<AttesterChecker usdcIssuer={usdcIssuer} network={network} />
</div>
);
}
78 changes: 78 additions & 0 deletions app/attester/useAttesterChecker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import { useState } from "react";
import {
type AttesterReadinessReport,
checkPayoutAccountReadiness,
InvalidAddressError,
} from "@/lib/attester/readiness";
import { isValidEd25519PublicKey } from "@/lib/attester/strkey";
import { t } from "@/lib/i18n/messages";

export interface UseAttesterCheckerOptions {
usdcIssuer: string;
network?: string;
horizonUrl?: string;
}

export function useAttesterChecker({
usdcIssuer,
network = "testnet",
horizonUrl,
}: UseAttesterCheckerOptions) {
const [address, setAddress] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [report, setReport] = useState<AttesterReadinessReport | null>(null);

async function check() {
const trimmed = address.trim();

if (!isValidEd25519PublicKey(trimmed)) {
setValidationError(t("pages.attester.errorInvalidAddress"));
setReport(null);
return;
}

setValidationError(null);
setError(null);
setIsLoading(true);

try {
const res = await checkPayoutAccountReadiness(trimmed, {
network,
assetCode: "USDC",
assetIssuer: usdcIssuer,
horizonUrl,
});
setReport(res);
} catch (err) {
if (err instanceof InvalidAddressError) {
setValidationError(t("pages.attester.errorInvalidAddress"));
} else {
setError(err instanceof Error ? err.message : String(err));
}
setReport(null);
} finally {
setIsLoading(false);
}
}

function handleAddressChange(val: string) {
setAddress(val);
if (validationError) {
setValidationError(null);
}
}

return {
address,
handleAddressChange,
validationError,
isLoading,
error,
report,
check,
};
}
Loading
Loading