From 5ff221d4f496531f3e1e7d4514873ad12968d31c Mon Sep 17 00:00:00 2001
From: jdjioe5-cpu <297354017+CodingAngel1@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:02:04 +0800
Subject: [PATCH] feat(app): /attester read-only payout-account checks
---
README.md | 4 +-
ROADMAP.md | 9 +-
app/attester/AttesterChecker.tsx | 174 +++++++++++++++++++++
app/attester/page.tsx | 31 +++-
app/attester/useAttesterChecker.ts | 78 ++++++++++
lib/attester/readiness.test.ts | 176 ++++++++++++++++++++++
lib/attester/readiness.ts | 234 +++++++++++++++++++++++++++++
lib/attester/strkey.ts | 58 +++++++
messages/en.json | 30 +++-
9 files changed, 785 insertions(+), 9 deletions(-)
create mode 100644 app/attester/AttesterChecker.tsx
create mode 100644 app/attester/useAttesterChecker.ts
create mode 100644 lib/attester/readiness.test.ts
create mode 100644 lib/attester/readiness.ts
create mode 100644 lib/attester/strkey.ts
diff --git a/README.md b/README.md
index ead4785..600978b 100644
--- a/README.md
+++ b/README.md
@@ -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`).
diff --git a/ROADMAP.md b/ROADMAP.md
index a4bc565..4ffda38 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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) |
@@ -131,7 +131,7 @@ 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% |
@@ -139,7 +139,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| 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%** |
---
@@ -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 |
@@ -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. |
diff --git a/app/attester/AttesterChecker.tsx b/app/attester/AttesterChecker.tsx
new file mode 100644
index 0000000..6404392
--- /dev/null
+++ b/app/attester/AttesterChecker.tsx
@@ -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 (
+
+
+
+ {error && (
+
+ )}
+
+ {report && (
+
+
+
{t("pages.attester.summaryHeading")}
+
+ {report.problemsCount === 0
+ ? t("pages.attester.summaryAllPass")
+ : `${report.problemsCount} ${t("pages.attester.summaryProblems")}`}
+
+
+
+
+ {/* 1. Account existence */}
+
+
+ {t("pages.attester.accountLabel")}
+
+ {report.account.status === "pass"
+ ? t("pages.attester.accountPass")
+ : t("pages.attester.accountFail")}
+
+
+ {report.account.status === "fail" && (
+
+ {t("pages.attester.accountAction")}
+
+ )}
+
+
+ {/* 2. Native XLM float */}
+
+
+ {t("pages.attester.xlmLabel")}
+
+ {report.xlm?.status === "pass"
+ ? t("pages.attester.xlmPass")
+ : t("pages.attester.xlmFail")}
+
+
+
+ {report.xlm ? `${report.xlm.balance} XLM` : "0 XLM"}
+ {t("pages.attester.xlmReserveExplanation")}
+
+ {report.xlm?.status === "fail" && (
+
+ {t("pages.attester.xlmAction")}
+
+ )}
+
+
+ {/* 3. USDC trustline */}
+
+
+
+
{t("pages.attester.trustlineLabel")}
+
+
+
+ {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")}
+
+
+ {report.trustline.status === "fail" && (
+
+ {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")}
+
+ )}
+
+
+
+ )}
+
+
+
{t("pages.attester.readOnlyNotice")}
+
+
+ );
+}
diff --git a/app/attester/page.tsx b/app/attester/page.tsx
index 48862e1..034c19e 100644
--- a/app/attester/page.tsx
+++ b/app/attester/page.tsx
@@ -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 ;
+ 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 (
+
+
+
{t("pages.attester.title")}
+
{t("pages.attester.subtitle")}
+
+
+
+
+ );
}
diff --git a/app/attester/useAttesterChecker.ts b/app/attester/useAttesterChecker.ts
new file mode 100644
index 0000000..9843eb9
--- /dev/null
+++ b/app/attester/useAttesterChecker.ts
@@ -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(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [report, setReport] = useState(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,
+ };
+}
diff --git a/lib/attester/readiness.test.ts b/lib/attester/readiness.test.ts
new file mode 100644
index 0000000..4af79a6
--- /dev/null
+++ b/lib/attester/readiness.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest";
+import {
+ checkPayoutAccountReadiness,
+ evaluateReadiness,
+ type HorizonBalance,
+ InvalidAddressError,
+} from "./readiness";
+import { isValidEd25519PublicKey } from "./strkey";
+
+const VALID_ADDRESS = "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM";
+const VALID_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
+const DEFAULT_OPTS = {
+ network: "testnet",
+ minXlm: 2,
+ assetCode: "USDC",
+ assetIssuer: VALID_ISSUER,
+};
+
+describe("StrKey Address Validation", () => {
+ it("accepts valid Stellar Ed25519 public addresses", () => {
+ expect(isValidEd25519PublicKey(VALID_ADDRESS)).toBe(true);
+ expect(isValidEd25519PublicKey(VALID_ISSUER)).toBe(true);
+ expect(
+ isValidEd25519PublicKey("GDPUY733T3UNZWG4EQMEBHBDGKD33YSFPOI2RB4AIK2JMOENKEXWK7ZG"),
+ ).toBe(true);
+ expect(
+ isValidEd25519PublicKey("GBXKOYQ4MJ6UJGQBFXVXOP7XL7SKKJTFTYJUSNREP5FW4IHXPMWY2WSX"),
+ ).toBe(true);
+ });
+
+ it("rejects invalid addresses with wrong prefix, short length, lowercase, or corrupted checksum", () => {
+ // Starts with S (secret key)
+ expect(
+ isValidEd25519PublicKey("SCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM"),
+ ).toBe(false);
+ // Short length (55 chars)
+ expect(isValidEd25519PublicKey("GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5Q")).toBe(
+ false,
+ );
+ // Lowercase
+ expect(
+ isValidEd25519PublicKey("gCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM"),
+ ).toBe(false);
+ // Corrupted checksum (last char changed from M to N)
+ expect(
+ isValidEd25519PublicKey("GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QN"),
+ ).toBe(false);
+ });
+});
+
+describe("Attester Readiness Evaluation (Pure Function & Parity)", () => {
+ it("reports account not found when balances is null (Horizon 404)", () => {
+ const report = evaluateReadiness(VALID_ADDRESS, null, DEFAULT_OPTS);
+ expect(report.account.status).toBe("fail");
+ expect(report.account.exists).toBe(false);
+ expect(report.xlm).toBeNull();
+ expect(report.trustline.status).toBe("fail");
+ expect(report.trustline.kind).toBe("absent");
+ expect(report.problemsCount).toBe(2); // account not found + trustline absent
+ });
+
+ it("evaluates healthy account with sufficient XLM and authorized USDC trustline with room", () => {
+ const balances: HorizonBalance[] = [
+ {
+ asset_type: "native",
+ balance: "100.5000000",
+ },
+ {
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: VALID_ISSUER,
+ balance: "50.0000000",
+ limit: "10000.0000000",
+ is_authorized: true,
+ },
+ ];
+
+ const report = evaluateReadiness(VALID_ADDRESS, balances, DEFAULT_OPTS);
+ expect(report.problemsCount).toBe(0);
+ expect(report.account.status).toBe("pass");
+ expect(report.account.exists).toBe(true);
+ expect(report.xlm?.status).toBe("pass");
+ expect(report.xlm?.balance).toBe(100.5);
+ expect(report.trustline.status).toBe("pass");
+ expect(report.trustline.kind).toBe("authorised");
+ });
+
+ it("catches insufficient XLM float (need >= 2 XLM)", () => {
+ const balances: HorizonBalance[] = [
+ {
+ asset_type: "native",
+ balance: "0.9999900",
+ },
+ {
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: VALID_ISSUER,
+ balance: "10.0000000",
+ limit: "1000.0000000",
+ is_authorized: true,
+ },
+ ];
+
+ const report = evaluateReadiness(VALID_ADDRESS, balances, DEFAULT_OPTS);
+ expect(report.problemsCount).toBe(1);
+ expect(report.xlm?.status).toBe("fail");
+ expect(report.xlm?.balance).toBe(0.99999);
+ expect(report.trustline.status).toBe("pass");
+ });
+
+ it("CRITICAL: distinctly identifies missing trustline vs unauthorised trustline vs full limit", () => {
+ // 1. Missing trustline (absent)
+ const balancesAbsent: HorizonBalance[] = [
+ {
+ asset_type: "native",
+ balance: "10.0000000",
+ },
+ ];
+ const reportAbsent = evaluateReadiness(VALID_ADDRESS, balancesAbsent, DEFAULT_OPTS);
+ expect(reportAbsent.trustline.status).toBe("fail");
+ expect(reportAbsent.trustline.kind).toBe("absent");
+ expect(reportAbsent.trustline.authorized).toBe(false);
+
+ // 2. Unauthorised trustline (present but is_authorized: false)
+ const balancesUnauth: HorizonBalance[] = [
+ {
+ asset_type: "native",
+ balance: "10.0000000",
+ },
+ {
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: VALID_ISSUER,
+ balance: "0.0000000",
+ limit: "1000.0000000",
+ is_authorized: false,
+ },
+ ];
+ const reportUnauth = evaluateReadiness(VALID_ADDRESS, balancesUnauth, DEFAULT_OPTS);
+ expect(reportUnauth.trustline.status).toBe("fail");
+ expect(reportUnauth.trustline.kind).toBe("unauthorised");
+ expect(reportUnauth.trustline.authorized).toBe(false);
+
+ // 3. No room (authorized but balance >= limit)
+ const balancesFull: HorizonBalance[] = [
+ {
+ asset_type: "native",
+ balance: "10.0000000",
+ },
+ {
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: VALID_ISSUER,
+ balance: "1000.0000000",
+ limit: "1000.0000000",
+ is_authorized: true,
+ },
+ ];
+ const reportFull = evaluateReadiness(VALID_ADDRESS, balancesFull, DEFAULT_OPTS);
+ expect(reportFull.trustline.status).toBe("fail");
+ expect(reportFull.trustline.kind).toBe("no_room");
+ expect(reportFull.trustline.authorized).toBe(true);
+
+ // Assert that the three verdicts are distinctly differentiated
+ expect(reportAbsent.trustline.kind).not.toEqual(reportUnauth.trustline.kind);
+ expect(reportUnauth.trustline.kind).not.toEqual(reportFull.trustline.kind);
+ });
+});
+
+describe("Client Fetch & StrKey Guard", () => {
+ it("rejects invalid StrKey address before making any fetch call", async () => {
+ await expect(checkPayoutAccountReadiness("invalid-address", DEFAULT_OPTS)).rejects.toThrowError(
+ InvalidAddressError,
+ );
+ });
+});
diff --git a/lib/attester/readiness.ts b/lib/attester/readiness.ts
new file mode 100644
index 0000000..450f5a9
--- /dev/null
+++ b/lib/attester/readiness.ts
@@ -0,0 +1,234 @@
+/**
+ * Read-only payout account checks for attesters.
+ * Roadmap M3-12 (depends on M1-26, check-attester-readiness.ts parity).
+ *
+ * Implements read-only account existence, native XLM float vs reserve threshold,
+ * and USDC trustline verification (distinguishing missing vs unauthorised vs no room).
+ * Purely read-only: no signing, no secret credentials, no transaction submission.
+ */
+
+import { isValidEd25519PublicKey } from "./strkey";
+
+export const DEFAULT_MIN_XLM = 2;
+export const DEFAULT_HORIZON_URL = "https://horizon-testnet.stellar.org";
+
+export interface HorizonBalance {
+ asset_type: string;
+ balance: string;
+ limit?: string;
+ asset_code?: string;
+ asset_issuer?: string;
+ is_authorized?: boolean;
+ is_authorized_to_maintain_liabilities?: boolean;
+}
+
+export interface HorizonAccountResponse {
+ id: string;
+ account_id: string;
+ sequence?: string;
+ balances: HorizonBalance[];
+}
+
+export type CheckStatus = "pass" | "fail";
+
+export interface AccountVerdict {
+ status: CheckStatus;
+ exists: boolean;
+ network: string;
+}
+
+export interface XlmVerdict {
+ status: CheckStatus;
+ balance: number;
+ minRequired: number;
+}
+
+export type TrustlineVerdictKind = "authorised" | "unauthorised" | "absent" | "no_room";
+
+export interface TrustlineVerdict {
+ status: CheckStatus;
+ kind: TrustlineVerdictKind;
+ assetCode: string;
+ assetIssuer: string;
+ balance: string;
+ limit: string;
+ authorized: boolean;
+}
+
+export interface AttesterReadinessReport {
+ address: string;
+ network: string;
+ problemsCount: number;
+ account: AccountVerdict;
+ xlm: XlmVerdict | null;
+ trustline: TrustlineVerdict;
+}
+
+export interface ReadinessOptions {
+ network?: string;
+ minXlm?: number;
+ assetCode: string;
+ assetIssuer: string;
+}
+
+/**
+ * Pure evaluation function: takes balances array (or null if account missing)
+ * and returns the structured readiness report matching check-attester-readiness.ts verdicts.
+ */
+export function evaluateReadiness(
+ address: string,
+ balances: HorizonBalance[] | null,
+ opts: ReadinessOptions,
+): AttesterReadinessReport {
+ const network = opts.network ?? "testnet";
+ const minXlm = opts.minXlm ?? DEFAULT_MIN_XLM;
+ let problemsCount = 0;
+
+ // 1. Account existence check
+ if (!balances) {
+ problemsCount += 2; // Account missing AND trustline missing (matches check-attester-readiness problem count of 2)
+ return {
+ address,
+ network,
+ problemsCount,
+ account: {
+ status: "fail",
+ exists: false,
+ network,
+ },
+ xlm: null,
+ trustline: {
+ status: "fail",
+ kind: "absent",
+ assetCode: opts.assetCode,
+ assetIssuer: opts.assetIssuer,
+ balance: "0",
+ limit: "0",
+ authorized: false,
+ },
+ };
+ }
+
+ const account: AccountVerdict = {
+ status: "pass",
+ exists: true,
+ network,
+ };
+
+ // 2. Native XLM float check
+ const nativeEntry = balances.find((b) => b.asset_type === "native");
+ const xlmBalance = nativeEntry ? Number.parseFloat(nativeEntry.balance) : 0;
+ const xlmPass = xlmBalance >= minXlm;
+ if (!xlmPass) {
+ problemsCount += 1;
+ }
+ const xlm: XlmVerdict = {
+ status: xlmPass ? "pass" : "fail",
+ balance: xlmBalance,
+ minRequired: minXlm,
+ };
+
+ // 3. Asset trustline check (USDC)
+ const trustEntry = balances.find(
+ (b) => b.asset_code === opts.assetCode && b.asset_issuer === opts.assetIssuer,
+ );
+
+ let trustline: TrustlineVerdict;
+ if (!trustEntry) {
+ problemsCount += 1;
+ trustline = {
+ status: "fail",
+ kind: "absent",
+ assetCode: opts.assetCode,
+ assetIssuer: opts.assetIssuer,
+ balance: "0",
+ limit: "0",
+ authorized: false,
+ };
+ } else {
+ const isAuthorized = trustEntry.is_authorized !== false;
+ const balanceNum = Number.parseFloat(trustEntry.balance);
+ const limitNum = trustEntry.limit ? Number.parseFloat(trustEntry.limit) : 0;
+ const hasRoom = limitNum > balanceNum;
+
+ if (!isAuthorized) {
+ problemsCount += 1;
+ trustline = {
+ status: "fail",
+ kind: "unauthorised",
+ assetCode: opts.assetCode,
+ assetIssuer: opts.assetIssuer,
+ balance: trustEntry.balance,
+ limit: trustEntry.limit ?? "0",
+ authorized: false,
+ };
+ } else if (!hasRoom) {
+ problemsCount += 1;
+ trustline = {
+ status: "fail",
+ kind: "no_room",
+ assetCode: opts.assetCode,
+ assetIssuer: opts.assetIssuer,
+ balance: trustEntry.balance,
+ limit: trustEntry.limit ?? "0",
+ authorized: true,
+ };
+ } else {
+ trustline = {
+ status: "pass",
+ kind: "authorised",
+ assetCode: opts.assetCode,
+ assetIssuer: opts.assetIssuer,
+ balance: trustEntry.balance,
+ limit: trustEntry.limit ?? "0",
+ authorized: true,
+ };
+ }
+ }
+
+ return {
+ address,
+ network,
+ problemsCount,
+ account,
+ xlm,
+ trustline,
+ };
+}
+
+export class InvalidAddressError extends Error {
+ constructor(message = "Invalid Stellar Ed25519 public address") {
+ super(message);
+ this.name = "InvalidAddressError";
+ }
+}
+
+/**
+ * Live / client-side check function: queries public Horizon REST endpoint.
+ * Validates address with StrKey before initiating any network access.
+ */
+export async function checkPayoutAccountReadiness(
+ address: string,
+ opts: ReadinessOptions & { horizonUrl?: string },
+): Promise {
+ const trimmed = address.trim();
+ if (!isValidEd25519PublicKey(trimmed)) {
+ throw new InvalidAddressError();
+ }
+
+ const horizonUrl = (opts.horizonUrl ?? DEFAULT_HORIZON_URL).replace(/\/+$/, "");
+ const res = await fetch(`${horizonUrl}/accounts/${trimmed}`, {
+ headers: { Accept: "application/json" },
+ });
+
+ if (res.status === 404) {
+ return evaluateReadiness(trimmed, null, opts);
+ }
+
+ if (!res.ok) {
+ throw new Error(`Horizon error (${res.status}): ${await res.text()}`);
+ }
+
+ const data = (await res.json()) as HorizonAccountResponse;
+ return evaluateReadiness(trimmed, data.balances ?? [], opts);
+}
diff --git a/lib/attester/strkey.ts b/lib/attester/strkey.ts
new file mode 100644
index 0000000..6400d75
--- /dev/null
+++ b/lib/attester/strkey.ts
@@ -0,0 +1,58 @@
+/**
+ * Lightweight StrKey validator for Stellar Ed25519 public keys (G-addresses).
+ * Implements RFC 4648 Base32 decoding and CRC-16 XModem checksum verification.
+ * Zero external dependencies.
+ */
+
+const RFC4648_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+const ED25519_PUBLIC_KEY_VERSION_BYTE = 6 << 3; // 48 (0x30) -> encoded 'G'
+
+export function isValidEd25519PublicKey(str: string): boolean {
+ if (typeof str !== "string" || str.length !== 56 || str[0] !== "G") {
+ return false;
+ }
+
+ const decoded = new Uint8Array(35);
+ let buffer = 0;
+ let bits = 0;
+ let count = 0;
+
+ for (let i = 0; i < str.length; i++) {
+ const val = RFC4648_ALPHABET.indexOf(str[i] as string);
+ if (val === -1) {
+ return false;
+ }
+ buffer = (buffer << 5) | val;
+ bits += 5;
+ if (bits >= 8) {
+ bits -= 8;
+ decoded[count++] = (buffer >> bits) & 0xff;
+ }
+ }
+
+ if (count !== 35) {
+ return false;
+ }
+
+ if (decoded[0] !== ED25519_PUBLIC_KEY_VERSION_BYTE) {
+ return false;
+ }
+
+ // Calculate CRC-16 XModem over the version byte and 32 payload bytes
+ let crc = 0x0000;
+ for (let i = 0; i < 33; i++) {
+ const byte = decoded[i] as number;
+ let code = (crc >>> 8) & 0xff;
+ code ^= byte & 0xff;
+ code ^= code >>> 4;
+ crc = (crc << 8) & 0xffff;
+ crc ^= code;
+ code = (code << 5) & 0xffff;
+ crc ^= code;
+ code = (code << 7) & 0xffff;
+ crc ^= code;
+ }
+
+ const expectedCrc = (decoded[33] as number) | ((decoded[34] as number) << 8);
+ return crc === expectedCrc;
+}
diff --git a/messages/en.json b/messages/en.json
index 0e11ca9..0e87cf8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -265,7 +265,35 @@
"viewLink": "View and claim"
},
"attester": {
- "title": "Attester checks"
+ "title": "Attester checks",
+ "subtitle": "Read-only verification of institutional payee payout accounts before onboarding or publishing registry records.",
+ "readOnlyNotice": "Strictly read-only: checks public ledger entries on the Stellar network. Never asks for private keys, seed phrases, or transaction signatures.",
+ "addressLabel": "Payout account address",
+ "addressPlaceholder": "G...",
+ "addressHelp": "Stellar public address (G...) to check.",
+ "checkButton": "Check account readiness",
+ "checking": "Checking account on Stellar network…",
+ "errorInvalidAddress": "Enter a valid 56-character Stellar public address starting with G.",
+ "summaryHeading": "Readiness checklist",
+ "summaryAllPass": "Account and trustline checks pass. Next: complete a real test receive and test release, then check the remaining boxes in the attester checklist by hand.",
+ "summaryProblems": "problem(s) found. Fix them before using this payout address.",
+ "accountLabel": "1. Account existence",
+ "accountPass": "Account exists on network",
+ "accountFail": "Account does not exist on network",
+ "accountAction": "Fund the account with native XLM (at least 2 XLM) to create it on the Stellar network.",
+ "xlmLabel": "2. Native XLM float",
+ "xlmPass": "Native XLM float meets reserve requirement",
+ "xlmFail": "Native XLM float is below reserve requirement (need >= 2 XLM)",
+ "xlmAction": "Deposit additional native XLM to meet base account reserves and pay transaction fees.",
+ "xlmReserveExplanation": "Reserve note: Accounts require a base reserve per ledger entry plus transaction fee headroom.",
+ "trustlineLabel": "3. USDC trustline",
+ "trustlinePass": "USDC trustline authorized with room to receive payments",
+ "trustlineAbsent": "No USDC trustline found on this account",
+ "trustlineAbsentAction": "The payout account must add a trustline for USDC via a change_trust operation.",
+ "trustlineUnauthorised": "USDC trustline is present but NOT authorized by the issuer",
+ "trustlineUnauthorisedAction": "Contact the asset issuer to authorize this account's trustline flags.",
+ "trustlineNoRoom": "USDC trustline is full (balance has reached the trustline limit)",
+ "trustlineNoRoomAction": "Increase the trustline limit via change_trust to receive new incoming payouts."
},
"receipt": {
"title": "Receipt",