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
8 changes: 5 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| | |
|---|---|
| **Last updated** | 2026-10-08 (fix: `NEXT_PUBLIC_INDEXER_URL` wrongly required, broke verify/send/request) |
| **Last updated** | 2026-10-08 (M3-26: interim chain-verified payee source, no indexer yet) |
| **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 | 25 | 10 | 6 | 9 | 0 | 0 | 0 | 40% |
| 4 App | 26 | 11 | 6 | 9 | 0 | 0 | 0 | 42% |
| 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** | **200** | **40** | **15** | **120** | **0** | **25** | **0** | **23%** |
| **All** | **201** | **41** | **15** | **120** | **0** | **25** | **0** | **23%** |

---

Expand Down Expand Up @@ -315,6 +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 |

---

Expand Down Expand Up @@ -524,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/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 |
| 2026-10-08 | `feat/site-polish` | app | DONE: M3-25 (new row) | Added `app/icon.svg`/`app/apple-icon.png` (favicon and iOS icon, from the org's new lock mark in `kinlock-icon/`). Built `components/nav/SiteHeader.tsx`: a persistent header on every page, desktop nav (Send/Request/Verify) plus two separately-styled, separately-destined buttons (outline "Documentation" to `kinlock-org.github.io`, filled-neutral "GitHub" to `github.com/Kinlock-Org`), collapsing to a hamburger disclosure menu below `md` (closes on route change and Escape). Fixed the landing footer's stale docs link, which pointed at a raw GitHub markdown file instead of the real docs site. Installed the `emilkowalski/skills` `animate` skill and used it for the menu's entrance and a staggered hero fade/slide-up on the landing page (CSS-only, no new dependency, both gated under `prefers-reduced-motion`). Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (93/93), `pnpm build` all green; visually checked desktop, mobile closed/open, and dark mode with Playwright screenshots. Not done here: the GitHub org avatar itself (no API/CLI command sets it; recommended `kinlock-icon/icon-light-1024.png`, needs a human to upload via org settings) |
Expand Down
5 changes: 3 additions & 2 deletions app/request/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/** Payee creates a payment-request link. Roadmap M3-05. */
import { type MessageKey, t } from "@/lib/i18n/messages";
import { type IndexedPayee, listPayees, payable } from "@/lib/indexer";
import { type IndexedPayee, payable } from "@/lib/indexer";
import { listKnownPayees } from "@/lib/payees/registry";
import { RequestForm } from "./RequestForm";

export const dynamic = "force-dynamic";

export default async function Page() {
let payees: IndexedPayee[] | null = null;
try {
payees = payable(await listPayees());
payees = payable(await listKnownPayees());
} catch {
payees = null;
}
Expand Down
5 changes: 3 additions & 2 deletions app/send/page.tsx
Original file line number Diff line number Diff line change
@@ -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 { type MessageKey, t } from "@/lib/i18n/messages";
import { type IndexedPayee, listPayees, payable } from "@/lib/indexer";
import { type IndexedPayee, payable } from "@/lib/indexer";
import { listKnownPayees } from "@/lib/payees/registry";
import { publicConfig } from "@/lib/sdk";
import { SendForm } from "./SendForm";

Expand All @@ -10,7 +11,7 @@ export const dynamic = "force-dynamic";
export default async function Page() {
let payees: IndexedPayee[] | null = null;
try {
payees = payable(await listPayees());
payees = payable(await listKnownPayees());
} catch {
payees = null;
}
Expand Down
57 changes: 57 additions & 0 deletions lib/payees/known.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* 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",
},
];
84 changes: 84 additions & 0 deletions lib/payees/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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> = {}): 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);
});
});
61 changes: 61 additions & 0 deletions lib/payees/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* 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<string, Json>)[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<IndexedPayee[]> {
const joined = await Promise.all(KNOWN_PAYEES.map(joinWithChain));
return joined.filter((p): p is IndexedPayee => p !== null);
}

async function joinWithChain(p: KnownPayee): Promise<IndexedPayee | null> {
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,
};
}
Loading