Skip to content
Merged
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: 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 (M3-11: `/payee` dashboard) |
| **Last updated** | 2026-10-08 (M3-21: error/empty states, indexer-lag notice) |
| **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 | 0 | 1 | 16 | 0 | 0 | 0 | 0% |
| 2 Contract + registry | 36 | 16 | 5 | 15 | 0 | 0 | 0 | 44% |
| 3 SDK + indexer | 19 | 1 | 1 | 17 | 0 | 0 | 0 | 5% |
| 4 App | 26 | 12 | 6 | 8 | 0 | 0 | 0 | 46% |
| 4 App | 26 | 13 | 6 | 7 | 0 | 0 | 0 | 50% |
| 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 | 8 | 1 | 0 | 7 | 0 | 0 | 0 | 13% |
| 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a |
| **All** | **201** | **42** | **15** | **119** | **0** | **25** | **0** | **24%** |
| **All** | **201** | **43** | **15** | **118** | **0** | **25** | **0** | **24%** |

---

Expand Down Expand Up @@ -310,7 +310,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| M3-18 | Mobile and low-bandwidth performance pass | app | P1 | TODO | M3-13 | Dashboard loads in under 2s on a mid-range phone profile |
| M3-19 | Playwright happy-path: request → send → claim → verify | app | P0 | TODO | M3-13 | Green in CI against testnet |
| M3-20 | Copy and wording review (plain language, no "proof of use", honest disclaimers) | app | P0 | IN PROGRESS | M3-13 | Reviewer sign-off |
| M3-21 | Error and empty states: trustline failure, expired, revoked, suspended, indexer lag | app | P0 | TODO | M3-09 | Each state has clear guidance |
| M3-21 | Error and empty states: trustline failure, expired, revoked, suspended, indexer lag | app | P0 | DONE | M3-09 | Each state has clear guidance |
| M3-22 | Testnet hosting and environment config | app | P0 | IN PROGRESS | M3-19 | Public testnet URL live |
| M3-23 | Externalize all UI strings (`messages/en.json`) and add `Intl`-based locale-aware formatting; handle zero- and three-decimal currencies, non-Latin text, and RTL-safe layout | app | P0 | DONE | M3-01 | Lint or test fails on inline strings; formatting tests across at least 3 locales and currencies |
| M3-24 | CI guard: fail on hard-coded country, currency, anchor, or locale literals outside registry data, tests, and fixtures | all | P1 | TODO | M3-01, M2-01 | Guard runs in app and sdk CI; passing and failing examples tested |
Expand Down Expand Up @@ -525,6 +525,7 @@ Newest first. One entry per PR. Required for every contribution (see §2).

