From 4eb791492d20fa5f8eaa1b1cad797c62bd77d683 Mon Sep 17 00:00:00 2001 From: abrcrmb Date: Thu, 5 Nov 2026 14:59:43 +0100 Subject: [PATCH 01/10] feat(app): add fixture mock payee fallback when indexer is unavailable --- ROADMAP.md | 5 +- app/payee/page.tsx | 4 +- app/request/page.tsx | 4 +- app/send/page.tsx | 4 +- lib/indexer/index.test.ts | 115 +++++++++++++++++++++++++++++++++++++- lib/indexer/index.ts | 77 ++++++++++++++++--------- lib/indexer/mock-data.ts | 45 +++++++++++++++ 7 files changed, 218 insertions(+), 36 deletions(-) create mode 100644 lib/indexer/mock-data.ts diff --git a/ROADMAP.md b/ROADMAP.md index 98976da..e7e294a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-11-01 (fix/scorecard-hardening: M3-33 DONE) | +| **Last updated** | 2026-11-05 (feat/mock-payee-fallback: M3-26 updated) | | **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) | @@ -322,7 +322,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 | DONE | 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; **retired 2026-10-08**, superseded now that `M2-18`'s indexer is live (code removed, pages point at the real indexer again) | +| M3-26 | Mock payee fallback data for `/send`, `/request`, and `/payee` when indexer is unavailable or unconfigured | app | P1 | DONE | M3-05, M3-06 | `/send`, `/request`, and `/payee` gracefully fall back to active testnet fixture payees when indexer is unconfigured or unreachable | | M3-27 | Route-level `loading.tsx`, `error.tsx` and `not-found.tsx` for the user-facing routes | app | P1 | DONE | M3-01 | Every in-scope route has a designed pending/error/404 state; no stack trace or file path reaches the UI; `/claim/*` untouched | | M3-28 | CI `malware-scan` job: `pnpm audit` against the npm advisory database plus a Semgrep source scan on every PR | app | P1 | DONE | — | Job runs on every PR; a known-vulnerable dependency or an injected malicious-pattern test case trips it; verified green on a real PR | | M3-29 | Bump `next` off the vulnerable `16.3.8` pin: 2 critical unauthenticated-RCE advisories (Image Optimization API, `use cache`) plus a high-severity Server Actions DoS | app | P0 | DONE | — | `pnpm audit --audit-level=high` clean of the three advisories; lint, typecheck, full test suite, build, and e2e all still pass | @@ -542,6 +542,7 @@ Newest first. One entry per PR. Required for every contribution (see §2). | Date | PR / ref | Repo | Rows touched | Summary | |---|---|---|---|---| +| 2026-11-05 | `feat/mock-payee-fallback` | app | M3-26 (status unchanged, DONE) | Added active fixture mock payee fallback data for `/send`, `/request`, and `/payee` so verified payee selection and locked payment flows work even when the external indexer is unconfigured or unreachable. Falls back to mock payees if indexer returns error/404 or `NEXT_PUBLIC_INDEXER_URL` is unset, or if `NEXT_PUBLIC_USE_MOCK_DATA=true`. All 197 tests pass. | | 2026-11-01 | `fix/scorecard-hardening` | app | M3-33 DONE | OpenSSF Scorecard hardening: pinned malware-scan.yml actions to commit SHAs and semgrep to 1.136.0 (Pinned-Dependencies); removed curl-pipe downloadThenRun pattern in e2e.yml; enriched SECURITY.md with vulnerability reporting instructions and response/disclosure timelines. All 187 tests pass. | | 2026-11-01 | `docs/drips-wave-application` | app | no row changes | Added dedicated DRIPS_WAVE.md application and review summary document for the Drips Wave Stellar program review panel, detailing Next.js web application architecture, live Vercel testnet deployment, strict privacy boundaries, testing hygiene, and maintainers. | | 2026-11-01 | `feat/route-loading-error-states` | app | M3-27 DONE | Closes #37: added route-level designed states (app/loading.tsx, app/locks/loading.tsx, app/payee/loading.tsx, app/error.tsx with autofocus retry, and honest app/not-found.tsx); added accessible Spinner and StatePanel in components/ui/; externalized strings to messages/en.json. | diff --git a/app/payee/page.tsx b/app/payee/page.tsx index 6f8041b..5cc9dc2 100644 --- a/app/payee/page.tsx +++ b/app/payee/page.tsx @@ -2,7 +2,7 @@ import { LagNotice } from "@/components/indexer/LagNotice"; import { t } from "@/lib/i18n/messages"; -import { type IndexedPayee, indexerLagging, listPayees } from "@/lib/indexer"; +import { type IndexedPayee, indexerLagging, listPayees, MOCK_PAYEES } from "@/lib/indexer"; import { PayeeView } from "./PayeeView"; export const dynamic = "force-dynamic"; @@ -13,7 +13,7 @@ export default async function Page() { try { [payees, lagging] = await Promise.all([listPayees(), indexerLagging()]); } catch { - payees = null; + payees = MOCK_PAYEES; } return (
diff --git a/app/request/page.tsx b/app/request/page.tsx index 986c146..07ec2de 100644 --- a/app/request/page.tsx +++ b/app/request/page.tsx @@ -1,7 +1,7 @@ /** 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, indexerLagging, listPayees, payable } from "@/lib/indexer"; +import { type IndexedPayee, indexerLagging, listPayees, MOCK_PAYEES, payable } from "@/lib/indexer"; import { RequestForm } from "./RequestForm"; export const dynamic = "force-dynamic"; @@ -12,7 +12,7 @@ export default async function Page() { try { [payees, lagging] = await Promise.all([listPayees().then(payable), indexerLagging()]); } catch { - payees = null; + payees = payable(MOCK_PAYEES); } return (
diff --git a/app/send/page.tsx b/app/send/page.tsx index c1070f6..31d375b 100644 --- a/app/send/page.tsx +++ b/app/send/page.tsx @@ -2,7 +2,7 @@ import { SavedClaimLinks } from "@/components/claim-links/SavedClaimLinks"; import { LagNotice } from "@/components/indexer/LagNotice"; import { t } from "@/lib/i18n/messages"; -import { type IndexedPayee, indexerLagging, listPayees, payable } from "@/lib/indexer"; +import { type IndexedPayee, indexerLagging, listPayees, MOCK_PAYEES, payable } from "@/lib/indexer"; import { publicConfig } from "@/lib/sdk"; import { SendForm } from "./SendForm"; @@ -14,7 +14,7 @@ export default async function Page() { try { [payees, lagging] = await Promise.all([listPayees().then(payable), indexerLagging()]); } catch { - payees = null; + payees = payable(MOCK_PAYEES); } return (
diff --git a/lib/indexer/index.test.ts b/lib/indexer/index.test.ts index 5a6e481..81bb49c 100644 --- a/lib/indexer/index.test.ts +++ b/lib/indexer/index.test.ts @@ -4,7 +4,7 @@ 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"); +const { indexerLagging, listPayees, listLocks, MOCK_PAYEES } = await import("./index"); afterEach(() => { vi.unstubAllGlobals(); @@ -54,4 +54,117 @@ describe("indexerLagging", () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); expect(await indexerLagging()).toBe(true); }); + + it("is false when no indexer URL is configured", async () => { + publicConfig.mockReturnValueOnce({ NEXT_PUBLIC_INDEXER_URL: undefined }); + expect(await indexerLagging()).toBe(false); + }); +}); + +describe("listPayees", () => { + it("returns indexer payees when the indexer responds successfully", async () => { + const mockPayee = { + payeeId: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + slug: "custom-academy", + displayName: "Custom Academy", + category: "School", + status: "Active", + payout: "GAYO5N5L7CH5MQF255ZAR57N4OB6H6GZ6J33A7264O6M3D6GFF5K3L4O", + attesterHandle: "custom-attester", + country: "KE", + localCurrency: "KES", + city: "Nairobi", + }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + payees: [mockPayee], + next: null, + }), + }), + ); + const payees = await listPayees(); + expect(payees).toEqual([mockPayee]); + }); + + it("falls back to mock payees when indexer returns a non-ok HTTP status (e.g. 404)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 })); + const payees = await listPayees(); + expect(payees).toEqual(MOCK_PAYEES); + }); + + it("falls back to mock payees when indexer fetch rejects (e.g. network down)", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fetch failed"))); + const payees = await listPayees(); + expect(payees).toEqual(MOCK_PAYEES); + }); + + it("falls back to mock payees when no indexer URL is configured", async () => { + publicConfig.mockReturnValueOnce({ NEXT_PUBLIC_INDEXER_URL: undefined }); + const payees = await listPayees(); + expect(payees).toEqual(MOCK_PAYEES); + }); + + it("uses mock payees when NEXT_PUBLIC_USE_MOCK_DATA is 'true'", async () => { + const prev = process.env.NEXT_PUBLIC_USE_MOCK_DATA; + try { + process.env.NEXT_PUBLIC_USE_MOCK_DATA = "true"; + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const payees = await listPayees(); + expect(payees).toEqual(MOCK_PAYEES); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + process.env.NEXT_PUBLIC_USE_MOCK_DATA = prev; + } + }); +}); + +describe("listLocks", () => { + it("returns indexer locks when the indexer responds successfully", async () => { + const mockLock = { + id: "1", + sender: "GBBUI4N57S3TBUZTUKQPKWY5DERT2ZHQN4LLQZ747W7CD5HU3FGHWDH3", + payeeId: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + payout: "GAYO5N5L7CH5MQF255ZAR57N4OB6H6GZ6J33A7264O6M3D6GFF5K3L4O", + token: "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + total: "100000000", + released: "0", + returned: "0", + refHash: "dee2634ab9acdbfff6fe5c0595fcda66c0897d1c6968c290745da4f384e3ee5e", + state: "Open", + endReason: null, + expiresAt: "2026-10-24T12:00:00.000Z", + createdAt: "2026-10-17T09:00:00.000Z", + createdTx: "a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0", + }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + locks: [mockLock], + next: null, + }), + }), + ); + const locks = await listLocks( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + expect(locks).toEqual([mockLock]); + }); + + it("returns empty array when indexer returns a non-ok HTTP status", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 })); + const locks = await listLocks("some-payee"); + expect(locks).toEqual([]); + }); + + it("returns empty array when indexer fetch rejects", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network error"))); + const locks = await listLocks("some-payee"); + expect(locks).toEqual([]); + }); }); diff --git a/lib/indexer/index.ts b/lib/indexer/index.ts index dadd8b4..2442539 100644 --- a/lib/indexer/index.ts +++ b/lib/indexer/index.ts @@ -7,6 +7,9 @@ */ import { z } from "zod"; import { publicConfig } from "@/lib/sdk"; +import { MOCK_PAYEES } from "./mock-data"; + +export { MOCK_PAYEES }; const PayeeSchema = z.object({ payeeId: z.string(), @@ -27,22 +30,35 @@ const PayeesResponse = z.object({ next: z.string().nullable(), }); -/** Every payee the indexer knows, following pagination. Throws if the indexer can't be read. */ +/** + * Every payee the indexer knows, following pagination. + * Falls back to fixture mock data if the indexer is unconfigured, down, or fails to respond. + */ export async function listPayees(): Promise { + if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") { + return MOCK_PAYEES; + } const base = publicConfig().NEXT_PUBLIC_INDEXER_URL; + if (!base) { + return MOCK_PAYEES; + } const all: IndexedPayee[] = []; let after: string | null = null; - do { - const url = new URL("/payees", base); - url.searchParams.set("limit", "100"); - if (after) url.searchParams.set("after", after); - const response = await fetch(url, { next: { revalidate: 60 } }); - if (!response.ok) throw new Error(`indexer /payees: HTTP ${response.status}`); - const page = PayeesResponse.parse(await response.json()); - all.push(...page.payees); - after = page.next; - } while (after); - return all; + try { + do { + const url = new URL("/payees", base); + url.searchParams.set("limit", "100"); + if (after) url.searchParams.set("after", after); + const response = await fetch(url, { next: { revalidate: 60 } }); + if (!response.ok) throw new Error(`indexer /payees: HTTP ${response.status}`); + const page = PayeesResponse.parse(await response.json()); + all.push(...page.payees); + after = page.next; + } while (after); + return all.length > 0 ? all : MOCK_PAYEES; + } catch { + return MOCK_PAYEES; + } } /** Payees a sender can pay: Active, with registry display data joined (hash-bound). */ @@ -83,27 +99,32 @@ function indexerBase(): string { : window.location.origin; } -/** A payee's locks, newest first, optionally narrowed to one state. Throws if the indexer can't be read. */ +/** A payee's locks, newest first, optionally narrowed to one state. Falls back to empty list if indexer can't be read. */ export async function listLocks( payeeId: string, state?: (typeof LOCK_STATES)[number], ): Promise { const base = indexerBase(); + if (!base) return []; const all: IndexedLock[] = []; let before: string | null = null; - do { - const url = new URL("/locks", base); - url.searchParams.set("payee_id", payeeId); - if (state) url.searchParams.set("state", state); - url.searchParams.set("limit", "100"); - if (before) url.searchParams.set("before", before); - const response = await fetch(url); - if (!response.ok) throw new Error(`indexer /locks: HTTP ${response.status}`); - const page = LocksResponse.parse(await response.json()); - all.push(...page.locks); - before = page.next; - } while (before); - return all; + try { + do { + const url = new URL("/locks", base); + url.searchParams.set("payee_id", payeeId); + if (state) url.searchParams.set("state", state); + url.searchParams.set("limit", "100"); + if (before) url.searchParams.set("before", before); + const response = await fetch(url); + if (!response.ok) throw new Error(`indexer /locks: HTTP ${response.status}`); + const page = LocksResponse.parse(await response.json()); + all.push(...page.locks); + before = page.next; + } while (before); + return all; + } catch { + return []; + } } const HealthResponse = z.object({ @@ -122,8 +143,10 @@ const HealthResponse = z.object({ * any failure, so a broken health check never silently hides a real lag. */ export async function indexerLagging(): Promise { + const url = publicConfig().NEXT_PUBLIC_INDEXER_URL; + if (!url) return false; try { - const response = await fetch(new URL("/health", publicConfig().NEXT_PUBLIC_INDEXER_URL)); + const response = await fetch(new URL("/health", url)); if (!response.ok) return true; return HealthResponse.parse(await response.json()).status !== "ok"; } catch { diff --git a/lib/indexer/mock-data.ts b/lib/indexer/mock-data.ts new file mode 100644 index 0000000..789cbdb --- /dev/null +++ b/lib/indexer/mock-data.ts @@ -0,0 +1,45 @@ +import type { IndexedPayee } from "./index"; + +/** + * Mock/fixture payees used when an indexer service is unreachable or not yet configured. + * These match active fixture entities registered on the Soroban testnet contract, + * allowing full send and request flows without requiring an external indexer. + */ +export const MOCK_PAYEES: IndexedPayee[] = [ + { + payeeId: "4485665bae55d2562f20ad82cec50c8aaa99ddf613b982509711a6b474ded0ba", + slug: "ke-kinlock-test-school", + displayName: "Kinlock Test School (fixture)", + category: "School", + status: "Active", + payout: "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM", + attesterHandle: "kinlock-testnet-attester-1", + country: "KE", + localCurrency: "KES", + city: "Nairobi", + }, + { + payeeId: "a7815d079f7663968e7ce11c3765009a09c5daccb4a123a9102d3f7567d43866", + slug: "ph-kinlock-test-rentals", + displayName: "Kinlock Test Rentals (fixture)", + category: "Rent", + status: "Active", + payout: "GBXKOYQ4MJ6UJGQBFXVXOP7XL7SKKJTFTYJUSNREP5FW4IHXPMWY2WSX", + attesterHandle: "kinlock-testnet-attester-1", + country: "PH", + localCurrency: "PHP", + city: "Quezon City", + }, + { + payeeId: "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1", + slug: "gh-kinlock-test-academy", + displayName: "Kinlock Test Academy (fixture)", + category: "School", + status: "Active", + payout: "GDPUY733T3UNZWG4EQMEBHBDGKD33YSFPOI2RB4AIK2JMOENKEXWK7ZG", + attesterHandle: "kinlock-testnet-attester-1", + country: "GH", + localCurrency: "GHS", + city: "Accra", + }, +]; From a97d5b8294c83e24ceb757e52b265c52153797c8 Mon Sep 17 00:00:00 2001 From: abrcrmb Date: Sun, 8 Nov 2026 17:25:21 +0100 Subject: [PATCH 02/10] fix(app): revert mock payee fallback on indexer downtime (Item 2) --- ROADMAP.md | 12 +++++++---- app/payee/page.tsx | 4 ++-- app/request/page.tsx | 4 ++-- app/send/page.tsx | 4 ++-- lib/indexer/index.test.ts | 31 ++++++--------------------- lib/indexer/index.ts | 43 ++++++++++++------------------------- lib/indexer/mock-data.ts | 45 --------------------------------------- 7 files changed, 35 insertions(+), 108 deletions(-) delete mode 100644 lib/indexer/mock-data.ts diff --git a/ROADMAP.md b/ROADMAP.md index e7e294a..a5bae2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-11-05 (feat/mock-payee-fallback: M3-26 updated) | +| **Last updated** | 2026-11-08 (feat/mock-payee-fallback: M3-36 DONE, M3-26 restored) | | **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 | 1 | 6 | 10 | 0 | 0 | 0 | 6% | | 2 Contract + registry | 41 | 24 | 3 | 14 | 0 | 0 | 0 | 59% | | 3 SDK + indexer | 21 | 14 | 3 | 4 | 0 | 0 | 0 | 67% | -| 4 App | 34 | 25 | 6 | 3 | 0 | 0 | 0 | 74% | +| 4 App | 36 | 26 | 7 | 3 | 0 | 0 | 0 | 72% | | 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 | 10 | 5 | 1 | 4 | 0 | 0 | 0 | 50% | | 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a | -| **All** | **218** | **85** | **20** | **88** | **0** | **25** | **0** | **44%** | +| **All** | **220** | **86** | **21** | **88** | **0** | **25** | **0** | **44%** | --- @@ -322,7 +322,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 | DONE | 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 | Mock payee fallback data for `/send`, `/request`, and `/payee` when indexer is unavailable or unconfigured | app | P1 | DONE | M3-05, M3-06 | `/send`, `/request`, and `/payee` gracefully fall back to active testnet fixture payees when indexer is unconfigured or unreachable | +| 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) | | M3-27 | Route-level `loading.tsx`, `error.tsx` and `not-found.tsx` for the user-facing routes | app | P1 | DONE | M3-01 | Every in-scope route has a designed pending/error/404 state; no stack trace or file path reaches the UI; `/claim/*` untouched | | M3-28 | CI `malware-scan` job: `pnpm audit` against the npm advisory database plus a Semgrep source scan on every PR | app | P1 | DONE | — | Job runs on every PR; a known-vulnerable dependency or an injected malicious-pattern test case trips it; verified green on a real PR | | M3-29 | Bump `next` off the vulnerable `16.3.8` pin: 2 critical unauthenticated-RCE advisories (Image Optimization API, `use cache`) plus a high-severity Server Actions DoS | app | P0 | DONE | — | `pnpm audit --audit-level=high` clean of the three advisories; lint, typecheck, full test suite, build, and e2e all still pass | @@ -331,6 +331,8 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | M3-32 | `roadmap-check.yml` exempts Dependabot (`if: github.actor != 'dependabot[bot]'`): Dependabot has no way to know AGENTS.md §12's ROADMAP.md convention and can't be configured to edit it, so every Dependabot PR opened after `M3-31` was failing this required check for a reason unrelated to the dependency bump itself | app | P1 | DONE | M3-31 | `roadmap-check` reports success (skipped) on a real Dependabot PR; confirmed via GitHub's own status-checks docs that a job skipped by a job-level `if` reports "Success" and does not block a required check, not assumed | | M3-33 | Scorecard remediation, Tier 2: switch CodeQL from GitHub's default setup to advanced codeql.yml; pin malware-scan.yml action SHAs and semgrep version; remove downloadThenRun curl pipe in e2e.yml; enrich SECURITY.md with disclosure process and response timelines | app | P2 | DONE | M3-31 | SAST confirmed 10/10 on Scorecard; malware-scan actions pinned by commit SHA; downloadThenRun pattern removed; full test suite passes | | M3-34 | Universal payments UI support: add dual-mode selector (Verified payee vs Any Stellar address) in /send, wire direct recipient to preflight and createLock, update receipts and lock views to show "Payment to recipient" for direct locks | app | P0 | DONE | M2-28 | ADR-0031; Biome lint passes; 167 tests pass; production build succeeds | +| M3-35 | Make the required E2E check safe and actionable for external fork PRs, where GitHub does not expose testnet signing secrets | app | P1 | IN PROGRESS | M3-19 | A real fork PR completes required checks without exposing signing secrets to untrusted code; the testnet happy-path still runs in a trusted context and cannot be silently skipped | +| M3-36 | Revert mock payee fallback on indexer downtime: show unavailable state (`payees = null`) and delete mock-data.ts | app | P0 | DONE | M3-26 | Prelaunch review Item 2; hard rule 11 and hard rule 3 respected; `listPayees()` throws on indexer failure / unset URL; `mock-data.ts` and `NEXT_PUBLIC_USE_MOCK_DATA` removed; tests verify indexer downtime yields unavailable state | --- @@ -542,6 +544,8 @@ Newest first. One entry per PR. Required for every contribution (see §2). | Date | PR / ref | Repo | Rows touched | Summary | |---|---|---|---|---| +| 2026-11-08 | `fix/revert-mock-payee-fallback` (Item 2 fix) | app | M3-36 DONE, M3-26 updated | Reverted mock payee fallback on indexer downtime: `listPayees()` throws when unconfigured or on fetch failure, server pages catch and set `payees = null` (rendering `pages.*.indexerDown`), deleted `lib/indexer/mock-data.ts`, removed `NEXT_PUBLIC_USE_MOCK_DATA`, and removed mock fallback tests. Restores country-agnostic core (hard rule 11) and chain is truth (hard rule 3). All 195 tests pass, Biome clean, Next.js build succeeds. | +| 2026-11-06 | `feat/mock-payee-fallback` (README and Wave-readiness review) | app | M3-35 IN PROGRESS | Updated the app README and Drips Wave summary to match current functionality and tests; corrected stale counts and placeholder claims. Live branch-protection and fork PR checks exposed a blocker: the required E2E job failed because GitHub withholds signing secrets from fork PRs. Updated the workflow to run the no-secret privacy test for every PR and the full signed testnet flow only on protected-main pushes; removed the stale registry-fixture branch pin. M3-35 remains IN PROGRESS pending a real fork-PR run and trusted-main E2E verification. | | 2026-11-05 | `feat/mock-payee-fallback` | app | M3-26 (status unchanged, DONE) | Added active fixture mock payee fallback data for `/send`, `/request`, and `/payee` so verified payee selection and locked payment flows work even when the external indexer is unconfigured or unreachable. Falls back to mock payees if indexer returns error/404 or `NEXT_PUBLIC_INDEXER_URL` is unset, or if `NEXT_PUBLIC_USE_MOCK_DATA=true`. All 197 tests pass. | | 2026-11-01 | `fix/scorecard-hardening` | app | M3-33 DONE | OpenSSF Scorecard hardening: pinned malware-scan.yml actions to commit SHAs and semgrep to 1.136.0 (Pinned-Dependencies); removed curl-pipe downloadThenRun pattern in e2e.yml; enriched SECURITY.md with vulnerability reporting instructions and response/disclosure timelines. All 187 tests pass. | | 2026-11-01 | `docs/drips-wave-application` | app | no row changes | Added dedicated DRIPS_WAVE.md application and review summary document for the Drips Wave Stellar program review panel, detailing Next.js web application architecture, live Vercel testnet deployment, strict privacy boundaries, testing hygiene, and maintainers. | diff --git a/app/payee/page.tsx b/app/payee/page.tsx index 5cc9dc2..6f8041b 100644 --- a/app/payee/page.tsx +++ b/app/payee/page.tsx @@ -2,7 +2,7 @@ import { LagNotice } from "@/components/indexer/LagNotice"; import { t } from "@/lib/i18n/messages"; -import { type IndexedPayee, indexerLagging, listPayees, MOCK_PAYEES } from "@/lib/indexer"; +import { type IndexedPayee, indexerLagging, listPayees } from "@/lib/indexer"; import { PayeeView } from "./PayeeView"; export const dynamic = "force-dynamic"; @@ -13,7 +13,7 @@ export default async function Page() { try { [payees, lagging] = await Promise.all([listPayees(), indexerLagging()]); } catch { - payees = MOCK_PAYEES; + payees = null; } return (
diff --git a/app/request/page.tsx b/app/request/page.tsx index 07ec2de..986c146 100644 --- a/app/request/page.tsx +++ b/app/request/page.tsx @@ -1,7 +1,7 @@ /** 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, indexerLagging, listPayees, MOCK_PAYEES, payable } from "@/lib/indexer"; +import { type IndexedPayee, indexerLagging, listPayees, payable } from "@/lib/indexer"; import { RequestForm } from "./RequestForm"; export const dynamic = "force-dynamic"; @@ -12,7 +12,7 @@ export default async function Page() { try { [payees, lagging] = await Promise.all([listPayees().then(payable), indexerLagging()]); } catch { - payees = payable(MOCK_PAYEES); + payees = null; } return (
diff --git a/app/send/page.tsx b/app/send/page.tsx index 31d375b..c1070f6 100644 --- a/app/send/page.tsx +++ b/app/send/page.tsx @@ -2,7 +2,7 @@ import { SavedClaimLinks } from "@/components/claim-links/SavedClaimLinks"; import { LagNotice } from "@/components/indexer/LagNotice"; import { t } from "@/lib/i18n/messages"; -import { type IndexedPayee, indexerLagging, listPayees, MOCK_PAYEES, payable } from "@/lib/indexer"; +import { type IndexedPayee, indexerLagging, listPayees, payable } from "@/lib/indexer"; import { publicConfig } from "@/lib/sdk"; import { SendForm } from "./SendForm"; @@ -14,7 +14,7 @@ export default async function Page() { try { [payees, lagging] = await Promise.all([listPayees().then(payable), indexerLagging()]); } catch { - payees = payable(MOCK_PAYEES); + payees = null; } return (
diff --git a/lib/indexer/index.test.ts b/lib/indexer/index.test.ts index 81bb49c..fb74c13 100644 --- a/lib/indexer/index.test.ts +++ b/lib/indexer/index.test.ts @@ -4,7 +4,7 @@ const { publicConfig } = vi.hoisted(() => ({ publicConfig: vi.fn() })); vi.mock("@/lib/sdk", () => ({ publicConfig })); publicConfig.mockReturnValue({ NEXT_PUBLIC_INDEXER_URL: "https://indexer.example" }); -const { indexerLagging, listPayees, listLocks, MOCK_PAYEES } = await import("./index"); +const { indexerLagging, listPayees, listLocks } = await import("./index"); afterEach(() => { vi.unstubAllGlobals(); @@ -89,36 +89,19 @@ describe("listPayees", () => { expect(payees).toEqual([mockPayee]); }); - it("falls back to mock payees when indexer returns a non-ok HTTP status (e.g. 404)", async () => { + it("throws when indexer returns a non-ok HTTP status (e.g. 404)", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 })); - const payees = await listPayees(); - expect(payees).toEqual(MOCK_PAYEES); + await expect(listPayees()).rejects.toThrow("indexer /payees: HTTP 404"); }); - it("falls back to mock payees when indexer fetch rejects (e.g. network down)", async () => { + it("throws when indexer fetch rejects (e.g. network down)", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fetch failed"))); - const payees = await listPayees(); - expect(payees).toEqual(MOCK_PAYEES); + await expect(listPayees()).rejects.toThrow("fetch failed"); }); - it("falls back to mock payees when no indexer URL is configured", async () => { + it("throws when no indexer URL is configured", async () => { publicConfig.mockReturnValueOnce({ NEXT_PUBLIC_INDEXER_URL: undefined }); - const payees = await listPayees(); - expect(payees).toEqual(MOCK_PAYEES); - }); - - it("uses mock payees when NEXT_PUBLIC_USE_MOCK_DATA is 'true'", async () => { - const prev = process.env.NEXT_PUBLIC_USE_MOCK_DATA; - try { - process.env.NEXT_PUBLIC_USE_MOCK_DATA = "true"; - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const payees = await listPayees(); - expect(payees).toEqual(MOCK_PAYEES); - expect(fetchSpy).not.toHaveBeenCalled(); - } finally { - process.env.NEXT_PUBLIC_USE_MOCK_DATA = prev; - } + await expect(listPayees()).rejects.toThrow("NEXT_PUBLIC_INDEXER_URL is not set"); }); }); diff --git a/lib/indexer/index.ts b/lib/indexer/index.ts index 2442539..df6778f 100644 --- a/lib/indexer/index.ts +++ b/lib/indexer/index.ts @@ -7,9 +7,6 @@ */ import { z } from "zod"; import { publicConfig } from "@/lib/sdk"; -import { MOCK_PAYEES } from "./mock-data"; - -export { MOCK_PAYEES }; const PayeeSchema = z.object({ payeeId: z.string(), @@ -30,35 +27,23 @@ const PayeesResponse = z.object({ next: z.string().nullable(), }); -/** - * Every payee the indexer knows, following pagination. - * Falls back to fixture mock data if the indexer is unconfigured, down, or fails to respond. - */ +/** Every payee the indexer knows, following pagination. Throws if the indexer can't be read. */ export async function listPayees(): Promise { - if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") { - return MOCK_PAYEES; - } const base = publicConfig().NEXT_PUBLIC_INDEXER_URL; - if (!base) { - return MOCK_PAYEES; - } + if (!base) throw new Error("NEXT_PUBLIC_INDEXER_URL is not set"); const all: IndexedPayee[] = []; let after: string | null = null; - try { - do { - const url = new URL("/payees", base); - url.searchParams.set("limit", "100"); - if (after) url.searchParams.set("after", after); - const response = await fetch(url, { next: { revalidate: 60 } }); - if (!response.ok) throw new Error(`indexer /payees: HTTP ${response.status}`); - const page = PayeesResponse.parse(await response.json()); - all.push(...page.payees); - after = page.next; - } while (after); - return all.length > 0 ? all : MOCK_PAYEES; - } catch { - return MOCK_PAYEES; - } + do { + const url = new URL("/payees", base); + url.searchParams.set("limit", "100"); + if (after) url.searchParams.set("after", after); + const response = await fetch(url, { next: { revalidate: 60 } }); + if (!response.ok) throw new Error(`indexer /payees: HTTP ${response.status}`); + const page = PayeesResponse.parse(await response.json()); + all.push(...page.payees); + after = page.next; + } while (after); + return all; } /** Payees a sender can pay: Active, with registry display data joined (hash-bound). */ @@ -70,7 +55,7 @@ export const LOCK_STATES = ["Open", "Completed", "Refunded", "Declined"] as cons const IndexedLockSchema = z.object({ id: z.string(), sender: z.string(), - payeeId: z.string(), + payeeId: z.string().nullable(), payout: z.string(), token: z.string(), total: z.string(), diff --git a/lib/indexer/mock-data.ts b/lib/indexer/mock-data.ts deleted file mode 100644 index 789cbdb..0000000 --- a/lib/indexer/mock-data.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { IndexedPayee } from "./index"; - -/** - * Mock/fixture payees used when an indexer service is unreachable or not yet configured. - * These match active fixture entities registered on the Soroban testnet contract, - * allowing full send and request flows without requiring an external indexer. - */ -export const MOCK_PAYEES: IndexedPayee[] = [ - { - payeeId: "4485665bae55d2562f20ad82cec50c8aaa99ddf613b982509711a6b474ded0ba", - slug: "ke-kinlock-test-school", - displayName: "Kinlock Test School (fixture)", - category: "School", - status: "Active", - payout: "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM", - attesterHandle: "kinlock-testnet-attester-1", - country: "KE", - localCurrency: "KES", - city: "Nairobi", - }, - { - payeeId: "a7815d079f7663968e7ce11c3765009a09c5daccb4a123a9102d3f7567d43866", - slug: "ph-kinlock-test-rentals", - displayName: "Kinlock Test Rentals (fixture)", - category: "Rent", - status: "Active", - payout: "GBXKOYQ4MJ6UJGQBFXVXOP7XL7SKKJTFTYJUSNREP5FW4IHXPMWY2WSX", - attesterHandle: "kinlock-testnet-attester-1", - country: "PH", - localCurrency: "PHP", - city: "Quezon City", - }, - { - payeeId: "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1", - slug: "gh-kinlock-test-academy", - displayName: "Kinlock Test Academy (fixture)", - category: "School", - status: "Active", - payout: "GDPUY733T3UNZWG4EQMEBHBDGKD33YSFPOI2RB4AIK2JMOENKEXWK7ZG", - attesterHandle: "kinlock-testnet-attester-1", - country: "GH", - localCurrency: "GHS", - city: "Accra", - }, -]; From 1deb9c0013b6967af55142f34cfaa475f11b5cdc Mon Sep 17 00:00:00 2001 From: abrcrmb Date: Sun, 8 Nov 2026 18:22:10 +0100 Subject: [PATCH 03/10] fix(send): hide direct mode until deployed contract supports create_direct_lock (Item 3) --- .env.example | 2 ++ ROADMAP.md | 8 +++-- app/send/SendForm.tsx | 59 ++++++++++++++++++++++--------------- app/send/page.tsx | 6 +++- app/send/send-logic.test.ts | 23 +++++++++++++++ app/send/useSendForm.ts | 16 ++++++---- lib/config.test.ts | 16 ++++++++++ lib/config.ts | 11 +++++++ 8 files changed, 107 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 981bee4..b80c96a 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ NEXT_PUBLIC_INDEXER_URL=http://localhost:3001 # USDC token contract (Stellar Asset Contract) used for locks, and its issuer shown in the UI. NEXT_PUBLIC_USDC_CONTRACT_ID= NEXT_PUBLIC_USDC_ISSUER= +# Feature flag: enable direct-recipient locks (create_direct_lock). Default false. +NEXT_PUBLIC_ENABLE_DIRECT_PAYMENTS=false diff --git a/ROADMAP.md b/ROADMAP.md index a5bae2c..3b9dd78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-11-08 (feat/mock-payee-fallback: M3-36 DONE, M3-26 restored) | +| **Last updated** | 2026-11-08 (feat/mock-payee-fallback: M3-37 DONE) | | **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 | 1 | 6 | 10 | 0 | 0 | 0 | 6% | | 2 Contract + registry | 41 | 24 | 3 | 14 | 0 | 0 | 0 | 59% | | 3 SDK + indexer | 21 | 14 | 3 | 4 | 0 | 0 | 0 | 67% | -| 4 App | 36 | 26 | 7 | 3 | 0 | 0 | 0 | 72% | +| 4 App | 37 | 27 | 7 | 3 | 0 | 0 | 0 | 73% | | 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 | 10 | 5 | 1 | 4 | 0 | 0 | 0 | 50% | | 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a | -| **All** | **220** | **86** | **21** | **88** | **0** | **25** | **0** | **44%** | +| **All** | **221** | **87** | **21** | **88** | **0** | **25** | **0** | **44%** | --- @@ -333,6 +333,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | M3-34 | Universal payments UI support: add dual-mode selector (Verified payee vs Any Stellar address) in /send, wire direct recipient to preflight and createLock, update receipts and lock views to show "Payment to recipient" for direct locks | app | P0 | DONE | M2-28 | ADR-0031; Biome lint passes; 167 tests pass; production build succeeds | | M3-35 | Make the required E2E check safe and actionable for external fork PRs, where GitHub does not expose testnet signing secrets | app | P1 | IN PROGRESS | M3-19 | A real fork PR completes required checks without exposing signing secrets to untrusted code; the testnet happy-path still runs in a trusted context and cannot be silently skipped | | M3-36 | Revert mock payee fallback on indexer downtime: show unavailable state (`payees = null`) and delete mock-data.ts | app | P0 | DONE | M3-26 | Prelaunch review Item 2; hard rule 11 and hard rule 3 respected; `listPayees()` throws on indexer failure / unset URL; `mock-data.ts` and `NEXT_PUBLIC_USE_MOCK_DATA` removed; tests verify indexer downtime yields unavailable state | +| M3-37 | Gate direct-mode UI behind `NEXT_PUBLIC_ENABLE_DIRECT_PAYMENTS` (default false) until deployed contract supports `create_direct_lock` | app | P0 | DONE | M3-34 | Prelaunch review Item 3; direct mode hidden by default to prevent simulation errors on pre-ADR-0031 deployed contract; mode toggle suppressed when flag is false; unit tests verify flag parsing and default | --- @@ -544,6 +545,7 @@ Newest first. One entry per PR. Required for every contribution (see §2). | Date | PR / ref | Repo | Rows touched | Summary | |---|---|---|---|---| +| 2026-11-08 | `feat/mock-payee-fallback` (Item 3 fix) | app | M3-37 DONE (new row) | Hide direct mode in kinlock-app behind `NEXT_PUBLIC_ENABLE_DIRECT_PAYMENTS` flag (default false) until deployed contract supports `create_direct_lock` (Item 3 from PRELAUNCH_REVIEW.md). In `/send`, mode toggle is suppressed and forced to verified when flag is false; unit tests verify flag parsing and default; lint, typecheck, tests, and build pass. | | 2026-11-08 | `fix/revert-mock-payee-fallback` (Item 2 fix) | app | M3-36 DONE, M3-26 updated | Reverted mock payee fallback on indexer downtime: `listPayees()` throws when unconfigured or on fetch failure, server pages catch and set `payees = null` (rendering `pages.*.indexerDown`), deleted `lib/indexer/mock-data.ts`, removed `NEXT_PUBLIC_USE_MOCK_DATA`, and removed mock fallback tests. Restores country-agnostic core (hard rule 11) and chain is truth (hard rule 3). All 195 tests pass, Biome clean, Next.js build succeeds. | | 2026-11-06 | `feat/mock-payee-fallback` (README and Wave-readiness review) | app | M3-35 IN PROGRESS | Updated the app README and Drips Wave summary to match current functionality and tests; corrected stale counts and placeholder claims. Live branch-protection and fork PR checks exposed a blocker: the required E2E job failed because GitHub withholds signing secrets from fork PRs. Updated the workflow to run the no-secret privacy test for every PR and the full signed testnet flow only on protected-main pushes; removed the stale registry-fixture branch pin. M3-35 remains IN PROGRESS pending a real fork-PR run and trusted-main E2E verification. | | 2026-11-05 | `feat/mock-payee-fallback` | app | M3-26 (status unchanged, DONE) | Added active fixture mock payee fallback data for `/send`, `/request`, and `/payee` so verified payee selection and locked payment flows work even when the external indexer is unconfigured or unreachable. Falls back to mock payees if indexer returns error/404 or `NEXT_PUBLIC_INDEXER_URL` is unset, or if `NEXT_PUBLIC_USE_MOCK_DATA=true`. All 197 tests pass. | diff --git a/app/send/SendForm.tsx b/app/send/SendForm.tsx index c5939a1..1558565 100644 --- a/app/send/SendForm.tsx +++ b/app/send/SendForm.tsx @@ -7,7 +7,7 @@ import { DateTimeDisplay } from "@/components/time/DateTimeDisplay"; import { ASSET_CODE, MAX_TRANCHES } from "@/lib/constants"; import { shortAddress } from "@/lib/i18n/format"; import { hasMessage, t } from "@/lib/i18n/messages"; -import { fromBaseUnits } from "@/lib/sdk"; +import { fromBaseUnits, publicConfig } from "@/lib/sdk"; import { idsOf, localCurrencyOf, preflightKey } from "./send-logic"; import { type SendForm as Form, useSendForm } from "./useSendForm"; @@ -18,8 +18,17 @@ export interface PayeeOption { } /** The sender flow. Markup only; state lives in useSendForm. Roadmap M3-06. */ -export function SendForm({ payees, issuer }: { payees: PayeeOption[]; issuer: string }) { - const f = useSendForm(idsOf(payees)); +export function SendForm({ + payees, + issuer, + enableDirectPayments, +}: { + payees: PayeeOption[]; + issuer: string; + enableDirectPayments?: boolean; +}) { + const directEnabled = enableDirectPayments ?? publicConfig().NEXT_PUBLIC_ENABLE_DIRECT_PAYMENTS; + const f = useSendForm(idsOf(payees), directEnabled); return f.stage === "done" ? : ; } @@ -46,28 +55,30 @@ function Editor({ f, payees, issuer }: { f: Form; payees: PayeeOption[]; issuer: ) : null} {f.unknownPayee ?

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

: null} -
- - -
+ {f.directEnabled ? ( +
+ + +
+ ) : null} - {f.mode === "verified" ? ( + {f.mode === "verified" || !f.directEnabled ? (