diff --git a/ROADMAP.md b/ROADMAP.md index 6bbf111..85e9cb4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-10-08 (M3-26: interim chain-verified payee source, no indexer yet) | +| **Last updated** | 2026-10-08 (retire M3-26's interim payee source; real indexer wired up) | | **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) | @@ -315,7 +315,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | 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 | | M3-25 | Favicon/app icon, persistent site header (nav, docs and GitHub links), mobile hamburger menu, reduced-motion-safe entrance motion | app | P2 | DONE | M3-01, M3-04 | Favicon set in every browser; nav usable one-handed on a narrow viewport; `prefers-reduced-motion` honored | -| M3-26 | Interim chain-verified payee source for `/send` and `/request`, since no indexer is deployed (`M2-18`) | app | P1 | DONE | M3-05, M3-06 | `/send` and `/request` list real, on-chain `Active` payees and complete their flow up to wallet connect; superseded once `M2-18` ships | +| M3-26 | Interim chain-verified payee source for `/send` and `/request`, since no indexer is deployed (`M2-18`) | app | P1 | DONE | M3-05, M3-06 | `/send` and `/request` list real, on-chain `Active` payees and complete their flow up to wallet connect; **retired 2026-10-08**, superseded now that `M2-18`'s indexer is live (code removed, pages point at the real indexer again) | --- @@ -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 | `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 | | 2026-10-08 | `fix/indexer-url-optional` | app | no row changes | Fix: owner reported `/send`, `/verify`, `/request` "not functioning" after the previous hotfix. Found the real root cause, one level deeper than that hotfix: `lib/config.ts`'s `PublicConfigSchema` required `NEXT_PUBLIC_INDEXER_URL`, but `@kinlock/sdk`'s own `KinlockConfig.indexerUrl` is typed `?: string` (optional) precisely so chain-only operations keep working without an indexer. Since `NEXT_PUBLIC_INDEXER_URL` genuinely isn't set (no indexer deployed, `M2-18`), *every* `publicConfig()`/`kinlockConfig()` call was throwing before any RPC request was even attempted, breaking `verifyReceipt`, and by the same path `createLock`/`release`/`refund`/`decline`, anywhere they're called without an indexer-fetch guard already in front of them (`/verify`'s "Check" button made zero network requests; confirmed with Playwright). `next.config.ts`'s rewrites already treated a missing indexer URL as expected (`if (!indexer) return {...empty...}`), so this was a `kinlock-app`-only validation bug, not a real product gap. Changed `NEXT_PUBLIC_INDEXER_URL` to `z.url().optional()` to match the SDK's own contract; added a regression test. Reproduced against the exact production condition before and after (dev server with the real indexer-less `.env.local`): before, `verifyReceipt` on `/verify` made 0 RPC requests and always hit the generic error; after, it makes a real `soroban-testnet.stellar.org` request and correctly resolves "Not valid / not found" for a made-up hash. `/send` and `/request` still correctly show "can't be loaded" for the payee list, since there's genuinely no indexer yet; that part is accurate, not a bug. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (94/94, new test added), `pnpm build`, plus the live repro above, before redeploying | | 2026-10-08 | `fix/verify-publicconfig-crash` | app | M3-13 (no status change, still `DONE`) | Hotfix: `/verify` and `/r/[txHash]/[eventIndex]` were returning `500` in production (reported by the owner after the previous deploy). Root cause: `publicConfig()` validates the entire shared config, including `NEXT_PUBLIC_INDEXER_URL`, which isn't set in this Vercel project (no indexer deployed yet, `M2-18`); `/send`/`/request`/`/payee` happen to avoid calling it because they only reach it after a successful indexer fetch, but `useReceiptPage.ts`/`useVerifyForm.ts` called it unconditionally just to label the asset. Fixed by wrapping that call in a `try/catch`, degrading to no asset label (the existing fallback for a missing `lock`), same as `ReceiptResult.tsx` already does. Reproduced the exact failure locally first (dev server with `.env.local`'s real, indexer-less config, hitting `/verify` and `/r/...` directly: both 500 before the fix), then confirmed both return 200 after it, before redeploying. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (93/93), `pnpm build`, plus the live repro above | diff --git a/app/request/page.tsx b/app/request/page.tsx index ab9523e..c7f6899 100644 --- a/app/request/page.tsx +++ b/app/request/page.tsx @@ -1,7 +1,6 @@ /** Payee creates a payment-request link. Roadmap M3-05. */ import { type MessageKey, t } from "@/lib/i18n/messages"; -import { type IndexedPayee, payable } from "@/lib/indexer"; -import { listKnownPayees } from "@/lib/payees/registry"; +import { type IndexedPayee, listPayees, payable } from "@/lib/indexer"; import { RequestForm } from "./RequestForm"; export const dynamic = "force-dynamic"; @@ -9,7 +8,7 @@ export const dynamic = "force-dynamic"; export default async function Page() { let payees: IndexedPayee[] | null = null; try { - payees = payable(await listKnownPayees()); + payees = payable(await listPayees()); } catch { payees = null; } diff --git a/app/send/page.tsx b/app/send/page.tsx index 0c06a97..eb66b84 100644 --- a/app/send/page.tsx +++ b/app/send/page.tsx @@ -1,8 +1,7 @@ /** Sender flow: request-link prefill, preflight, USD (indicative local currency once DEC-10). Roadmap M3-06. */ import { SavedClaimLinks } from "@/components/claim-links/SavedClaimLinks"; import { type MessageKey, t } from "@/lib/i18n/messages"; -import { type IndexedPayee, payable } from "@/lib/indexer"; -import { listKnownPayees } from "@/lib/payees/registry"; +import { type IndexedPayee, listPayees, payable } from "@/lib/indexer"; import { publicConfig } from "@/lib/sdk"; import { SendForm } from "./SendForm"; @@ -11,7 +10,7 @@ export const dynamic = "force-dynamic"; export default async function Page() { let payees: IndexedPayee[] | null = null; try { - payees = payable(await listKnownPayees()); + payees = payable(await listPayees()); } catch { payees = null; } diff --git a/lib/payees/known.ts b/lib/payees/known.ts deleted file mode 100644 index 3d88365..0000000 --- a/lib/payees/known.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * A committed snapshot of kinlock-registry's payees, used only until an indexer is deployed - * (`M2-18`, still `TODO`). Never trusted directly: `registry.ts` verifies each entry's `meta_hash` - * against what's actually on chain before showing any of its fields (same binding the real - * indexer's registry sync enforces in `kinlock-sdk`'s `services/indexer/src/registry/sync.ts`). - * - * Synced by hand from `kinlock-registry/fixtures/payees/` on 2026-10-06 (fictional test - * institutions for testnet only, per that repo's `fixtures/README.md`). Replace this file's - * contents, or the whole module, once `M2-18` ships. - */ -export interface KnownPayee { - slug: string; - display_name: string; - category: "School" | "Rent"; - country: string; - local_currency: string; - city: string; - payout_address: string; - attester: string; - verified_at: string; -} - -export const KNOWN_PAYEES: readonly KnownPayee[] = [ - { - slug: "ng-kinlock-test-academy", - display_name: "Kinlock Test Academy (fixture)", - category: "School", - country: "NG", - local_currency: "NGN", - city: "Ibadan", - payout_address: "GDPUY733T3UNZWG4EQMEBHBDGKD33YSFPOI2RB4AIK2JMOENKEXWK7ZG", - attester: "kinlock-testnet-attester-1", - verified_at: "2026-10-06", - }, - { - slug: "ke-kinlock-test-school", - display_name: "Kinlock Test School (fixture)", - category: "School", - country: "KE", - local_currency: "KES", - city: "Nairobi", - payout_address: "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM", - attester: "kinlock-testnet-attester-1", - verified_at: "2026-10-06", - }, - { - slug: "ph-kinlock-test-rentals", - display_name: "Kinlock Test Rentals (fixture)", - category: "Rent", - country: "PH", - local_currency: "PHP", - city: "Quezon City", - payout_address: "GBXKOYQ4MJ6UJGQBFXVXOP7XL7SKKJTFTYJUSNREP5FW4IHXPMWY2WSX", - attester: "kinlock-testnet-attester-1", - verified_at: "2026-10-06", - }, -]; diff --git a/lib/payees/registry.test.ts b/lib/payees/registry.test.ts deleted file mode 100644 index 4e275e2..0000000 --- a/lib/payees/registry.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Payee } from "@kinlock/sdk"; -import { describe, expect, it, vi } from "vitest"; -import { KNOWN_PAYEES } from "./known"; - -const { getPayee } = vi.hoisted(() => ({ getPayee: vi.fn() })); -vi.mock("@/lib/sdk", () => ({ getPayee })); - -const { listKnownPayees, payeeIdOf } = await import("./registry"); - -const chainPayee = (over: Partial = {}): Payee => ({ - payout: "GDPUY733T3UNZWG4EQMEBHBDGKD33YSFPOI2RB4AIK2JMOENKEXWK7ZG", - category: "School", - status: "Active", - statusChangedAt: 0n, - attester: "GC23HSHHWSCKFAGMJF5JWXJRHAJCBZGZZMEN7TD5YQBHYBQ3ITNCO6FX", - metaHash: "b91bbf10a4b5ac34c0ff345817423cbf8ade78ecf9bf27fe50f2cc91ebc135b6", - registeredAt: 0n, - ...over, -}); - -describe("payeeIdOf", () => { - it("matches kinlock-registry's own payee_id for each known fixture", () => { - // Computed independently with `pnpm hash` in kinlock-registry against the source files. - expect(payeeIdOf("ng-kinlock-test-academy")).toBe( - "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1", - ); - expect(payeeIdOf("ke-kinlock-test-school")).toBe( - "4485665bae55d2562f20ad82cec50c8aaa99ddf613b982509711a6b474ded0ba", - ); - expect(payeeIdOf("ph-kinlock-test-rentals")).toBe( - "a7815d079f7663968e7ce11c3765009a09c5daccb4a123a9102d3f7567d43866", - ); - }); -}); - -describe("listKnownPayees", () => { - it("shows display fields only when the on-chain meta_hash matches this snapshot", async () => { - getPayee.mockImplementation(async (payeeId: string) => - payeeId === payeeIdOf("ng-kinlock-test-academy") - ? chainPayee({ - metaHash: "b91bbf10a4b5ac34c0ff345817423cbf8ade78ecf9bf27fe50f2cc91ebc135b6", - }) - : null, - ); - const result = await listKnownPayees(); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - slug: "ng-kinlock-test-academy", - displayName: "Kinlock Test Academy (fixture)", - country: "NG", - status: "Active", - }); - }); - - it("clears display fields when the on-chain meta_hash doesn't match (edited after registration)", async () => { - getPayee.mockResolvedValue(chainPayee({ metaHash: "0".repeat(64) })); - const result = await listKnownPayees(); - for (const p of result) { - expect(p.slug).toBeNull(); - expect(p.displayName).toBeNull(); - expect(p.country).toBeNull(); - } - }); - - it("drops payees the chain has never heard of", async () => { - getPayee.mockResolvedValue(null); - expect(await listKnownPayees()).toEqual([]); - }); - - it("never throws when a chain read fails; drops that payee instead", async () => { - getPayee.mockRejectedValue(new Error("rpc down")); - await expect(listKnownPayees()).resolves.toEqual([]); - }); - - it("reflects live chain status, not a hard-coded value", async () => { - getPayee.mockResolvedValue(chainPayee({ status: "Suspended" })); - const result = await listKnownPayees(); - expect(result.every((p) => p.status === "Suspended")).toBe(true); - }); - - it("covers every known fixture (sanity: the snapshot isn't empty)", () => { - expect(KNOWN_PAYEES.length).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/lib/payees/registry.ts b/lib/payees/registry.ts deleted file mode 100644 index 4052654..0000000 --- a/lib/payees/registry.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Known payees joined with live chain state. Interim source for `/send` and `/request` while no - * indexer is deployed (`M2-18`); `getPayee` is a chain read (hard rule 3: chain is truth), so - * `status` and `payout` here are never guessed or taken from the committed snapshot. Mirrors the - * canonicalization and meta_hash binding kinlock-registry's own `scripts/registry.ts` and - * kinlock-sdk's indexer (`services/indexer/src/registry/sync.ts`) already use, so a payee's - * display fields are shown only when this snapshot's hash of the full record still matches what - * the attester actually registered on chain. - */ -import { createHash } from "node:crypto"; -import type { IndexedPayee } from "@/lib/indexer"; -import { getPayee } from "@/lib/sdk"; -import { KNOWN_PAYEES, type KnownPayee } from "./known"; - -type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; - -function canonicalize(value: Json): string { - if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; - if (value !== null && typeof value === "object") { - return `{${Object.keys(value) - .sort() - .map( - (k) => `${JSON.stringify(k)}:${canonicalize((value as Record)[k] as Json)}`, - ) - .join(",")}}`; - } - return JSON.stringify(value); -} - -const sha256Hex = (text: string) => createHash("sha256").update(text, "utf8").digest("hex"); - -/** The on-chain payee ID: SHA-256 of the slug (matches kinlock-registry's `payeeId`). */ -export const payeeIdOf = (slug: string): string => sha256Hex(slug); - -/** SHA-256 of the canonical JSON (matches kinlock-registry's `metaHash`). */ -const metaHashOf = (payee: KnownPayee): string => sha256Hex(canonicalize(payee as unknown as Json)); - -/** Known payees the chain actually confirms exist and are bound to this snapshot's data. */ -export async function listKnownPayees(): Promise { - const joined = await Promise.all(KNOWN_PAYEES.map(joinWithChain)); - return joined.filter((p): p is IndexedPayee => p !== null); -} - -async function joinWithChain(p: KnownPayee): Promise { - const payeeId = payeeIdOf(p.slug); - const onChain = await getPayee(payeeId).catch(() => null); - if (!onChain) return null; - const bound = onChain.metaHash === metaHashOf(p) && onChain.category === p.category; - return { - payeeId, - slug: bound ? p.slug : null, - displayName: bound ? p.display_name : null, - category: onChain.category, - status: onChain.status, - payout: onChain.payout, - attesterHandle: null, - country: bound ? p.country : null, - localCurrency: bound ? p.local_currency : null, - city: bound ? p.city : null, - }; -}