| Date | PR / ref | Repo | Rows touched | Summary |
|---|---|---|---|---|
| 2026-10-08 | `feat/m3-21-indexer-lag-notice` | app | DONE: M3-21 | Audited each of the row's five named states against the actual message keys and error-mapping code (not assumed): **trustline failure** (claim/release side: `claim-logic.ts`'s `CONTRACT_ERRORS` plus `errorTx`'s "can't receive this asset yet" phrasing; send side: `preflight.payout_trustline_authorized.*`, already specific per failure mode), **expired** (`claim.errorExpired`, `lock.refundAvailable.Expired`), **revoked** and **suspended** (`claim.errorPayeeNotActive` via the contract's `PayeeNotActive` error; `preflight.payee_active.revoked`/`.suspended`; `lock.refundAvailable.Revoked`/`.SuspendedTimeout`) were all already clear and, per the SDK's own preflight doc comment, chain-backed where it matters (`payee_active` and `payout_trustline_authorized` are blocking chain checks, never indexer-sourced, so none of this is at risk from indexer lag). **Indexer lag** was the one genuinely missing piece: added `indexerLagging()` to `lib/indexer/index.ts` (reads the indexer's own `/health` status, defaulting to "possibly stale" on any failure so a broken health check can't silently hide a real lag) and a shared `<LagNotice>` component, shown on `/send`, `/request`, and `/payee` when the indexer reports anything other than `ok`. Purely informational, never blocking: every actual money decision already re-reads chain state regardless. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (108/108, 5 new in `lib/indexer/index.test.ts`), `pnpm build`; confirmed against the real indexer (currently healthy, `lagLedgers: 1`, correctly shows no notice) that the three pages still render and list payees correctly with the added parallel health fetch |
| 2026-10-08 | `feat/payee-dashboard` | app | DONE: M3-11 | Built `/payee`, replacing its `PageStub`. Connect a wallet; `matchingPayeeIds` (new, `payee-logic.ts`) finds every registered payee this wallet is the payout address for, by matching against the already-fetched indexer payee list (no new chain or indexer call needed for that step). For each match, lists its locks via a new `listLocks(payeeId, state?)` in `lib/indexer/index.ts`, filterable by status (server-side, re-queries the indexer per change) and by one specific payment: pasting a claim link runs the exact same `parseClaimLink` + `computeRefHash` check `/claim/[id]` already uses, entirely client-side (hard rule 5: the reference and salt never leave the browser; only `ref_hash` is ever compared). `listLocks` is callable from the browser too (same-origin, via `next.config.ts`'s existing `/locks` rewrite), unlike `listPayees` which is server-only, since the dashboard doesn't know which payeeId to ask for until a wallet connects client-side. Each row links to `/claim/[id]` for the payee's actual release/decline actions; nothing here moves money. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (103/103, 9 new in `payee-logic.test.ts`), `pnpm build` all green; confirmed the `/locks` rewrite returns real, already-created testnet locks for a known payeeId with the exact shape `listLocks` expects (`curl` against a local build with the real indexer URL); visually verified the pre-connect page renders cleanly with no console errors. Not independently verified: the post-wallet-connect render with real data, since driving a real wallet extension isn't automatable (same limitation already accepted for `/send`'s wallet step this session) |
| 2026-10-08 | `chore/retire-known-payees-fallback` | app | M3-26 (retired, status unchanged) | `kinlock-sdk`'s indexer (`M2-18`) is live on Railway (`indexer-production-705a.up.railway.app`, healthy, `lagLedgers: 0`), deployed by a teammate while `M3-26` was in flight. Set `NEXT_PUBLIC_INDEXER_URL` in Vercel (production and preview). Reverted `/send` and `/request` from `M3-26`'s interim `lib/payees/*` (chain-verified snapshot) back to the real `listPayees()`, and deleted that module: carrying two parallel payee sources going forward would be the dead-code / duplicate-abstraction AGENTS.md explicitly rules out, and the real indexer serves the identical three payees plus whatever gets registered next. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (94/94), `pnpm build`, then `pnpm start` locally against the real indexer URL confirmed `/send` and `/request` still list all three payees, this time genuinely through `M2-18`'s pipeline. `M2-18` itself is still `IN PROGRESS` in `kinlock-sdk`'s `ROADMAP.md` (daily backup schedule and a restore-vs-live comparison remain), not this repo's row to close |
| 2026-10-08 | `feat/known-payees-chain-read` | app | DONE: M3-26 (new row) | `/send` and `/request` showed "can't be loaded" for every payee, since no indexer is deployed (`M2-18`, still `TODO`). Investigated whether `kinlock-registry`'s real `payees/` data could substitute, but it's empty (`.gitkeep` only) and the indexer's own registry-join (`kinlock-sdk/services/indexer/src/registry/sync.ts`) reads a local filesystem checkout, not something `kinlock-app` should fetch live or re-implement wholesale. Built a scoped interim source instead: `lib/payees/known.ts` is a committed snapshot of `kinlock-registry/fixtures/payees/` (the three fictional testnet fixtures), and `lib/payees/registry.ts` joins each one with a live chain read (`getPayee`, hard rule 3: chain is truth) before showing anything, verifying the on-chain `meta_hash` still matches this snapshot's hash of the full record, i.e. exactly the binding the real indexer enforces (same `canonicalize`/`sha256` algorithm, deliberately duplicated here as kinlock-sdk's indexer already duplicates it from kinlock-registry). `status` and `payout` are always the live on-chain values, never the snapshot's. Wired into `/send` and `/request` in place of the broken `listPayees()` call. While investigating, found all three fixtures were in fact already registered and `Active` on the real testnet contract from earlier work this session (not something this PR did); verified against the deployed contract directly (`stellar contract invoke ... get_payee`). Verified end to end with Playwright against the real local build (no indexer configured, matching production): both pages list all three payees, `/request` generates a real prefilled `/send` link, `/send`'s full review flow (schedule, take-back date, USD total, preflight) completes up to "Connect your wallet first" (the correct, by-design stopping point; actually signing needs a real wallet extension, out of scope for an automated check). Also installed the `stellar` CLI (Homebrew) and generated a throwaway testnet identity (`kinlock-fixture-attester`) while investigating, in case registration had been needed; turned out not to be. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (101/101, 7 new), `pnpm build` all green. Superseded once `M2-18` (real indexer) ships |
Expand Down
11 changes: 8 additions & 3 deletions app/payee/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
/** Payee dashboard: every lock sent to the connected wallet. Roadmap M3-11. */

import { LagNotice } from "@/components/indexer/LagNotice";
import { t } from "@/lib/i18n/messages";
import { type IndexedPayee, listPayees } from "@/lib/indexer";
import { type IndexedPayee, indexerLagging, listPayees } from "@/lib/indexer";
import { PayeeView } from "./PayeeView";

export const dynamic = "force-dynamic";

export default async function Page() {
let payees: IndexedPayee[] | null = null;
let lagging = false;
try {
payees = await listPayees();
[payees, lagging] = await Promise.all([listPayees(), indexerLagging()]);
} catch {
payees = null;
}
Expand All @@ -22,7 +24,10 @@ export default async function Page() {
{t("pages.payee.indexerDown")}
</p>
) : (
<PayeeView payees={payees} />
<>
<LagNotice lagging={lagging} />
<PayeeView payees={payees} />
</>
)}
</main>
);
Expand Down
7 changes: 5 additions & 2 deletions app/request/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
/** Payee creates a payment-request link. Roadmap M3-05. */
import { LagNotice } from "@/components/indexer/LagNotice";
import { type MessageKey, t } from "@/lib/i18n/messages";
import { type IndexedPayee, listPayees, payable } from "@/lib/indexer";
import { type IndexedPayee, indexerLagging, listPayees, payable } from "@/lib/indexer";
import { RequestForm } from "./RequestForm";

export const dynamic = "force-dynamic";

export default async function Page() {
let payees: IndexedPayee[] | null = null;
let lagging = false;
try {
payees = payable(await listPayees());
[payees, lagging] = await Promise.all([listPayees().then(payable), indexerLagging()]);
} catch {
payees = null;
}
Expand All @@ -17,6 +19,7 @@ export default async function Page() {
<h1 className="text-2xl font-semibold">{t("pages.request.title")}</h1>
<p className="mt-2">{t("pages.request.intro")}</p>
<p className="mt-2 text-sm">{t("pages.request.noAuthority")}</p>
{payees !== null ? <LagNotice lagging={lagging} /> : null}
<Body payees={payees} />
</main>
);
Expand Down
7 changes: 5 additions & 2 deletions app/send/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
/** Sender flow: request-link prefill, preflight, USD (indicative local currency once DEC-10). Roadmap M3-06. */
import { SavedClaimLinks } from "@/components/claim-links/SavedClaimLinks";
import { LagNotice } from "@/components/indexer/LagNotice";
import { type MessageKey, t } from "@/lib/i18n/messages";
import { type IndexedPayee, listPayees, payable } from "@/lib/indexer";
import { type IndexedPayee, indexerLagging, listPayees, payable } from "@/lib/indexer";
import { publicConfig } from "@/lib/sdk";
import { SendForm } from "./SendForm";

export const dynamic = "force-dynamic";

export default async function Page() {
let payees: IndexedPayee[] | null = null;
let lagging = false;
try {
payees = payable(await listPayees());
[payees, lagging] = await Promise.all([listPayees().then(payable), indexerLagging()]);
} catch {
payees = null;
}
return (
<main className="mx-auto max-w-xl p-4">
<h1 className="text-2xl font-semibold">{t("pages.send.title")}</h1>
<p className="mt-2">{t("pages.send.intro")}</p>
{payees !== null ? <LagNotice lagging={lagging} /> : null}
<Body payees={payees} />
<SavedClaimLinks />
</main>
Expand Down
15 changes: 15 additions & 0 deletions components/indexer/LagNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { t } from "@/lib/i18n/messages";

/**
* Shown on indexer-backed list pages when the data might be a little stale. Never a reason to
* block anything: preflight's blocking checks and every claim/release/refund read chain state
* directly regardless (hard rule 3). Roadmap M3-21.
*/
export function LagNotice({ lagging }: { lagging: boolean }) {
if (!lagging) return null;
return (
<p role="status" className="mt-2 text-sm text-ink/60">
{t("common.indexerLagNotice")}
</p>
);
}
57 changes: 57 additions & 0 deletions lib/indexer/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";

const { publicConfig } = vi.hoisted(() => ({ publicConfig: vi.fn() }));
vi.mock("@/lib/sdk", () => ({ publicConfig }));
publicConfig.mockReturnValue({ NEXT_PUBLIC_INDEXER_URL: "https://indexer.example" });

const { indexerLagging } = await import("./index");

afterEach(() => {
vi.unstubAllGlobals();
});

describe("indexerLagging", () => {
it("is false when the indexer reports ok", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
status: "ok",
indexedLedger: 10,
latestLedger: 10,
lagLedgers: 0,
lastBatchAt: "2026-10-08T00:00:00.000Z",
}),
}),
);
expect(await indexerLagging()).toBe(false);
});

it("is true when the indexer reports lagging", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
status: "lagging",
indexedLedger: 5,
latestLedger: 20,
lagLedgers: 15,
lastBatchAt: "2026-10-08T00:00:00.000Z",
}),
}),
);
expect(await indexerLagging()).toBe(true);
});

