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}