diff --git a/ROADMAP.md b/ROADMAP.md index 53429b8..eae3b34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | @@ -131,7 +131,7 @@ 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% | @@ -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 | 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%** | --- @@ -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 | @@ -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 `` 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 | diff --git a/app/payee/page.tsx b/app/payee/page.tsx index e7d1e7a..6f8041b 100644 --- a/app/payee/page.tsx +++ b/app/payee/page.tsx @@ -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; } @@ -22,7 +24,10 @@ export default async function Page() { {t("pages.payee.indexerDown")}

) : ( - + <> + + + )} ); diff --git a/app/request/page.tsx b/app/request/page.tsx index c7f6899..986c146 100644 --- a/app/request/page.tsx +++ b/app/request/page.tsx @@ -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; } @@ -17,6 +19,7 @@ export default async function Page() {

{t("pages.request.title")}

{t("pages.request.intro")}

{t("pages.request.noAuthority")}

+ {payees !== null ? : null} ); diff --git a/app/send/page.tsx b/app/send/page.tsx index eb66b84..d853a58 100644 --- a/app/send/page.tsx +++ b/app/send/page.tsx @@ -1,7 +1,8 @@ /** 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"; @@ -9,8 +10,9 @@ 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; } @@ -18,6 +20,7 @@ export default async function Page() {

{t("pages.send.title")}

{t("pages.send.intro")}

+ {payees !== null ? : null}
diff --git a/components/indexer/LagNotice.tsx b/components/indexer/LagNotice.tsx new file mode 100644 index 0000000..5175b9f --- /dev/null +++ b/components/indexer/LagNotice.tsx @@ -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 ( +

+ {t("common.indexerLagNotice")} +

+ ); +} diff --git a/lib/indexer/index.test.ts b/lib/indexer/index.test.ts new file mode 100644 index 0000000..5a6e481 --- /dev/null +++ b/lib/indexer/index.test.ts @@ -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); + }); +}); diff --git a/lib/indexer/index.ts b/lib/indexer/index.ts index acaa43f..dadd8b4 100644 --- a/lib/indexer/index.ts +++ b/lib/indexer/index.ts @@ -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 { + 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; + } +} diff --git a/messages/en.json b/messages/en.json index ed53df0..0e11ca9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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",