it("is true when the health endpoint returns a non-ok HTTP status", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false }));
expect(await indexerLagging()).toBe(true);
});

it("is true when the health check itself fails (never hides a real lag)", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
expect(await indexerLagging()).toBe(true);
});
});
25 changes: 25 additions & 0 deletions lib/indexer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,28 @@ export async function listLocks(
} while (before);
return all;
}

const HealthResponse = z.object({
status: z.enum(["ok", "lagging", "unknown"]),
indexedLedger: z.number().nullable(),
latestLedger: z.number().nullable(),
lagLedgers: z.number().nullable(),
lastBatchAt: z.string().nullable(),
});

/**
* Whether indexer-backed lists (payee/lock displays, never money-moving decisions) might be
* stale. Never blocks anything: `preflight`'s blocking checks and every claim/release/refund read
* chain state directly (hard rule 3), so a lagging indexer only means a list looks a little old,
* never that an unsafe action is allowed through. Defaults to "yes, treat as possibly stale" on
* any failure, so a broken health check never silently hides a real lag.
*/
export async function indexerLagging(): Promise<boolean> {
try {
const response = await fetch(new URL("/health", publicConfig().NEXT_PUBLIC_INDEXER_URL));
if (!response.ok) return true;
return HealthResponse.parse(await response.json()).status !== "ok";
} catch {
return true;
}
}
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"notImplemented": "This page is not built yet.",
"indicative": "Indicative",
"usdOnlyNote": "No reliable exchange rate is available for this currency, so only the USD amount is shown.",
"rateRiskNote": "Exchange rates change. The local-currency amount is an estimate and is not guaranteed."
"rateRiskNote": "Exchange rates change. The local-currency amount is an estimate and is not guaranteed.",
"indexerLagNotice": "This list may be a few minutes out of date. Payments and verification always check the blockchain directly, so this never affects what you can actually send or claim."
},
"nav": {
"send": "Send",
Expand Down
Loading