From b937e91ef884e72d4ec746055f4fd8743e93ed2d Mon Sep 17 00:00:00 2001 From: FlowMemory HQ Agent Date: Thu, 14 May 2026 13:42:32 -0500 Subject: [PATCH] Add production L1 rpc implementation snapshot --- apps/dashboard/src/App.tsx | 2 + apps/dashboard/src/components/AppShell.tsx | 2 + apps/dashboard/src/data/bridge.ts | 300 ++++ apps/dashboard/src/styles.css | 206 ++- apps/dashboard/src/test/dashboardData.test.ts | 114 ++ apps/dashboard/src/views/BridgeView.tsx | 520 +++++++ docs/FLOWCHAIN_CONTROL_PLANE_API.md | 245 +++- .../production-l1-rpc/API_SURFACE.md | 35 + .../production-l1-rpc/BRIDGE_API_PROOF.md | 61 + .../agent-runs/production-l1-rpc/CHECKLIST.md | 30 + .../production-l1-rpc/COMPLETION_AUDIT.md | 35 + .../production-l1-rpc/DASHBOARD_CONTRACT.md | 189 +++ .../DASHBOARD_FIELD_PROOF.md | 15 + .../production-l1-rpc/ENDPOINT_MATRIX.md | 91 ++ .../production-l1-rpc/ERROR_MODEL.md | 52 + .../production-l1-rpc/EXPERIMENTS.md | 29 + docs/agent-runs/production-l1-rpc/HANDOFF.md | 93 ++ .../LIVE_WALLET_DASHBOARD_USE.md | 95 ++ docs/agent-runs/production-l1-rpc/NOTES.md | 24 + .../production-l1-rpc/NO_SECRET_PROOF.md | 44 + docs/agent-runs/production-l1-rpc/PLAN.md | 40 + .../SCHEMA_VALIDATION_PROOF.md | 44 + .../production-l1-rpc/SUBMIT_LOOP_PROOF.md | 30 + .../production-l1-rpc/SUBMIT_QUERY_PROOF.md | 50 + infra/scripts/flowchain-no-secret-scan.mjs | 107 ++ package.json | 1 + schemas/flowmemory/README.md | 8 + .../control-plane-production-l1.schema.json | 123 ++ services/control-plane/README.md | 20 +- services/control-plane/src/errors.ts | 125 +- services/control-plane/src/index.ts | 1 + services/control-plane/src/methods.ts | 1266 ++++++++++++++++- services/control-plane/src/server.ts | 56 +- services/control-plane/src/smoke.ts | 232 ++- .../control-plane/src/transaction-envelope.ts | 234 +++ services/control-plane/src/types.ts | 22 +- .../control-plane/test/control-plane.test.ts | 263 +++- 37 files changed, 4648 insertions(+), 156 deletions(-) create mode 100644 apps/dashboard/src/data/bridge.ts create mode 100644 apps/dashboard/src/views/BridgeView.tsx create mode 100644 docs/agent-runs/production-l1-rpc/API_SURFACE.md create mode 100644 docs/agent-runs/production-l1-rpc/BRIDGE_API_PROOF.md create mode 100644 docs/agent-runs/production-l1-rpc/CHECKLIST.md create mode 100644 docs/agent-runs/production-l1-rpc/COMPLETION_AUDIT.md create mode 100644 docs/agent-runs/production-l1-rpc/DASHBOARD_CONTRACT.md create mode 100644 docs/agent-runs/production-l1-rpc/DASHBOARD_FIELD_PROOF.md create mode 100644 docs/agent-runs/production-l1-rpc/ENDPOINT_MATRIX.md create mode 100644 docs/agent-runs/production-l1-rpc/ERROR_MODEL.md create mode 100644 docs/agent-runs/production-l1-rpc/EXPERIMENTS.md create mode 100644 docs/agent-runs/production-l1-rpc/HANDOFF.md create mode 100644 docs/agent-runs/production-l1-rpc/LIVE_WALLET_DASHBOARD_USE.md create mode 100644 docs/agent-runs/production-l1-rpc/NOTES.md create mode 100644 docs/agent-runs/production-l1-rpc/NO_SECRET_PROOF.md create mode 100644 docs/agent-runs/production-l1-rpc/PLAN.md create mode 100644 docs/agent-runs/production-l1-rpc/SCHEMA_VALIDATION_PROOF.md create mode 100644 docs/agent-runs/production-l1-rpc/SUBMIT_LOOP_PROOF.md create mode 100644 docs/agent-runs/production-l1-rpc/SUBMIT_QUERY_PROOF.md create mode 100644 infra/scripts/flowchain-no-secret-scan.mjs create mode 100644 schemas/flowmemory/control-plane-production-l1.schema.json create mode 100644 services/control-plane/src/transaction-envelope.ts diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 750ab52e..90c60056 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -7,6 +7,7 @@ import type { DashboardData } from "./data/types"; import { DEFAULT_CONTROL_PLANE_URL, buildWorkbenchSnapshot, fetchWorkbenchSnapshot, type WorkbenchSnapshot } from "./data/workbench"; import { AlertsView } from "./views/AlertsView"; import { CanaryDeploymentView } from "./views/CanaryDeploymentView"; +import { BridgeView } from "./views/BridgeView"; import { DevnetBlocksView } from "./views/DevnetBlocksView"; import { FlowMemoryView } from "./views/FlowMemoryView"; import { FlowPulseStreamView } from "./views/FlowPulseStreamView"; @@ -118,6 +119,7 @@ export default function App() { setVersion((current) => current + 1)} />} /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/dashboard/src/components/AppShell.tsx b/apps/dashboard/src/components/AppShell.tsx index 765d1308..f5a2bbff 100644 --- a/apps/dashboard/src/components/AppShell.tsx +++ b/apps/dashboard/src/components/AppShell.tsx @@ -14,6 +14,7 @@ import { Network, RadioTower, ShieldCheck, + Wallet, } from "lucide-react"; import type { DashboardData } from "../data/types"; import type { WorkbenchSnapshot } from "../data/workbench"; @@ -30,6 +31,7 @@ const NAV_ITEMS = [ { to: "/", label: "Workbench", icon: Monitor }, { to: "/overview", label: "Overview", icon: LayoutDashboard }, { to: "/canary", label: "Base canary", icon: RadioReceiver }, + { to: "/bridge", label: "Bridge wallet", icon: Wallet }, { to: "/flowmemory", label: "Flow Memory", icon: BrainCircuit }, { to: "/flowpulse", label: "FlowPulse", icon: Activity }, { to: "/rootfields", label: "Rootfields", icon: Boxes }, diff --git a/apps/dashboard/src/data/bridge.ts b/apps/dashboard/src/data/bridge.ts new file mode 100644 index 00000000..1d5f0cde --- /dev/null +++ b/apps/dashboard/src/data/bridge.ts @@ -0,0 +1,300 @@ +import { DEFAULT_CONTROL_PLANE_URL } from "./workbench"; + +const REQUEST_TIMEOUT_MS = 1200; + +export const PLACEHOLDER_FLOWCHAIN_RECIPIENT = /^0x5{64}$/i; +export const FLOWCHAIN_ACCOUNT_PATTERN = /^0x[0-9a-fA-F]{64}$/; +export const ZERO_METADATA_HASH = `0x${"0".repeat(64)}`; + +type JsonObject = Record; + +function isRecord(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export interface BridgeControlPlaneHealth { + status?: string; + localOnly?: boolean; + routes?: string[]; + capabilities?: string[]; + [key: string]: unknown; +} + +export interface BridgeCredit { + creditId?: string; + depositId?: string; + accountId?: string; + txHash?: string; + baseTxHash?: string; + status?: string; + token?: string; + amount?: string; + sourceChainId?: string; + appliedAt?: string; + placeholderRecipient?: boolean; + valueBearingPilot?: boolean; + [key: string]: unknown; +} + +export interface BridgeDeposit { + depositId?: string; + observationId?: string; + txHash?: string; + flowchainRecipient?: string; + status?: string; + token?: string; + amount?: string; + sourceChainId?: string; + observedAt?: string; + [key: string]: unknown; +} + +export interface BridgeCreditStatus { + schema?: string; + readinessLabel?: "LIVE PILOT" | "LOCAL ONLY" | "NOT READY" | string; + exposureLabel?: "LOCAL ONLY" | string; + livePilot?: boolean; + localOnly?: boolean; + usingFixtureFallback?: boolean; + source?: JsonObject; + baseTxHash?: string | null; + confirmationStatus?: string; + lifecycleStatus?: { + observed?: string; + queued?: string; + applied?: string; + idempotent?: string; + [key: string]: unknown; + }; + creditedAccount?: string | null; + tokenId?: string | null; + amount?: string | null; + spendableBalance?: string | null; + balanceBreakdown?: { + localAmount?: string; + bridgeCreditAmount?: string; + pendingAcceptedDelta?: string; + [key: string]: unknown; + } | null; + transferActionStatus?: string; + latestTransferReceipt?: unknown; + firstUsableAt?: string | null; + latencyMs?: number | null; + placeholderRecipient?: boolean; + matchedCounts?: { + credits?: number; + deposits?: number; + }; + credit?: BridgeCredit | null; + deposit?: BridgeDeposit | null; + noBaseReleaseBroadcast?: boolean; + cappedOwnerTesting?: boolean; + [key: string]: unknown; +} + +export interface BridgeLiveSnapshot { + baseUrl: string; + fetchedAt: string; + health: BridgeControlPlaneHealth; + status: BridgeCreditStatus; + credits: BridgeCredit[]; + deposits: BridgeDeposit[]; + txLookup: BridgeCredit | null; +} + +export interface LockNativeDraft { + schema: "flowmemory.dashboard.lock_native_draft.v1"; + functionName: "lockNative"; + signature: "lockNative(bytes32 flowchainRecipient, bytes32 metadataHash)"; + args: { + flowchainRecipient: string; + metadataHash: string; + }; + operatorConfirmedRecipient: true; + noBroadcast: true; + localOnly: true; +} + +export interface TransferSendParams { + from: string; + to: string; + amount: string; + tokenId?: string | null; + memo?: string; +} + +export interface TransferSendResult { + schema?: string; + accepted?: boolean; + txId?: string; + status?: string; + from?: string; + to?: string; + tokenId?: string; + amount?: string; + receipt?: unknown; + noBaseReleaseBroadcast?: boolean; + localOnly?: boolean; + [key: string]: unknown; +} + +function bridgeControlPlaneUrl(): string { + const env = (import.meta as ImportMeta & { env?: Record }).env; + const configured = env?.VITE_FLOWCHAIN_CONTROL_PLANE_URL?.trim(); + return configured && configured.length > 0 ? configured.replace(/\/+$/, "") : DEFAULT_CONTROL_PLANE_URL; +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + cache: "no-store", + ...init, + headers: { + Accept: "application/json", + ...init?.headers, + }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`.trim()); + } + return (await response.json()) as T; + } finally { + globalThis.clearTimeout(timeout); + } +} + +async function rpc(baseUrl: string, method: string, params?: JsonObject): Promise { + const response = await fetchJson<{ result?: T; error?: { message?: string; data?: unknown } }>(`${baseUrl}/rpc`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: method, method, params }), + }); + + if (response.error !== undefined) { + throw new Error(response.error.message ?? `Control-plane RPC failed: ${method}`); + } + if (response.result === undefined) { + throw new Error(`Control-plane RPC returned no result: ${method}`); + } + return response.result; +} + +function listCredits(payload: unknown): BridgeCredit[] { + const record = payload as { credits?: unknown }; + return Array.isArray(record.credits) ? (record.credits as BridgeCredit[]) : []; +} + +function listDeposits(payload: unknown): BridgeDeposit[] { + const record = payload as { deposits?: unknown }; + return Array.isArray(record.deposits) ? (record.deposits as BridgeDeposit[]) : []; +} + +export function getBridgeControlPlaneUrl(): string { + return bridgeControlPlaneUrl(); +} + +export function isPlaceholderFlowchainRecipient(value: string | null | undefined): boolean { + return typeof value === "string" && PLACEHOLDER_FLOWCHAIN_RECIPIENT.test(value); +} + +export function isUsableFlowchainRecipient(value: string | null | undefined): value is string { + return typeof value === "string" && FLOWCHAIN_ACCOUNT_PATTERN.test(value) && !isPlaceholderFlowchainRecipient(value); +} + +export function flowchainAccountFromBytes(bytes: Uint8Array): string { + if (bytes.length !== 32) { + throw new Error("FlowChain account bytes must be exactly 32 bytes."); + } + return `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +export function generateFlowchainAccount(): string { + const bytes = new Uint8Array(32); + globalThis.crypto.getRandomValues(bytes); + return flowchainAccountFromBytes(bytes); +} + +export function buildLockNativeDraft(flowchainRecipient: string, metadataHash = ZERO_METADATA_HASH): LockNativeDraft { + if (!isUsableFlowchainRecipient(flowchainRecipient)) { + throw new Error("A real 32-byte FlowChain recipient is required before preparing lockNative."); + } + if (!FLOWCHAIN_ACCOUNT_PATTERN.test(metadataHash)) { + throw new Error("metadataHash must be a 32-byte hex value."); + } + return { + schema: "flowmemory.dashboard.lock_native_draft.v1", + functionName: "lockNative", + signature: "lockNative(bytes32 flowchainRecipient, bytes32 metadataHash)", + args: { + flowchainRecipient, + metadataHash, + }, + operatorConfirmedRecipient: true, + noBroadcast: true, + localOnly: true, + }; +} + +export function candidateBridgeAccounts(status: BridgeCreditStatus | null, credits: BridgeCredit[]): string[] { + const accounts = [ + status?.creditedAccount, + status?.credit?.accountId, + status?.deposit?.flowchainRecipient, + ...credits.map((credit) => credit.accountId), + ]; + return [...new Set(accounts.filter(isUsableFlowchainRecipient))]; +} + +export async function lookupBridgeCreditByTxHash(baseUrl: string, txHash: string): Promise { + const result = await rpc<{ credit?: BridgeCredit }>(baseUrl, "bridge_credit_get", { txHash }); + if (result.credit === undefined) { + throw new Error("Bridge credit lookup returned no credit."); + } + return result.credit; +} + +export async function sendBridgeCreditTransfer(baseUrl: string, params: TransferSendParams): Promise { + const payload = await fetchJson(`${baseUrl}/transfer/send`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(params), + }); + + if (isRecord(payload) && isRecord(payload.error)) { + throw new Error(typeof payload.error.message === "string" ? payload.error.message : "transfer_send failed"); + } + if (isRecord(payload) && isRecord(payload.result)) { + return payload.result as TransferSendResult; + } + if (!isRecord(payload)) { + throw new Error("transfer_send returned an invalid response."); + } + return payload as TransferSendResult; +} + +export async function fetchBridgeLiveSnapshot(baseUrl = bridgeControlPlaneUrl()): Promise { + const [health, status, creditPayload, depositPayload] = await Promise.all([ + fetchJson(`${baseUrl}/health`), + fetchJson(`${baseUrl}/bridge/credit-status`), + fetchJson(`${baseUrl}/bridge/credits?limit=20`), + fetchJson(`${baseUrl}/bridge/deposits?limit=20`), + ]); + const credits = listCredits(creditPayload); + const deposits = listDeposits(depositPayload); + const txHash = status.baseTxHash ?? credits.find((credit) => credit.txHash ?? credit.baseTxHash)?.txHash ?? credits.find((credit) => credit.baseTxHash)?.baseTxHash; + const txLookup = txHash === undefined || txHash === null ? null : await lookupBridgeCreditByTxHash(baseUrl, txHash).catch(() => null); + + return { + baseUrl, + fetchedAt: new Date().toISOString(), + health, + status, + credits, + deposits, + txLookup, + }; +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index a26271c5..42bd8918 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -1113,6 +1113,200 @@ code { gap: 5px; } +.bridge-readiness-strip { + display: grid; + grid-template-columns: 1.1fr 1fr 1fr; + gap: 10px; +} + +.bridge-readiness-strip article { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 7px 10px; + align-items: start; + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8f9f4; +} + +.bridge-readiness-strip strong, +.bridge-readiness-strip span { + overflow-wrap: anywhere; +} + +.bridge-readiness-strip span:not(.status-badge) { + grid-column: 2; + color: #4c554c; + font-size: 0.82rem; + line-height: 1.42; +} + +.bridge-status-grid, +.bridge-action-grid { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(360px, 0.95fr); + gap: 14px; + align-items: start; +} + +.bridge-live-panel, +.bridge-recipient-panel, +.bridge-lookup-panel, +.bridge-transfer-panel { + display: grid; + gap: 13px; +} + +.bridge-fact-grid, +.bridge-transfer-source { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; + margin: 0; +} + +.bridge-fact-grid div, +.bridge-transfer-source div { + min-width: 0; + padding: 9px; + border: 1px solid var(--line); + border-radius: 7px; + background: #f7f8f4; +} + +.bridge-fact-grid dt, +.bridge-transfer-source dt { + margin-bottom: 5px; + color: #586258; + font-size: 0.68rem; + font-weight: 800; + text-transform: uppercase; +} + +.bridge-fact-grid dd, +.bridge-transfer-source dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: #202720; + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 0.8rem; +} + +.bridge-mode-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} + +.bridge-mode-grid button { + min-height: 36px; + padding: 0 9px; + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +.bridge-mode-grid button.active, +.bridge-mode-grid button:hover { + border-color: #93aa9d; + background: #e7eee8; +} + +.bridge-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.bridge-field span, +.bridge-generated-account span, +.bridge-recipient-display strong { + color: #586258; + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.bridge-field input, +.bridge-field select { + min-height: 38px; + padding: 0 10px; + border: 1px solid var(--line-strong); + border-radius: 7px; + background: var(--surface); + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 0.8rem; +} + +.bridge-amount-field { + max-width: 220px; +} + +.bridge-generated-account, +.bridge-recipient-display { + display: grid; + gap: 6px; + min-width: 0; + padding: 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: #f7f8f4; +} + +.bridge-generated-account code, +.bridge-recipient-display span { + width: auto; + margin: 0; + overflow-wrap: anywhere; +} + +.bridge-recipient-display.valid { + border-color: #a8ceb7; + background: #eef6f0; +} + +.bridge-recipient-display small { + color: var(--danger); +} + +.bridge-confirm-line { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 9px; + align-items: start; + color: #374238; + font-size: 0.86rem; + line-height: 1.4; +} + +.bridge-confirm-line input { + width: 16px; + height: 16px; + margin: 2px 0 0; +} + +.bridge-error { + margin: 0; + color: #963b3b; + font-size: 0.84rem; + line-height: 1.4; +} + +.bridge-receipt { + max-height: 320px; + margin: 0; + padding: 12px; + overflow: auto; + border: 1px solid #d8dfd4; + border-radius: 7px; + background: #202720; + color: #edf4ec; + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 0.76rem; + line-height: 1.48; +} + .rootfield-grid, .hardware-grid { display: grid; @@ -1422,7 +1616,10 @@ code { .pilot-status-body, .local-action-grid, .workbench-command-center, - .workbench-record-grid { + .workbench-record-grid, + .bridge-readiness-strip, + .bridge-status-grid, + .bridge-action-grid { grid-template-columns: 1fr; } @@ -1496,7 +1693,9 @@ code { border-top: 1px solid var(--line); } - .workbench-fact-grid { + .workbench-fact-grid, + .bridge-fact-grid, + .bridge-transfer-source { grid-template-columns: 1fr; } } @@ -1508,7 +1707,8 @@ code { .definition-grid, .lane-stats, .record-facts, - .workbench-switcher { + .workbench-switcher, + .bridge-mode-grid { grid-template-columns: 1fr; } diff --git a/apps/dashboard/src/test/dashboardData.test.ts b/apps/dashboard/src/test/dashboardData.test.ts index f4e16394..1095fc1e 100644 --- a/apps/dashboard/src/test/dashboardData.test.ts +++ b/apps/dashboard/src/test/dashboardData.test.ts @@ -19,6 +19,13 @@ import { buildWorkbenchSnapshot, fetchWorkbenchSnapshot, } from "../data/workbench"; +import { + buildLockNativeDraft, + flowchainAccountFromBytes, + isUsableFlowchainRecipient, + type BridgeLiveSnapshot, +} from "../data/bridge"; +import { BridgeView } from "../views/BridgeView"; import { WorkbenchView } from "../views/WorkbenchView"; describe("dashboard fixture", () => { @@ -307,4 +314,111 @@ describe("dashboard fixture", () => { expect(html).toContain("Hardware Signals"); expect(html).toContain("Raw JSON"); }); + + it("requires a non-placeholder FlowChain recipient before lockNative preparation", () => { + const placeholder = `0x${"5".repeat(64)}`; + const account = flowchainAccountFromBytes(new Uint8Array(32).fill(0xab)); + const draft = buildLockNativeDraft(account); + + expect(isUsableFlowchainRecipient(account)).toBe(true); + expect(isUsableFlowchainRecipient(placeholder)).toBe(false); + expect(draft.signature).toBe("lockNative(bytes32 flowchainRecipient, bytes32 metadataHash)"); + expect(draft.args.flowchainRecipient).toBe(account); + expect(draft.noBroadcast).toBe(true); + expect(() => buildLockNativeDraft(placeholder)).toThrow(/real 32-byte FlowChain recipient/); + }); + + it("renders the bridge route with live status labels and transfer surface", () => { + const account = `0x${"a".repeat(64)}`; + const txHash = `0x${"b".repeat(64)}`; + const snapshot: BridgeLiveSnapshot = { + baseUrl: DEFAULT_CONTROL_PLANE_URL, + fetchedAt: "2026-05-14T12:00:00.000Z", + health: { + status: "ok", + localOnly: true, + routes: ["GET /bridge/credit-status", "POST /transfer/send"], + }, + status: { + schema: "flowmemory.control_plane.bridge_credit_status.v1", + readinessLabel: "LIVE PILOT", + exposureLabel: "LOCAL ONLY", + livePilot: true, + localOnly: true, + usingFixtureFallback: false, + baseTxHash: txHash, + confirmationStatus: "base_observed", + lifecycleStatus: { + observed: "observed", + queued: "queued", + applied: "applied", + idempotent: "unique_or_idempotent", + }, + creditedAccount: account, + tokenId: "flowchain-bridge-credit", + amount: "10", + spendableBalance: "10", + balanceBreakdown: { + localAmount: "0", + bridgeCreditAmount: "10", + pendingAcceptedDelta: "0", + }, + transferActionStatus: "not_run", + firstUsableAt: "2026-05-14T12:00:02.000Z", + latencyMs: 2000, + placeholderRecipient: false, + noBaseReleaseBroadcast: true, + cappedOwnerTesting: true, + credit: { + creditId: "bridge-credit:live", + accountId: account, + baseTxHash: txHash, + status: "applied", + token: "flowchain-bridge-credit", + amount: "10", + }, + deposit: { + depositId: "bridge-deposit:live", + txHash, + flowchainRecipient: account, + status: "observed", + token: "flowchain-bridge-credit", + amount: "10", + }, + }, + credits: [{ + creditId: "bridge-credit:live", + accountId: account, + baseTxHash: txHash, + status: "applied", + token: "flowchain-bridge-credit", + amount: "10", + }], + deposits: [{ + depositId: "bridge-deposit:live", + txHash, + flowchainRecipient: account, + status: "observed", + token: "flowchain-bridge-credit", + amount: "10", + }], + txLookup: { + creditId: "bridge-credit:live", + accountId: account, + baseTxHash: txHash, + status: "applied", + token: "flowchain-bridge-credit", + amount: "10", + }, + }; + const html = renderToStaticMarkup(createElement(BridgeView, { controlPlaneUrl: DEFAULT_CONTROL_PLANE_URL, initialSnapshot: snapshot })); + + expect(html).toContain("Live wallet and bridge credit"); + expect(html).toContain("LIVE PILOT"); + expect(html).toContain("LOCAL ONLY"); + expect(html).toContain("lockNative"); + expect(html).toContain("bridge_credit_get"); + expect(html).toContain("transfer_send"); + expect(html).not.toContain(`0x${"5".repeat(64)}`); + }); }); diff --git a/apps/dashboard/src/views/BridgeView.tsx b/apps/dashboard/src/views/BridgeView.tsx new file mode 100644 index 00000000..f8f39c58 --- /dev/null +++ b/apps/dashboard/src/views/BridgeView.tsx @@ -0,0 +1,520 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { AlertTriangle, CheckCircle2, Coins, RefreshCw, Send, ShieldAlert, Wallet } from "lucide-react"; +import { HashValue } from "../components/HashValue"; +import { SectionHeader } from "../components/SectionHeader"; +import { StatusBadge } from "../components/StatusBadge"; +import type { DashboardStatus } from "../data/types"; +import { + ZERO_METADATA_HASH, + buildLockNativeDraft, + candidateBridgeAccounts, + fetchBridgeLiveSnapshot, + generateFlowchainAccount, + getBridgeControlPlaneUrl, + isPlaceholderFlowchainRecipient, + isUsableFlowchainRecipient, + lookupBridgeCreditByTxHash, + sendBridgeCreditTransfer, + type BridgeCredit, + type BridgeCreditStatus, + type BridgeLiveSnapshot, + type LockNativeDraft, + type TransferSendResult, +} from "../data/bridge"; + +interface BridgeViewProps { + controlPlaneUrl?: string; + initialSnapshot?: BridgeLiveSnapshot | null; +} + +type RecipientMode = "existing" | "generated" | "manual"; + +const DEFAULT_STATUS = { + readinessLabel: "NOT READY", + exposureLabel: "LOCAL ONLY", + localOnly: true, + livePilot: false, + usingFixtureFallback: true, + baseTxHash: null, + confirmationStatus: "not_observed", + lifecycleStatus: { + observed: "missing", + queued: "not_queued", + applied: "missing", + idempotent: "unknown", + }, + creditedAccount: null, + tokenId: "local-test-unit", + amount: "0", + spendableBalance: "0", + balanceBreakdown: null, + transferActionStatus: "not_run", + firstUsableAt: null, + latencyMs: null, + placeholderRecipient: false, + noBaseReleaseBroadcast: true, + cappedOwnerTesting: true, +}; + +function readinessStatus(label: string | undefined, livePilot: boolean | undefined): DashboardStatus { + if (livePilot && label === "LIVE PILOT") { + return "verified"; + } + if (label === "LOCAL ONLY") { + return "pending"; + } + return "failed"; +} + +function text(value: unknown, fallback = "not available"): string { + if (value === null || value === undefined || value === "") { + return fallback; + } + return String(value); +} + +function displayHash(value: string | null | undefined, label: string) { + return value ? : "not observed"; +} + +function positiveInteger(value: string): boolean { + return /^[0-9]+$/.test(value) && BigInt(value) > 0n; +} + +function withinBalance(amount: string, spendable: string | null | undefined): boolean { + if (!positiveInteger(amount) || spendable === null || spendable === undefined || !/^[0-9]+$/.test(spendable)) { + return false; + } + return BigInt(amount) <= BigInt(spendable); +} + +function prettyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function Fact({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function lifecycleSummary(status: BridgeCreditStatus["lifecycleStatus"] | typeof DEFAULT_STATUS.lifecycleStatus | undefined): Array<[string, unknown]> { + return [ + ["observed", status?.observed], + ["queued", status?.queued], + ["applied", status?.applied], + ["idempotent", status?.idempotent], + ]; +} + +function uniqueCreditTxHashes(credits: BridgeCredit[], fallback: string | null | undefined): string[] { + return [ + ...new Set([ + fallback, + ...credits.flatMap((credit) => [credit.baseTxHash, credit.txHash]), + ].filter((value): value is string => typeof value === "string" && value.length > 0)), + ]; +} + +export function BridgeView({ controlPlaneUrl = getBridgeControlPlaneUrl(), initialSnapshot = null }: BridgeViewProps) { + const [snapshot, setSnapshot] = useState(initialSnapshot); + const [loading, setLoading] = useState(initialSnapshot === null); + const [loadError, setLoadError] = useState(null); + const [recipientMode, setRecipientMode] = useState("existing"); + const [selectedAccount, setSelectedAccount] = useState(""); + const [generatedRecipient, setGeneratedRecipient] = useState(""); + const [manualRecipient, setManualRecipient] = useState(""); + const [metadataHash, setMetadataHash] = useState(ZERO_METADATA_HASH); + const [operatorConfirmed, setOperatorConfirmed] = useState(false); + const [lockNativeDraft, setLockNativeDraft] = useState(null); + const [draftError, setDraftError] = useState(null); + const [lookupTxHash, setLookupTxHash] = useState(""); + const [lookupResult, setLookupResult] = useState(initialSnapshot?.txLookup ?? null); + const [lookupError, setLookupError] = useState(null); + const [transferTo, setTransferTo] = useState(""); + const [transferAmount, setTransferAmount] = useState("1"); + const [transferResult, setTransferResult] = useState(null); + const [transferError, setTransferError] = useState(null); + const [transferPending, setTransferPending] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const nextSnapshot = await fetchBridgeLiveSnapshot(controlPlaneUrl); + setSnapshot(nextSnapshot); + setLookupResult(nextSnapshot.txLookup); + setLoadError(null); + } catch (error) { + setLoadError(error instanceof Error ? error.message : "Bridge control-plane status load failed."); + } finally { + setLoading(false); + } + }, [controlPlaneUrl]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const status = snapshot?.status ?? DEFAULT_STATUS; + const credits = snapshot?.credits ?? []; + const deposits = snapshot?.deposits ?? []; + const accounts = useMemo(() => candidateBridgeAccounts(status, credits), [status, credits]); + const creditTxHashes = useMemo(() => uniqueCreditTxHashes(credits, status.baseTxHash), [credits, status.baseTxHash]); + const fromAccount = status.creditedAccount ?? ""; + const spendableBalance = status.spendableBalance ?? "0"; + const tokenId = status.tokenId ?? "local-test-unit"; + const readinessLabel = text(status.readinessLabel, "NOT READY"); + const exposureLabel = text(status.exposureLabel, status.localOnly ? "LOCAL ONLY" : "external exposure unknown"); + const selectedRecipient = + recipientMode === "manual" + ? manualRecipient.trim() + : recipientMode === "generated" + ? generatedRecipient + : selectedAccount; + const recipientValid = isUsableFlowchainRecipient(selectedRecipient); + const canPrepareLockNative = recipientValid && operatorConfirmed && metadataHash.match(/^0x[0-9a-fA-F]{64}$/) !== null; + const canTransfer = + isUsableFlowchainRecipient(fromAccount) + && isUsableFlowchainRecipient(transferTo) + && fromAccount !== transferTo + && withinBalance(transferAmount, spendableBalance); + + useEffect(() => { + if (accounts.length > 0 && selectedAccount.length === 0) { + setSelectedAccount(accounts[0]); + } + }, [accounts, selectedAccount]); + + useEffect(() => { + if (lookupTxHash.length === 0 && status.baseTxHash) { + setLookupTxHash(status.baseTxHash); + } + }, [lookupTxHash, status.baseTxHash]); + + const generateDepositRecipient = () => { + const account = generateFlowchainAccount(); + setGeneratedRecipient(account); + setRecipientMode("generated"); + setOperatorConfirmed(false); + setLockNativeDraft(null); + setDraftError(null); + }; + + const generateTransferRecipient = () => { + setTransferTo(generateFlowchainAccount()); + setTransferResult(null); + setTransferError(null); + }; + + const prepareLockNative = () => { + setDraftError(null); + try { + const draft = buildLockNativeDraft(selectedRecipient, metadataHash); + setLockNativeDraft(draft); + } catch (error) { + setLockNativeDraft(null); + setDraftError(error instanceof Error ? error.message : "Unable to prepare lockNative call."); + } + }; + + const runLookup = async () => { + setLookupError(null); + setLookupResult(null); + try { + const credit = await lookupBridgeCreditByTxHash(controlPlaneUrl, lookupTxHash.trim()); + setLookupResult(credit); + } catch (error) { + setLookupError(error instanceof Error ? error.message : "Bridge credit lookup failed."); + } + }; + + const runTransfer = async () => { + setTransferPending(true); + setTransferError(null); + setTransferResult(null); + try { + const result = await sendBridgeCreditTransfer(controlPlaneUrl, { + from: fromAccount, + to: transferTo.trim(), + amount: transferAmount.trim(), + tokenId, + memo: "dashboard-bridge-credit-transfer-test", + }); + setTransferResult(result); + await refresh(); + } catch (error) { + setTransferError(error instanceof Error ? error.message : "Transfer send failed."); + } finally { + setTransferPending(false); + } + }; + + return ( +
+ +
+ ); +} diff --git a/docs/FLOWCHAIN_CONTROL_PLANE_API.md b/docs/FLOWCHAIN_CONTROL_PLANE_API.md index d8f2697c..9b018cfe 100644 --- a/docs/FLOWCHAIN_CONTROL_PLANE_API.md +++ b/docs/FLOWCHAIN_CONTROL_PLANE_API.md @@ -1,10 +1,30 @@ # FlowChain Local Control Plane API -Status: local runtime/fixture-backed V0 contract. +Status: private/local L1-shaped control-plane contract. -This document defines the local JSON-RPC 2.0 API for the FlowChain / FlowMemory control-plane. It gives dashboard, agent, verifier, and devnet tooling one deterministic local surface for FlowMemory objects, local runtime status, local file-backed transaction intake, and bridge-observation intake. +This document defines the local JSON-RPC 2.0 API for the FlowChain / FlowMemory control-plane. It gives dashboard, wallet, bridge, agent, verifier, and devnet tooling one deterministic local surface for FlowMemory objects, local runtime status, local file-backed signed transaction intake, bridge intake, and L1-style inspection. -It is not a production RPC endpoint, public L1 API, hosted service, wallet API, production bridge API, production token API, or verifier economics surface. +It is still local/private and no-value unless a later gate explicitly changes that. It is not a hosted public RPC, public validator network, production bridge, custody surface, production tokenomics system, or verifier economics surface. + +Every JSON-RPC result includes `responseProvenance`: + +```json +{ + "schema": "flowmemory.control_plane.response_provenance.v1", + "apiVersion": "flowchain-control-plane-production-l1.v1", + "method": "chain_status", + "runtimeSource": "live|imported|deterministic_fixture|unavailable", + "storageSource": "live|imported|deterministic_fixture|unavailable", + "indexerSource": "live|imported|deterministic_fixture|unavailable", + "bridgeSource": "live|imported|deterministic_fixture|unavailable" +} +``` + +The schema catalog is published at: + +```text +schemas/flowmemory/control-plane-production-l1.schema.json +``` ## Runtime Boundary @@ -86,11 +106,17 @@ Error: "jsonrpc": "2.0", "id": "1", "error": { - "code": -32602, - "message": "rootfield_get requires one of: rootfieldId", + "code": -32041, + "message": "signed envelope signature verification failed", "data": { - "schema": "flowmemory.control_plane.error.v0", - "reasonCode": "params.invalid", + "schema": "flowmemory.control_plane.error.v1", + "reasonCode": "transaction.bad_signature", + "errorCode": "BAD_SIGNATURE", + "message": "signed envelope signature verification failed", + "correlationId": "control-plane-local", + "recoverable": true, + "retryable": false, + "sourceComponent": "control-plane", "localOnly": true } } @@ -107,6 +133,18 @@ Error codes: | `-32602` | Missing or invalid params. | | `-32603` | Internal local control-plane error. | | `-32004` | Requested local object was not found. | +| `-32040` | Secret-shaped request or response material was rejected. | +| `-32041` | Signed transaction or bridge replay was rejected. | +| `-32042` | Live runtime is unavailable. | +| `-32043` | Storage source is unavailable. | + +Machine-readable `errorCode` values: + +`MALFORMED_REQUEST`, `UNSIGNED_TRANSACTION`, `BAD_SIGNATURE`, +`WRONG_CHAIN_ID`, `STALE_NONCE`, `DUPLICATE_TX`, `UNKNOWN_BLOCK`, +`UNKNOWN_TX`, `UNKNOWN_ACCOUNT`, `UNKNOWN_TOKEN`, `UNKNOWN_POOL`, +`BRIDGE_REPLAY`, `LIVE_RUNTIME_UNAVAILABLE`, `STORAGE_UNAVAILABLE`, and +`UNSAFE_SECRET_DETECTED`. ## Methods @@ -128,6 +166,11 @@ Browser-safe summary endpoints are also available: GET /explorer/summary GET /product-flow/status GET /pilot/status +GET /bridge/status +GET /bridge/deposits?limit=50 +GET /bridge/credits?limit=50 +GET /bridge/credit-status +POST /transfer/send ``` ### `chain_status` @@ -178,6 +221,12 @@ Params: Returns local/private peer inventory when present. Current single-node mode returns local-only peer rows or an empty local list; it does not imply public validators. +### `sync_status` + +Params: none. + +Returns current height, target height, finalized height, catch-up state, live runtime availability, and whether fallback state was used. + ### `block_list` Params: @@ -239,9 +288,18 @@ Params: ```json { "signedEnvelope": { - "schema": "flowchain.local_transaction_envelope.v0", - "tx": { - "schema": "flowchain.local_transaction.v0" + "schema": "flowchain.signed_transaction_envelope.v1", + "chainId": "flowmemory-local-devnet-v0", + "signer": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "nonce": "0", + "signatureScheme": "flowchain-local-digest-v1", + "payload": { + "schema": "flowchain.transaction.transfer.v1", + "type": "transfer", + "from": "account:alice", + "to": "account:bob", + "tokenId": "local-test-unit", + "amount": "7" }, "signature": "0x..." }, @@ -249,7 +307,47 @@ Params: } ``` -Accepts signed local test transaction envelopes only. Plain `transaction`, `tx`, or `txs` params are rejected. The method rejects secret-shaped material and appends an intake row to `devnet/local/intake/transactions.ndjson`. It does not broadcast to a public chain. +Accepts only versioned FlowChain signed transaction envelopes. Plain `transaction`, `tx`, or `txs` params are rejected. The method checks chain id, signer format, nonce, payload schema, duplicate tx id, and local deterministic signature digest. It rejects secret-shaped material and appends an accepted intake row plus local receipt to `devnet/local/intake/transactions.ndjson`. It does not broadcast to a public chain. + +Structured rejection codes include `UNSIGNED_TRANSACTION`, `BAD_SIGNATURE`, +`WRONG_CHAIN_ID`, `STALE_NONCE`, `DUPLICATE_TX`, and +`UNSAFE_SECRET_DETECTED`. + +### `transfer_send` + +Params: + +```json +{ + "from": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "to": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "amount": "2", + "tokenId": "flowchain-bridge-credit", + "memo": "dashboard-bridge-credit-transfer-test" +} +``` + +Creates a deterministic local signed transfer envelope for an already credited +FlowChain account, submits it through the same local transaction intake path as +`transaction_submit`, and returns a machine-readable receipt: + +```json +{ + "schema": "flowmemory.control_plane.transfer_send_result.v1", + "accepted": true, + "status": "accepted_local", + "receipt": { + "schema": "flowmemory.control_plane.transfer_receipt.v1", + "status": "accepted_local" + }, + "noBaseReleaseBroadcast": true, + "localOnly": true +} +``` + +The method refuses the placeholder `0x5555...5555` FlowChain recipient, checks +spendable local balance before accepting, and never broadcasts a Base release +transaction. ### `mempool_list` @@ -261,6 +359,30 @@ Params: Returns pending local transaction/intake rows. +### `event_list`, `event_get` + +Event methods expose FlowPulse observations, rejected logs, and local +transaction-intake events. + +List params: + +```json +{ + "blockNumber": "123457", + "blockHash": "0x...", + "txId": "0x...", + "accountId": "0x...", + "eventType": "ROOT_COMMITTED", + "limit": 50 +} +``` + +Get params: + +```json +{ "eventId": "0x..." } +``` + ### `account_list` Params: @@ -289,7 +411,11 @@ Params: { "accountId": "local-balance:demo:agent-alpha" } ``` -Returns a no-value local test-unit balance record. This is not a token balance, reward, fee account, or bridge asset. +Returns the local spendable balance for an account. When an applied bridge +credit exists for the account, the response includes `spendableBalance`, +`bridgeCreditAmount`, `pendingAcceptedDelta`, and `valueBearingPilot`. Without +an applied Base 8453 credit, the balance remains a no-value local test-unit +record. ### `token_list` @@ -868,6 +994,12 @@ Params: All params are optional. Returns native finality receipts when present and projected local finality rows for launch-core receipts. +### `finality_status` + +Params: none. + +Returns chain-level finalized height/hash, finality row counts, and whether finality state is live local runtime state or degraded fallback state. + ### `bridge_observation_list` Params: @@ -903,6 +1035,7 @@ Params: ``` Rejects secret-shaped material and writes an ignored local intake row to `devnet/local/intake/bridge-observations.ndjson`. +Duplicate bridge replay keys are rejected with `BRIDGE_REPLAY`. HTTP bridge observation endpoints are also available: @@ -915,9 +1048,84 @@ POST /bridge/observations Expose local bridge-deposit test objects. These do not imply a production bridge, withdrawal, lockbox, or asset claim. -### `bridge_credit_list`, `bridge_credit_get` +### `bridge_credit_list`, `bridge_credit_get`, `bridge_credit_status` + +`bridge_credit_list` exposes local bridge-credit rows from runtime/control-plane +handoff maps or bridge-deposit projections. + +`bridge_credit_get` accepts `creditId`, `depositId`, `accountId`, +`flowchainAccount`, `txHash`, or `baseTxHash`. When multiple rows match the same +Base transaction, the applied runtime credit is preferred over projected +artifacts. + +`bridge_credit_status` accepts the same lookup aliases and returns the live +wallet/dashboard status panel data: + +```json +{ + "schema": "flowmemory.control_plane.bridge_credit_status.v1", + "readinessLabel": "LIVE PILOT|LOCAL ONLY|NOT READY", + "exposureLabel": "LOCAL ONLY", + "baseTxHash": "0x...", + "confirmationStatus": "base_observed", + "lifecycleStatus": { + "observed": "observed", + "queued": "queued", + "applied": "applied", + "idempotent": "unique_or_idempotent" + }, + "creditedAccount": "0x...", + "spendableBalance": "10", + "transferActionStatus": "not_run|accepted_local", + "firstUsableAt": "2026-05-14T12:00:02.000Z", + "latencyMs": 2000, + "noBaseReleaseBroadcast": true, + "localOnly": true +} +``` + +`LIVE PILOT` is only returned when the running local node/control-plane state has +an applied Base chain ID `8453` credit to a non-placeholder FlowChain account. +Fixture, mock, Base Sepolia, or placeholder-recipient fallback is labeled +`NOT READY`; an unexposed local control-plane remains `LOCAL ONLY`. + +### `bridge_config_get`, `bridge_status` + +`bridge_config_get` returns browser-safe bridge mode, cap summary, pause status, +replay-protection counts, and runtime-intake readiness without exposing env +values. + +`bridge_status` returns bridge readiness, observation/credit/withdrawal/release +counts, replay status, and `envValuesExposed: false`. + +Browser-safe bridge HTTP mirrors are also available: + +```text +GET /bridge/status +GET /bridge/deposits?limit=50 +GET /bridge/credits?limit=50 +GET /bridge/credit-status?txHash=0x... +GET /bridge/credit-status?accountId=0x... +POST /transfer/send +``` + +### `withdrawal_intent_list`, `withdrawal_intent_get` + +Expose local bridge withdrawal-intent test objects. These are aliases over the +same source rows as `withdrawal_list` with dashboard-oriented field names. + +### `release_evidence_list`, `release_evidence_get` + +Expose local release-evidence records from bridge runtime handoffs. If no +release evidence exists but a withdrawal intent exists, the API returns an +explicit `pending_operator_release_evidence` projection so the dashboard can +show the missing step without parsing logs. + +### `replay_rejection_list`, `replay_rejection_get` -Expose local bridge-credit test objects from runtime/control-plane handoff maps or bridge-deposit projections. These are no-value local accounting objects only. +Expose duplicate replay rejections when present. If there are no duplicates, the +list returns an explicit `idempotent_no_duplicate` record for dashboard and +relayer readiness checks. ### `withdrawal_list`, `withdrawal_get` @@ -980,8 +1188,8 @@ Returns the raw loaded local JSON object for dashboard/workbench debug views. It Dashboard agents should prefer: -1. `health`, `node_status`, and `chain_status` for source health and global counters. -2. `block_list`, `transaction_list`, and `mempool_list` for chain/devnet tables. +1. `health`, `node_status`, `sync_status`, `chain_status`, and `finality_status` for source health and global counters. +2. `block_list`, `transaction_list`, `event_list`, and `mempool_list` for chain/devnet tables. 3. `account_list`, `balance_get`, `faucet_event_list`, and `wallet_metadata_list` for local identity and public metadata panels. 4. `rootfield_list` and `rootfield_get` for Rootfield detail. 5. `work_receipt_list`, `receipt_list`, `verifier_module_list`, and `verifier_report_list` for lifecycle tables. @@ -990,7 +1198,8 @@ Dashboard agents should prefer: 8. `challenge_get`, `challenge_list`, `finality_get`, and `finality_list` for local challenge/finality labels. 9. `token_list`, `token_balance_list`, `pool_list`, `lp_position_list`, `swap_list`, and `product_flow_status` for product-testnet token/DEX/explorer panels. 10. `pilot_status`, the four pilot list methods, and the four pilot status methods for capped owner-testing real-value pilot evidence. -11. `bridge_observation_list`, `bridge_deposit_list`, `bridge_credit_list`, and `withdrawal_list` for local bridge-shaped test panels. -12. `raw_json_get` for raw JSON inspection. +11. `bridge_config_get`, `bridge_status`, `bridge_credit_status`, `bridge_observation_list`, `bridge_deposit_list`, `bridge_credit_list`, `withdrawal_intent_list`, `release_evidence_list`, and `replay_rejection_list` for bridge-shaped operator panels. +12. `bridge_credit_get` by `txHash` or `baseTxHash`, `balance_get`, and `transfer_send` for wallet/dashboard credit lookup and local transfer receipt checks. +13. `raw_json_get` for raw JSON inspection. The API is local-only for V0. The submit methods are local file intake, not public chain broadcast. Live indexing, production settlement, production wallet custody, and production bridge methods require separate scoped work. diff --git a/docs/agent-runs/production-l1-rpc/API_SURFACE.md b/docs/agent-runs/production-l1-rpc/API_SURFACE.md new file mode 100644 index 00000000..9e85832b --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/API_SURFACE.md @@ -0,0 +1,35 @@ +# API Surface + +The existing `services/control-plane/` service remains the only API service. All private/local L1-shaped capabilities are JSON-RPC 2.0 methods on `/rpc`; browser-safe HTTP mirrors are limited to existing health, state, explorer, product-flow, bridge-observation, and pilot routes. + +## Schema Catalog + +Published schema catalog: + +```text +schemas/flowmemory/control-plane-production-l1.schema.json +``` + +The catalog lists every method, request schema, response schema, and result schema. The complete endpoint matrix with source component and smoke coverage is in `docs/agent-runs/production-l1-rpc/ENDPOINT_MATRIX.md`. + +## Source Components + +| Component | Paths | Provenance values | +| --- | --- | --- | +| Runtime | `devnet/local/state.json`, `devnet/local/launch-v0-state.json`, `fixtures/launch-core/generated/devnet/state.json` | `live`, `imported`, `deterministic_fixture`, `unavailable` | +| Storage/handoff | `fixtures/launch-core/generated/devnet/control-plane-handoff.json` | `imported`, `deterministic_fixture`, `unavailable` | +| Indexer | `services/indexer/out/indexer-state.json` or in-memory fixture recovery | `imported`, `deterministic_fixture` | +| Verifier | `services/verifier/out/reports.json` or in-memory fixture recovery | `imported`, `deterministic_fixture` | +| Bridge | `services/bridge-relayer/out/bridge-observation.json`, `fixtures/bridge/local-runtime-bridge-handoff.json`, bridge intake NDJSON | `imported`, `deterministic_fixture`, `unavailable` | +| Intake | `devnet/local/intake/transactions.ndjson`, `devnet/local/intake/bridge-observations.ndjson` | `local-file-intake` | + +Every result includes `responseProvenance` so dashboards can show fallback state explicitly. + +## Method Groups + +- Status: `health`, `node_status`, `peer_list`, `sync_status`, `chain_status`, `finality_status`. +- Chain data: `block_list`, `block_get`, `transaction_list`, `transaction_get`, `transaction_submit`, `event_list`, `event_get`, `mempool_list`, `receipt_list`, `receipt_get`. +- Accounts and products: `account_list`, `account_get`, `balance_get`, `token_list`, `token_get`, `token_balance_list`, `token_balance_get`, `pool_list`, `pool_get`, `lp_position_list`, `lp_position_get`, `swap_list`, `swap_get`, `product_flow_status`, `faucet_event_list`, `wallet_metadata_list`, `wallet_metadata_get`. +- Flow Memory: `rootfield_list`, `rootfield_get`, `agent_list`, `agent_get`, `model_list`, `model_get`, `work_receipt_list`, `work_receipt_get`, `artifact_get`, `artifact_availability_list`, `artifact_availability_get`, `verifier_module_list`, `verifier_module_get`, `verifier_report_list`, `verifier_report_get`, `memory_cell_list`, `memory_cell_get`, `challenge_list`, `challenge_get`, `finality_list`, `finality_get`, `provenance_get`, `raw_json_get`. +- Bridge: `bridge_config_get`, `bridge_status`, `bridge_observation_list`, `bridge_observation_get`, `bridge_observation_submit`, `bridge_deposit_list`, `bridge_deposit_get`, `bridge_credit_list`, `bridge_credit_get`, `withdrawal_intent_list`, `withdrawal_intent_get`, `release_evidence_list`, `release_evidence_get`, `replay_rejection_list`, `replay_rejection_get`, `withdrawal_list`, `withdrawal_get`. +- Pilot compatibility: `pilot_status`, `pilot_deposit_observation_list`, `pilot_credit_list`, `pilot_withdrawal_intent_list`, `pilot_release_evidence_list`, `pilot_cap_status`, `pilot_pause_status`, `pilot_retry_status`, `pilot_emergency_status`. diff --git a/docs/agent-runs/production-l1-rpc/BRIDGE_API_PROOF.md b/docs/agent-runs/production-l1-rpc/BRIDGE_API_PROOF.md new file mode 100644 index 00000000..46af2304 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/BRIDGE_API_PROOF.md @@ -0,0 +1,61 @@ +# Bridge API Proof + +The bridge API loop is exposed through the existing control-plane JSON-RPC service. + +## Covered Methods + +- `bridge_config_get` +- `bridge_status` +- `bridge_observation_list` +- `bridge_observation_get` +- `bridge_observation_submit` +- `bridge_deposit_list` +- `bridge_deposit_get` +- `bridge_credit_list` +- `bridge_credit_get` +- `withdrawal_intent_list` +- `withdrawal_intent_get` +- `release_evidence_list` +- `release_evidence_get` +- `replay_rejection_list` +- `replay_rejection_get` +- compatibility aliases: `withdrawal_list`, `withdrawal_get` + +## Smoke Evidence + +`npm run control-plane:smoke` calls every method above. It submits a safe mock bridge observation with a unique replay key, then queries it back by `observationId`. + +Bridge status fields: + +- `readiness` +- `bridgeSource` +- `observationCount` +- `creditCount` +- `withdrawalIntentCount` +- `releaseEvidenceCount` +- `replayRejectionCount` +- `envValuesExposed: false` +- `lastError` + +Bridge config fields: + +- `mode` +- `productionReady: false` +- `cappedOwnerTesting: true` +- `pauseStatus` +- `pilotCaps` +- `replayProtection` +- `runtimeIntake` +- `source` + +Release evidence behavior: + +- If bridge runtime handoff includes release evidence, it is returned directly. +- If withdrawal intent exists but no release record exists, `release_evidence_list` returns an explicit `pending_operator_release_evidence` projection. + +Replay behavior: + +- Duplicate replay keys submitted to `bridge_observation_submit` are rejected with `BRIDGE_REPLAY`. +- If no duplicate exists, `replay_rejection_list` returns an explicit `idempotent_no_duplicate` row. + +No bridge response exposes RPC URLs, private keys, env maps, API keys, webhooks, or vault contents. diff --git a/docs/agent-runs/production-l1-rpc/CHECKLIST.md b/docs/agent-runs/production-l1-rpc/CHECKLIST.md new file mode 100644 index 00000000..00009ac9 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/CHECKLIST.md @@ -0,0 +1,30 @@ +# Private/Local L1-Shaped RPC Checklist + +- [x] Read `AGENTS.md`. +- [x] Read `docs/START_HERE.md`. +- [x] Read `docs/FLOWMEMORY_HQ_CONTEXT.md`. +- [x] Read `docs/CURRENT_STATE.md`. +- [x] Read `docs/FLOWCHAIN_CONTROL_PLANE_API.md`. +- [x] Read `docs/ROOTFLOW_V0.md`. +- [x] Read `docs/FLOW_MEMORY_V0.md`. +- [x] Read `docs/V0_LAUNCH_ACCEPTANCE.md`. +- [x] Inventory `services/control-plane/`. +- [x] Inventory `services/shared/`. +- [x] Inventory `schemas/flowmemory/`. +- [x] Locate production protocol/runtime/storage/bridge/execution handoffs if present. +- [x] Draft endpoint matrix. +- [x] Add schemas for every request and response. +- [x] Add versioned error envelope. +- [x] Add provenance fields. +- [x] Implement signed transaction submit validation. +- [x] Implement transaction query and receipt query loop. +- [x] Implement event/account/balance/token/pool/swap/finality/sync queries. +- [x] Implement bridge query loop and readiness status. +- [x] Add response schema validation in smoke. +- [x] Add no-secret scanner coverage for every route. +- [x] Run `npm test --prefix services/control-plane`. +- [x] Run `npm run control-plane:smoke`. +- [x] Run `npm run control-plane:e2e` if added. Not added; standalone smoke and full L1 E2E cover the control-plane route matrix. +- [x] Run `npm run flowchain:l1-e2e`. +- [x] Run `git diff --check`. +- [x] Write proof and handoff artifacts. diff --git a/docs/agent-runs/production-l1-rpc/COMPLETION_AUDIT.md b/docs/agent-runs/production-l1-rpc/COMPLETION_AUDIT.md new file mode 100644 index 00000000..2cfeffc7 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/COMPLETION_AUDIT.md @@ -0,0 +1,35 @@ +# Completion Audit + +## Objective + +Expose the private/local FlowChain L1-shaped surface through the existing `services/control-plane/` API with versioned schemas, response provenance, signed transaction submit/query, bridge/dashboard coverage, no-secret scanning, required proof artifacts, and green command gates. + +## Prompt-To-Artifact Checklist + +| Requirement | Evidence | +| --- | --- | +| Use the existing control-plane API; do not create a second service. | Implementation is in `services/control-plane/src/methods.ts`, `server.ts`, `types.ts`, `errors.ts`, `smoke.ts`, and `transaction-envelope.ts`. No new API service was added. | +| Create tracking files first. | `PLAN.md`, `CHECKLIST.md`, `EXPERIMENTS.md`, and `NOTES.md` exist in this directory. | +| Keep edits in allowed folders. | Final content diff is limited to `services/control-plane/`, `schemas/flowmemory/`, `docs/FLOWCHAIN_CONTROL_PLANE_API.md`, and this run directory. | +| Signed transaction submit validates a versioned signed envelope and rejects unsigned requests. | `transaction-envelope.ts` defines `flowchain.signed_transaction_envelope.v1`; `transaction_submit` rejects unsigned input with `UNSIGNED_TRANSACTION`; tests cover both paths. | +| Submit checks chain ID, signer, nonce, signature, payload schema, duplicate tx, stale nonce, wrong chain, and invalid signature. | `transaction_submit` in `methods.ts`; smoke expected errors are `BAD_SIGNATURE`, `DUPLICATE_TX`, `WRONG_CHAIN_ID`, and `STALE_NONCE`. | +| Query methods cover chain/node/peers/sync/finality/block/tx/receipt/events/account/balance/token/pool/LP/swap/bridge/diagnostic JSON surfaces. | `ENDPOINT_MATRIX.md` lists 91 smoke-covered methods with request schema, response schema, source component, dashboard consumer, and smoke test name. | +| Every route has explicit request and response schemas. | `schemas/flowmemory/control-plane-production-l1.schema.json` publishes the method catalog; smoke validates every success result schema against it. | +| Runtime source of truth prefers live state and marks fallback provenance. | Success responses include `responseProvenance`; `API_SURFACE.md`, `DASHBOARD_CONTRACT.md`, and `FLOWCHAIN_CONTROL_PLANE_API.md` document live/imported/deterministic fixture/unavailable provenance. | +| No-secret scanner covers every smoke response and mapped browser-safe route body. | `NO_SECRET_PROOF.md`; smoke result scanned 91 responses with `findingCount: 0`; the control-plane HTTP test scans `/health`, `/state`, `/explorer/summary`, `/product-flow/status`, `/rpc`, `/bridge/observations`, and `/pilot/*` route bodies. | +| Versioned error envelope and required codes. | `ERROR_MODEL.md`; `errors.ts` emits `flowmemory.control_plane.error.v1` with machine code, message, correlation ID, recoverable, retryable, and source component. | +| At least one signed transaction is submitted or validated and later queried with a receipt and balance/state change. | `SUBMIT_QUERY_PROOF.md` and `SUBMIT_LOOP_PROOF.md`; smoke submits a signed transfer, queries `transaction_get`, `receipt_get`, and recipient `balance_get`. | +| Bridge API loop is covered. | `BRIDGE_API_PROOF.md`; smoke covers bridge config/status, observation, credit, withdrawal intent, release evidence, and replay rejection list/detail paths. | +| Dashboard contract fields are stable and documented. | `DASHBOARD_CONTRACT.md` and `DASHBOARD_FIELD_PROOF.md`. | +| Raw JSON diagnostics are local and safe. | `raw_json_get` is in the schema catalog, matrix, and smoke; responses are no-secret scanned. | +| Required proof artifacts are present. | `API_SURFACE.md`, `ERROR_MODEL.md`, `NO_SECRET_PROOF.md`, `SUBMIT_QUERY_PROOF.md`, `ENDPOINT_MATRIX.md`, `SUBMIT_LOOP_PROOF.md`, `BRIDGE_API_PROOF.md`, `DASHBOARD_FIELD_PROOF.md`, `SCHEMA_VALIDATION_PROOF.md`, `DASHBOARD_CONTRACT.md`, and `HANDOFF.md`. | +| `npm test --prefix services/control-plane` passes. | Passed: 21 tests, 0 failures. | +| `npm run control-plane:smoke` passes and calls every private/local L1-shaped method. | Passed: `methodCount: 91`, `successCount: 87`, `expectedErrorCount: 4`, `noSecretScan.findingCount: 0`. | +| `npm run flowchain:l1-e2e` passes after integration. | Passed full private/local smoke gate, including service, crypto, launch, dashboard, bridge, hardware, and control-plane gates. | +| `git diff --check` passes. | Passed with no whitespace errors; only Windows LF/CRLF warnings were emitted. | + +## Residual Caveats + +- Live long-running runtime state is not present in this worktree, so the API marks fallback provenance instead of claiming live state. +- The signed transaction path uses deterministic local testnet validation, not production custody or audited cryptography. +- Accepted transactions are persisted as local intake rows with immediate local receipts; no public-chain broadcast is implemented or claimed. diff --git a/docs/agent-runs/production-l1-rpc/DASHBOARD_CONTRACT.md b/docs/agent-runs/production-l1-rpc/DASHBOARD_CONTRACT.md new file mode 100644 index 00000000..626277d5 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/DASHBOARD_CONTRACT.md @@ -0,0 +1,189 @@ +# Dashboard Contract + +Dashboard agents should consume stable JSON-RPC fields and should not parse logs or raw files. + +## Global Status + +Methods: `health`, `node_status`, `sync_status`, `chain_status`, `finality_status` + +Fields: + +- `schema` +- `status` +- `chainId` +- `networkName` +- `genesisHash` +- `latestHeight` +- `latestBlockHash` +- `finalizedHeight` +- `finalizedHash` +- `stateRoot` +- `runtimeSource` +- `storageSource` +- `responseProvenance` +- `counts` + +## Node And Peers + +Methods: `node_status`, `peer_list` + +Fields: + +- `nodeId` +- `startTime` +- `uptimeSeconds` +- `dataDirectory` +- `listeningAddresses` +- `peerCount` +- `syncMode` +- `syncTarget` +- `catchUpState` +- `lastError` +- `peers[].peerId` +- `peers[].address` +- `peers[].status` + +## Blocks, Transactions, Receipts, Events + +Methods: `block_list`, `block_get`, `transaction_list`, `transaction_get`, `receipt_get`, `event_list`, `event_get`, `mempool_list` + +Fields: + +- `blocks[].blockNumber` +- `blocks[].blockHash` +- `blocks[].parentHash` +- `blocks[].txIds` +- `blocks[].eventCount` +- `blocks[].receiptCount` +- `blocks[].stateRoot` +- `transactions[].txId` +- `transactions[].status` +- `transactions[].signer` +- `transactions[].nonce` +- `transactions[].payloadSummary` +- `transactions[].receiptRef` +- `receipt.txId` +- `receipt.status` +- `receipt.reason` +- `events[].eventId` +- `events[].eventType` +- `events[].txId` +- `events[].blockNumber` +- `events[].accountId` + +## Accounts And Balances + +Methods: `account_list`, `account_get`, `balance_get`, `wallet_metadata_list`, `wallet_metadata_get` + +Fields: + +- `accounts[].accountId` +- `accounts[].accountType` +- `accounts[].controller` +- `accounts[].rootfieldId` +- `accounts[].walletPublicMetadata` +- `balance.accountId` +- `balance.tokenId` +- `balance.amount` +- `balance.baseAmount` +- `balance.pendingAcceptedDelta` +- `wallets[].walletId` +- `wallets[].publicOnly` + +## Tokens And DEX + +Methods: `token_list`, `token_get`, `token_balance_list`, `token_balance_get`, `pool_list`, `pool_get`, `lp_position_list`, `lp_position_get`, `swap_list`, `swap_get`, `product_flow_status` + +Fields: + +- `tokens[].tokenId` +- `tokens[].symbol` +- `tokens[].name` +- `tokens[].totalSupply` +- `token.holderCount` +- `token.transferHistory` +- `token.launchTransaction` +- `balances[].balanceId` +- `balances[].accountId` +- `balances[].tokenId` +- `balances[].amount` +- `pools[].poolId` +- `pools[].token0` +- `pools[].token1` +- `pools[].reserve0` +- `pools[].reserve1` +- `pools[].lpSupply` +- `positions[].positionId` +- `positions[].poolId` +- `positions[].accountId` +- `positions[].liquidity` +- `swaps[].swapId` +- `swaps[].poolId` +- `swaps[].tokenIn` +- `swaps[].tokenOut` +- `swaps[].amountIn` +- `swaps[].amountOut` +- `stages[].stage` +- `stages[].status` + +## Flow Memory + +Methods: `rootfield_list`, `rootfield_get`, `agent_list`, `agent_get`, `model_list`, `model_get`, `work_receipt_list`, `work_receipt_get`, `artifact_get`, `artifact_availability_list`, `artifact_availability_get`, `verifier_module_list`, `verifier_module_get`, `verifier_report_list`, `verifier_report_get`, `memory_cell_list`, `memory_cell_get`, `challenge_list`, `challenge_get`, `finality_list`, `finality_get`, `provenance_get` + +Fields: + +- `rootfields[].rootfieldId` +- `rootfields[].status` +- `bundle.latestRoot` +- `agents[].agentId` +- `models[].modelId` +- `workReceipts[].receiptId` +- `artifactAvailability[].availabilityId` +- `verifierModules[].moduleId` +- `reports[].reportId` +- `memoryCells[].memoryCellId` +- `challenges[].challengeId` +- `finality[].objectId` +- `finality[].status` +- `provenance.sources[]` +- `provenance.links` + +## Bridge + +Methods: `bridge_config_get`, `bridge_status`, `bridge_observation_list`, `bridge_observation_get`, `bridge_deposit_list`, `bridge_deposit_get`, `bridge_credit_list`, `bridge_credit_get`, `withdrawal_intent_list`, `withdrawal_intent_get`, `release_evidence_list`, `release_evidence_get`, `replay_rejection_list`, `replay_rejection_get`, `pilot_status` + +Fields: + +- `readiness` +- `bridgeSource` +- `envValuesExposed` +- `pilotCaps` +- `replayProtection` +- `runtimeIntake` +- `observations[].observationId` +- `observations[].replayKey` +- `deposits[].depositId` +- `deposits[].sourceChainId` +- `deposits[].txHash` +- `credits[].creditId` +- `credits[].status` +- `withdrawalIntents[].withdrawalIntentId` +- `withdrawalIntents[].status` +- `releaseEvidence[].releaseEvidenceId` +- `releaseEvidence[].status` +- `replayRejections[].replayRejectionId` +- `replayRejections[].status` +- `pilot_status.lifecycle[]` + +## Diagnostics + +Methods: `raw_json_get`, `devnet_state`, `provenance_get` + +Fields: + +- `source` +- `dataSource` +- `raw` +- `responseProvenance` + +Raw JSON is only available through allowlisted local diagnostic sources. diff --git a/docs/agent-runs/production-l1-rpc/DASHBOARD_FIELD_PROOF.md b/docs/agent-runs/production-l1-rpc/DASHBOARD_FIELD_PROOF.md new file mode 100644 index 00000000..f2609c79 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/DASHBOARD_FIELD_PROOF.md @@ -0,0 +1,15 @@ +# Dashboard Field Proof + +The smoke client validates every dashboard-facing method in `DASHBOARD_CONTRACT.md`. + +Evidence from `npm run control-plane:smoke`: + +- chain and node fields: `health`, `node_status`, `sync_status`, `chain_status`, `finality_status`; +- explorer fields: `block_list`, `block_get`, `transaction_list`, `transaction_get`, `event_list`, `event_get`, `receipt_get`; +- account fields: `account_list`, `account_get`, `balance_get`, `wallet_metadata_list`, `wallet_metadata_get`; +- token and DEX fields: token, balance, pool, LP, swap, and product-flow list/detail methods; +- Flow Memory fields: Rootfield, agent, model, receipt, artifact, verifier, memory cell, challenge, finality, and provenance methods; +- bridge fields: config, status, observation, deposit, credit, withdrawal intent, release evidence, replay rejection, and pilot methods; +- diagnostics fields: `raw_json_get` and `devnet_state`. + +No dashboard view needs to parse raw logs. Raw JSON is retained only for explicit local diagnostics and is scanned for secret-shaped material before return. diff --git a/docs/agent-runs/production-l1-rpc/ENDPOINT_MATRIX.md b/docs/agent-runs/production-l1-rpc/ENDPOINT_MATRIX.md new file mode 100644 index 00000000..be9490ab --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/ENDPOINT_MATRIX.md @@ -0,0 +1,91 @@ +# Endpoint Matrix + +All methods are JSON-RPC 2.0 methods on the existing `services/control-plane/` `/rpc` service. HTTP mirrors remain `/health`, `/state`, `/explorer/summary`, `/product-flow/status`, `/bridge/observations`, and `/pilot/*`. + +| Method | Request schema | Response schema | Success | Error | Source component | Dashboard consumer | Smoke case | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `health` | `flowmemory.control_plane.health_request.v1` | `flowmemory.control_plane.health_response.v1` | `flowmemory.control_plane.health.v0` | `flowmemory.control_plane.error.v1` | control-plane loader | status header | `health` | +| `node_status` | `flowmemory.control_plane.node_status_request.v1` | `flowmemory.control_plane.node_status_response.v1` | `flowmemory.control_plane.node_status.v0` | error v1 | runtime/source loader | node view | `node` | +| `peer_list` | `flowmemory.control_plane.peer_list_request.v1` | `flowmemory.control_plane.peer_list_response.v1` | `flowmemory.control_plane.peer_list.v0` | error v1 | runtime peers | peer view | `peers` | +| `sync_status` | `flowmemory.control_plane.sync_status_request.v1` | `flowmemory.control_plane.sync_status_response.v1` | `flowmemory.control_plane.sync_status.v0` | error v1 | runtime/source loader | sync view | `sync` | +| `chain_status` | `flowmemory.control_plane.chain_status_request.v1` | `flowmemory.control_plane.chain_status_response.v1` | `flowmemory.control_plane.chain_status.v0` | error v1 | runtime/indexer/storage | overview | `chain` | +| `finality_status` | `flowmemory.control_plane.finality_status_request.v1` | `flowmemory.control_plane.finality_status_response.v1` | `flowmemory.control_plane.finality_status.v0` | error v1 | runtime/finality | finality view | `finalityStatus` | +| `devnet_state` | `flowmemory.control_plane.devnet_state_request.v1` | `flowmemory.control_plane.devnet_state_response.v1` | `flowmemory.control_plane.devnet_state.v0` | error v1 | runtime/storage | raw/state | `devnet` | +| `block_list` | `flowmemory.control_plane.block_list_request.v1` | `flowmemory.control_plane.block_list_response.v1` | `flowmemory.control_plane.block_list.v0` | error v1 | runtime/indexer | blocks table | `blocks` | +| `block_get` | `flowmemory.control_plane.block_get_request.v1` | `flowmemory.control_plane.block_get_response.v1` | `flowmemory.control_plane.block_detail.v0` | `UNKNOWN_BLOCK` | runtime/indexer | block detail | `block` | +| `transaction_list` | `flowmemory.control_plane.transaction_list_request.v1` | `flowmemory.control_plane.transaction_list_response.v1` | `flowmemory.control_plane.transaction_list.v0` | error v1 | runtime/indexer/intake | tx table | `transactions` | +| `transaction_get` | `flowmemory.control_plane.transaction_get_request.v1` | `flowmemory.control_plane.transaction_get_response.v1` | `flowmemory.control_plane.transaction_detail.v0` | `UNKNOWN_TX` | runtime/indexer/intake | tx detail | `transaction`, `submittedTransaction` | +| `transaction_submit` | `flowmemory.control_plane.transaction_submit_request.v1` | `flowmemory.control_plane.transaction_submit_response.v1` | `flowmemory.control_plane.transaction_submit_result.v1` | tx rejection codes | signed intake | wallet submit | `transactionSubmit`, rejection cases | +| `event_list` | `flowmemory.control_plane.event_list_request.v1` | `flowmemory.control_plane.event_list_response.v1` | `flowmemory.control_plane.event_list.v0` | error v1 | indexer/intake | event stream | `events` | +| `event_get` | `flowmemory.control_plane.event_get_request.v1` | `flowmemory.control_plane.event_get_response.v1` | `flowmemory.control_plane.event_detail.v0` | error v1 | indexer/intake | event detail | `event` | +| `mempool_list` | `flowmemory.control_plane.mempool_list_request.v1` | `flowmemory.control_plane.mempool_list_response.v1` | `flowmemory.control_plane.mempool_list.v0` | error v1 | runtime/intake | mempool | `mempool` | +| `account_list` | `flowmemory.control_plane.account_list_request.v1` | `flowmemory.control_plane.account_list_response.v1` | `flowmemory.control_plane.account_list.v0` | error v1 | runtime/storage | accounts | `accounts` | +| `account_get` | `flowmemory.control_plane.account_get_request.v1` | `flowmemory.control_plane.account_get_response.v1` | `flowmemory.control_plane.account_detail.v0` | `UNKNOWN_ACCOUNT` | runtime/storage | account detail | `account` | +| `balance_get` | `flowmemory.control_plane.balance_get_request.v1` | `flowmemory.control_plane.balance_get_response.v1` | `flowmemory.control_plane.balance.v0` | `UNKNOWN_ACCOUNT` | runtime/intake projection | balances | `balance`, `submittedBalance` | +| `token_list` | `flowmemory.control_plane.token_list_request.v1` | `flowmemory.control_plane.token_list_response.v1` | `flowmemory.control_plane.token_list.v0` | error v1 | runtime/product maps | tokens | `tokens` | +| `token_get` | `flowmemory.control_plane.token_get_request.v1` | `flowmemory.control_plane.token_get_response.v1` | `flowmemory.control_plane.token_detail.v0` | `UNKNOWN_TOKEN` | runtime/product maps | token detail | `token` | +| `token_balance_list` | `flowmemory.control_plane.token_balance_list_request.v1` | `flowmemory.control_plane.token_balance_list_response.v1` | `flowmemory.control_plane.token_balance_list.v0` | error v1 | runtime/product maps | token balances | `tokenBalances` | +| `token_balance_get` | `flowmemory.control_plane.token_balance_get_request.v1` | `flowmemory.control_plane.token_balance_get_response.v1` | `flowmemory.control_plane.token_balance_detail.v0` | error v1 | runtime/product maps | balance detail | `tokenBalance` | +| `pool_list` | `flowmemory.control_plane.pool_list_request.v1` | `flowmemory.control_plane.pool_list_response.v1` | `flowmemory.control_plane.pool_list.v0` | error v1 | runtime/dex maps | pools | `pools` | +| `pool_get` | `flowmemory.control_plane.pool_get_request.v1` | `flowmemory.control_plane.pool_get_response.v1` | `flowmemory.control_plane.pool_detail.v0` | `UNKNOWN_POOL` | runtime/dex maps | pool detail | `pool` | +| `lp_position_list` | `flowmemory.control_plane.lp_position_list_request.v1` | `flowmemory.control_plane.lp_position_list_response.v1` | `flowmemory.control_plane.lp_position_list.v0` | error v1 | runtime/dex maps | LP positions | `lpPositions` | +| `lp_position_get` | `flowmemory.control_plane.lp_position_get_request.v1` | `flowmemory.control_plane.lp_position_get_response.v1` | `flowmemory.control_plane.lp_position_detail.v0` | error v1 | runtime/dex maps | LP detail | `lpPosition` | +| `swap_list` | `flowmemory.control_plane.swap_list_request.v1` | `flowmemory.control_plane.swap_list_response.v1` | `flowmemory.control_plane.swap_list.v0` | error v1 | runtime/dex maps | swaps | `swaps` | +| `swap_get` | `flowmemory.control_plane.swap_get_request.v1` | `flowmemory.control_plane.swap_get_response.v1` | `flowmemory.control_plane.swap_detail.v0` | error v1 | runtime/dex maps | swap detail | `swap` | +| `product_flow_status` | `flowmemory.control_plane.product_flow_status_request.v1` | `flowmemory.control_plane.product_flow_status_response.v1` | `flowmemory.control_plane.product_flow_status.v0` | error v1 | runtime/product maps | product readiness | `productFlowStatus` | +| `faucet_event_list` | `flowmemory.control_plane.faucet_event_list_request.v1` | `flowmemory.control_plane.faucet_event_list_response.v1` | `flowmemory.control_plane.faucet_event_list.v0` | error v1 | runtime/faucet maps | faucet | `faucet` | +| `wallet_metadata_list` | `flowmemory.control_plane.wallet_metadata_list_request.v1` | `flowmemory.control_plane.wallet_metadata_list_response.v1` | `flowmemory.control_plane.wallet_public_metadata_list.v0` | error v1 | public wallet metadata | wallets | `wallets` | +| `wallet_metadata_get` | `flowmemory.control_plane.wallet_metadata_get_request.v1` | `flowmemory.control_plane.wallet_metadata_get_response.v1` | `flowmemory.control_plane.wallet_public_metadata_detail.v0` | error v1 | public wallet metadata | wallet detail | `wallet` | +| `rootfield_list` | `flowmemory.control_plane.rootfield_list_request.v1` | `flowmemory.control_plane.rootfield_list_response.v1` | `flowmemory.control_plane.rootfield_list.v0` | error v1 | launch/runtime | Rootfields | `rootfields` | +| `rootfield_get` | `flowmemory.control_plane.rootfield_get_request.v1` | `flowmemory.control_plane.rootfield_get_response.v1` | `flowmemory.control_plane.rootfield.v0` | error v1 | launch/runtime | Rootfield detail | `rootfield` | +| `agent_list` | `flowmemory.control_plane.agent_list_request.v1` | `flowmemory.control_plane.agent_list_response.v1` | `flowmemory.control_plane.agent_list.v0` | error v1 | launch/runtime | agents | `agents` | +| `agent_get` | `flowmemory.control_plane.agent_get_request.v1` | `flowmemory.control_plane.agent_get_response.v1` | `flowmemory.control_plane.agent.v0` | error v1 | launch/runtime | agent detail | `agent` | +| `model_list` | `flowmemory.control_plane.model_list_request.v1` | `flowmemory.control_plane.model_list_response.v1` | `flowmemory.control_plane.model_list.v0` | error v1 | runtime/model maps | models | `models` | +| `model_get` | `flowmemory.control_plane.model_get_request.v1` | `flowmemory.control_plane.model_get_response.v1` | `flowmemory.control_plane.model_detail.v0` | error v1 | runtime/model maps | model detail | `model` | +| `work_receipt_list` | `flowmemory.control_plane.work_receipt_list_request.v1` | `flowmemory.control_plane.work_receipt_list_response.v1` | `flowmemory.control_plane.work_receipt_list.v0` | error v1 | runtime/launch | work receipts | `workReceipts` | +| `work_receipt_get` | `flowmemory.control_plane.work_receipt_get_request.v1` | `flowmemory.control_plane.work_receipt_get_response.v1` | `flowmemory.control_plane.work_receipt.v0` | error v1 | runtime/launch | receipt detail | `workReceipt` | +| `artifact_get` | `flowmemory.control_plane.artifact_get_request.v1` | `flowmemory.control_plane.artifact_get_response.v1` | `flowmemory.control_plane.artifact.v0` | error v1 | artifact resolver | artifact detail | `artifactResolver` | +| `artifact_availability_list` | `flowmemory.control_plane.artifact_availability_list_request.v1` | `flowmemory.control_plane.artifact_availability_list_response.v1` | `flowmemory.control_plane.artifact_availability_list.v0` | error v1 | runtime/verifier | artifact availability | `artifactAvailability` | +| `artifact_availability_get` | `flowmemory.control_plane.artifact_availability_get_request.v1` | `flowmemory.control_plane.artifact_availability_get_response.v1` | `flowmemory.control_plane.artifact_availability_detail.v0` | error v1 | runtime/verifier | artifact availability detail | `artifact` | +| `verifier_module_list` | `flowmemory.control_plane.verifier_module_list_request.v1` | `flowmemory.control_plane.verifier_module_list_response.v1` | `flowmemory.control_plane.verifier_module_list.v0` | error v1 | runtime/verifier | verifier modules | `modules` | +| `verifier_module_get` | `flowmemory.control_plane.verifier_module_get_request.v1` | `flowmemory.control_plane.verifier_module_get_response.v1` | `flowmemory.control_plane.verifier_module_detail.v0` | error v1 | runtime/verifier | module detail | `module` | +| `verifier_report_list` | `flowmemory.control_plane.verifier_report_list_request.v1` | `flowmemory.control_plane.verifier_report_list_response.v1` | `flowmemory.control_plane.verifier_report_list.v0` | error v1 | verifier | verifier reports | `reports` | +| `verifier_report_get` | `flowmemory.control_plane.verifier_report_get_request.v1` | `flowmemory.control_plane.verifier_report_get_response.v1` | `flowmemory.control_plane.verifier_report.v0` | error v1 | verifier | report detail | `report` | +| `receipt_list` | `flowmemory.control_plane.receipt_list_request.v1` | `flowmemory.control_plane.receipt_list_response.v1` | `flowmemory.control_plane.receipt_list.v0` | error v1 | launch/runtime/intake | receipts | `receipts` | +| `receipt_get` | `flowmemory.control_plane.receipt_get_request.v1` | `flowmemory.control_plane.receipt_get_response.v1` | `flowmemory.control_plane.receipt.v0` | `UNKNOWN_TX` when tx receipt missing | launch/runtime/intake | receipt detail | `receipt`, `submittedReceipt` | +| `memory_cell_list` | `flowmemory.control_plane.memory_cell_list_request.v1` | `flowmemory.control_plane.memory_cell_list_response.v1` | `flowmemory.control_plane.memory_cell_list.v0` | error v1 | runtime/launch | memory cells | `memoryCells` | +| `memory_cell_get` | `flowmemory.control_plane.memory_cell_get_request.v1` | `flowmemory.control_plane.memory_cell_get_response.v1` | `flowmemory.control_plane.memory_cell.v0` | error v1 | runtime/launch | memory cell detail | `memoryCell` | +| `challenge_list` | `flowmemory.control_plane.challenge_list_request.v1` | `flowmemory.control_plane.challenge_list_response.v1` | `flowmemory.control_plane.challenge_list.v0` | error v1 | runtime/launch | challenges | `challenges` | +| `challenge_get` | `flowmemory.control_plane.challenge_get_request.v1` | `flowmemory.control_plane.challenge_get_response.v1` | `flowmemory.control_plane.challenge.v0` | error v1 | runtime/launch | challenge detail | `challenge` | +| `finality_list` | `flowmemory.control_plane.finality_list_request.v1` | `flowmemory.control_plane.finality_list_response.v1` | `flowmemory.control_plane.finality_list.v0` | error v1 | runtime/launch | finality rows | `finalityList` | +| `finality_get` | `flowmemory.control_plane.finality_get_request.v1` | `flowmemory.control_plane.finality_get_response.v1` | `flowmemory.control_plane.finality.v0` | error v1 | runtime/launch | finality detail | `finality` | +| `pilot_status` | `flowmemory.control_plane.pilot_status_request.v1` | `flowmemory.control_plane.pilot_status_response.v1` | `flowmemory.control_plane.real_value_pilot_status.v0` | error v1 | bridge pilot | pilot overview | `pilotStatus` | +| `pilot_deposit_observation_list` | `flowmemory.control_plane.pilot_deposit_observation_list_request.v1` | `flowmemory.control_plane.pilot_deposit_observation_list_response.v1` | `flowmemory.control_plane.real_value_pilot_deposit_observation_list.v0` | error v1 | bridge pilot | pilot deposits | `pilotDeposits` | +| `pilot_credit_list` | `flowmemory.control_plane.pilot_credit_list_request.v1` | `flowmemory.control_plane.pilot_credit_list_response.v1` | `flowmemory.control_plane.real_value_pilot_credit_list.v0` | error v1 | bridge pilot | pilot credits | `pilotCredits` | +| `pilot_withdrawal_intent_list` | `flowmemory.control_plane.pilot_withdrawal_intent_list_request.v1` | `flowmemory.control_plane.pilot_withdrawal_intent_list_response.v1` | `flowmemory.control_plane.real_value_pilot_withdrawal_intent_list.v0` | error v1 | bridge pilot | pilot withdrawals | `pilotWithdrawals` | +| `pilot_release_evidence_list` | `flowmemory.control_plane.pilot_release_evidence_list_request.v1` | `flowmemory.control_plane.pilot_release_evidence_list_response.v1` | `flowmemory.control_plane.real_value_pilot_release_evidence_list.v0` | error v1 | bridge pilot | pilot releases | `pilotReleaseEvidence` | +| `pilot_cap_status` | `flowmemory.control_plane.pilot_cap_status_request.v1` | `flowmemory.control_plane.pilot_cap_status_response.v1` | `flowmemory.control_plane.real_value_pilot_cap_status.v0` | error v1 | bridge pilot | pilot caps | `pilotCapStatus` | +| `pilot_pause_status` | `flowmemory.control_plane.pilot_pause_status_request.v1` | `flowmemory.control_plane.pilot_pause_status_response.v1` | `flowmemory.control_plane.real_value_pilot_pause_status.v0` | error v1 | bridge pilot | pilot pause | `pilotPauseStatus` | +| `pilot_retry_status` | `flowmemory.control_plane.pilot_retry_status_request.v1` | `flowmemory.control_plane.pilot_retry_status_response.v1` | `flowmemory.control_plane.real_value_pilot_retry_status.v0` | error v1 | bridge pilot | pilot retry | `pilotRetryStatus` | +| `pilot_emergency_status` | `flowmemory.control_plane.pilot_emergency_status_request.v1` | `flowmemory.control_plane.pilot_emergency_status_response.v1` | `flowmemory.control_plane.real_value_pilot_emergency_status.v0` | error v1 | bridge pilot | pilot emergency | `pilotEmergencyStatus` | +| `bridge_observation_list` | `flowmemory.control_plane.bridge_observation_list_request.v1` | `flowmemory.control_plane.bridge_observation_list_response.v1` | `flowmemory.control_plane.bridge_observation_list.v0` | error v1 | bridge relayer/intake | bridge observations | `bridgeObservationList` | +| `bridge_observation_get` | `flowmemory.control_plane.bridge_observation_get_request.v1` | `flowmemory.control_plane.bridge_observation_get_response.v1` | `flowmemory.control_plane.bridge_observation.v0` | error v1 | bridge relayer/intake | observation detail | `bridgeObservation`, `submittedBridgeObservation` | +| `bridge_observation_submit` | `flowmemory.control_plane.bridge_observation_submit_request.v1` | `flowmemory.control_plane.bridge_observation_submit_response.v1` | `flowmemory.control_plane.bridge_observation_submit_result.v0` | `BRIDGE_REPLAY` | bridge intake | relayer intake | `bridgeObservationSubmit` | +| `bridge_config_get` | `flowmemory.control_plane.bridge_config_get_request.v1` | `flowmemory.control_plane.bridge_config_get_response.v1` | `flowmemory.control_plane.bridge_config.v0` | error v1 | bridge runtime | bridge config | `bridgeConfig` | +| `bridge_status` | `flowmemory.control_plane.bridge_status_request.v1` | `flowmemory.control_plane.bridge_status_response.v1` | `flowmemory.control_plane.bridge_status.v0` | error v1 | bridge runtime | bridge status | `bridgeStatus` | +| `bridge_deposit_list` | `flowmemory.control_plane.bridge_deposit_list_request.v1` | `flowmemory.control_plane.bridge_deposit_list_response.v1` | `flowmemory.control_plane.bridge_deposit_list.v0` | error v1 | bridge relayer | deposits | `bridgeDeposits` | +| `bridge_deposit_get` | `flowmemory.control_plane.bridge_deposit_get_request.v1` | `flowmemory.control_plane.bridge_deposit_get_response.v1` | `flowmemory.control_plane.bridge_deposit_detail.v0` | error v1 | bridge relayer | deposit detail | `bridgeDeposit` | +| `bridge_credit_list` | `flowmemory.control_plane.bridge_credit_list_request.v1` | `flowmemory.control_plane.bridge_credit_list_response.v1` | `flowmemory.control_plane.bridge_credit_list.v0` | error v1 | bridge/runtime | credits | `bridgeCredits` | +| `bridge_credit_get` | `flowmemory.control_plane.bridge_credit_get_request.v1` | `flowmemory.control_plane.bridge_credit_get_response.v1` | `flowmemory.control_plane.bridge_credit_detail.v0` | error v1 | bridge/runtime | credit detail | `bridgeCredit` | +| `withdrawal_intent_list` | `flowmemory.control_plane.withdrawal_intent_list_request.v1` | `flowmemory.control_plane.withdrawal_intent_list_response.v1` | `flowmemory.control_plane.withdrawal_intent_list.v0` | error v1 | bridge/runtime | withdrawal intents | `withdrawalIntents` | +| `withdrawal_intent_get` | `flowmemory.control_plane.withdrawal_intent_get_request.v1` | `flowmemory.control_plane.withdrawal_intent_get_response.v1` | `flowmemory.control_plane.withdrawal_intent_detail.v0` | error v1 | bridge/runtime | withdrawal detail | `withdrawalIntent` | +| `release_evidence_list` | `flowmemory.control_plane.release_evidence_list_request.v1` | `flowmemory.control_plane.release_evidence_list_response.v1` | `flowmemory.control_plane.release_evidence_list.v0` | error v1 | bridge/runtime | release evidence | `releaseEvidenceList` | +| `release_evidence_get` | `flowmemory.control_plane.release_evidence_get_request.v1` | `flowmemory.control_plane.release_evidence_get_response.v1` | `flowmemory.control_plane.release_evidence_detail.v0` | error v1 | bridge/runtime | release detail | `releaseEvidence` | +| `replay_rejection_list` | `flowmemory.control_plane.replay_rejection_list_request.v1` | `flowmemory.control_plane.replay_rejection_list_response.v1` | `flowmemory.control_plane.replay_rejection_list.v0` | error v1 | bridge/runtime | replay checks | `replayRejectionList` | +| `replay_rejection_get` | `flowmemory.control_plane.replay_rejection_get_request.v1` | `flowmemory.control_plane.replay_rejection_get_response.v1` | `flowmemory.control_plane.replay_rejection_detail.v0` | error v1 | bridge/runtime | replay detail | `replayRejection` | +| `withdrawal_list` | `flowmemory.control_plane.withdrawal_list_request.v1` | `flowmemory.control_plane.withdrawal_list_response.v1` | `flowmemory.control_plane.withdrawal_list.v0` | error v1 | bridge/runtime | withdrawal compatibility | `withdrawals` | +| `withdrawal_get` | `flowmemory.control_plane.withdrawal_get_request.v1` | `flowmemory.control_plane.withdrawal_get_response.v1` | `flowmemory.control_plane.withdrawal_detail.v0` | error v1 | bridge/runtime | withdrawal compatibility detail | `withdrawal` | +| `provenance_get` | `flowmemory.control_plane.provenance_get_request.v1` | `flowmemory.control_plane.provenance_get_response.v1` | `flowmemory.control_plane.provenance.v0` | error v1 | provenance linker | source detail | `provenance` | +| `raw_json_get` | `flowmemory.control_plane.raw_json_get_request.v1` | `flowmemory.control_plane.raw_json_get_response.v1` | `flowmemory.control_plane.raw_json.v0` | error v1 | safe raw local loader | local raw JSON | `raw` | + +The smoke client also validates these expected error cases: `invalidSignature` (`BAD_SIGNATURE`), `duplicateTransaction` (`DUPLICATE_TX`), `wrongChain` (`WRONG_CHAIN_ID`), and `staleNonce` (`STALE_NONCE`). diff --git a/docs/agent-runs/production-l1-rpc/ERROR_MODEL.md b/docs/agent-runs/production-l1-rpc/ERROR_MODEL.md new file mode 100644 index 00000000..db4496c8 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/ERROR_MODEL.md @@ -0,0 +1,52 @@ +# Error Model + +All methods use the versioned JSON-RPC error data envelope: + +```json +{ + "schema": "flowmemory.control_plane.error.v1", + "reasonCode": "transaction.bad_signature", + "errorCode": "BAD_SIGNATURE", + "message": "signed envelope signature verification failed", + "correlationId": "control-plane-local", + "recoverable": true, + "retryable": false, + "sourceComponent": "control-plane", + "localOnly": true +} +``` + +Raw exception text is replaced or redacted if it contains secret-shaped material. + +| Code | JSON-RPC code | Recoverable | Retryable | Tested route or smoke case | +| --- | ---: | --- | --- | --- | +| `MALFORMED_REQUEST` | `-32600` or `-32602` | yes | no | malformed JSON-RPC request, invalid limits, bad raw JSON source | +| `UNSIGNED_TRANSACTION` | `-32041` | yes | no | `transaction_submit` with plain `transaction` | +| `BAD_SIGNATURE` | `-32041` | yes | no | `invalidSignature` smoke case | +| `WRONG_CHAIN_ID` | `-32041` | yes | no | `wrongChain` smoke case | +| `STALE_NONCE` | `-32041` | yes | no | `staleNonce` smoke case | +| `DUPLICATE_TX` | `-32041` | yes | no | `duplicateTransaction` smoke case | +| `UNKNOWN_BLOCK` | `-32004` | yes | no | `block_get` not-found path | +| `UNKNOWN_TX` | `-32004` | yes | no | `transaction_get` or `receipt_get` tx not-found path | +| `UNKNOWN_ACCOUNT` | `-32004` | yes | no | `account_get` or `balance_get` not-found path | +| `UNKNOWN_TOKEN` | `-32004` | yes | no | `token_get` not-found path | +| `UNKNOWN_POOL` | `-32004` | yes | no | `pool_get` not-found path | +| `BRIDGE_REPLAY` | `-32041` | yes | no | `bridge_observation_submit` duplicate replay-key path | +| `LIVE_RUNTIME_UNAVAILABLE` | `-32042` | yes | yes | available constructor; degraded runtime is represented in `sync_status` provenance | +| `STORAGE_UNAVAILABLE` | `-32043` | yes | yes | available constructor; degraded storage is represented in `responseProvenance` | +| `UNSAFE_SECRET_DETECTED` | `-32040` | no | no | secret-shaped transaction, bridge intake, and raw response tests | + +Smoke expected error cases: + +- `invalidSignature` -> `BAD_SIGNATURE` +- `duplicateTransaction` -> `DUPLICATE_TX` +- `wrongChain` -> `WRONG_CHAIN_ID` +- `staleNonce` -> `STALE_NONCE` + +Unit test error cases: + +- malformed request and bad params; +- unknown method; +- unsigned transaction; +- secret-shaped intake and response material; +- secret-shaped bridge/pilot-adjacent material. diff --git a/docs/agent-runs/production-l1-rpc/EXPERIMENTS.md b/docs/agent-runs/production-l1-rpc/EXPERIMENTS.md new file mode 100644 index 00000000..ebcb1ef1 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/EXPERIMENTS.md @@ -0,0 +1,29 @@ +# Private/Local L1-Shaped RPC Experiments + +## Command Log + +Commands and outcomes will be recorded here as the implementation is verified. + +| Command | Outcome | Notes | +| --- | --- | --- | +| `npm test --prefix services/control-plane` | Passed | 21 tests passed after signed-envelope, schema, bridge, event, sync, and error-envelope updates; re-run after generated fixture cleanup. | +| `npm run control-plane:smoke` | Passed | 91 JSON-RPC calls, 87 successful responses, 4 expected structured submit rejections, 0 no-secret findings; re-run after generated fixture cleanup. | +| `npm run flowchain:l1-e2e` | Passed | Full private/local smoke passed, including service tests, crypto/vector checks, launch candidate gate, dashboard build, bridge local-credit smoke, hardware smoke, and control-plane smoke. | +| `git diff --check` | Passed | No whitespace errors reported; Git emitted Windows LF/CRLF warnings only. | + +## Smoke Coverage Notes + +The smoke client must call every private/local L1-shaped method and scan every response for private keys, seed phrases, mnemonics, RPC credentials, API keys, webhook-shaped text, and unsafe raw environment data. + +## Submit/Query Loop Notes + +The final proof must include: + +- signed transfer submission: covered by `transaction_submit` smoke with `flowchain.signed_transaction_envelope.v1`; +- transaction query by ID: covered by `transaction_get` for the submitted `txId`; +- receipt query by transaction ID: covered by `receipt_get` with `{ txId }`; +- account balance query after execution or explicit rejected receipt: covered by `balance_get` for the unique per-run `account:submit:bob:*`, amount `7`; +- invalid signature rejection: covered by `BAD_SIGNATURE`; +- duplicate transaction rejection: covered by `DUPLICATE_TX`; +- wrong-chain rejection: covered by `WRONG_CHAIN_ID`; +- stale nonce rejection: covered by `STALE_NONCE`. diff --git a/docs/agent-runs/production-l1-rpc/HANDOFF.md b/docs/agent-runs/production-l1-rpc/HANDOFF.md new file mode 100644 index 00000000..6d283be2 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/HANDOFF.md @@ -0,0 +1,93 @@ +# Handoff + +## Scope Completed + +The existing `services/control-plane/` JSON-RPC API now exposes the private/local L1-shaped FlowChain surface without creating a second API service. + +## Key Files + +- API handlers: `services/control-plane/src/methods.ts` +- Error envelope: `services/control-plane/src/errors.ts` +- Signed envelope helper: `services/control-plane/src/transaction-envelope.ts` +- Smoke client: `services/control-plane/src/smoke.ts` +- Schema catalog: `schemas/flowmemory/control-plane-production-l1.schema.json` +- API docs: `docs/FLOWCHAIN_CONTROL_PLANE_API.md` +- Endpoint matrix: `docs/agent-runs/production-l1-rpc/ENDPOINT_MATRIX.md` + +## Methods Added Or Tightened + +Added: + +- `sync_status` +- `finality_status` +- `event_list` +- `event_get` +- `bridge_config_get` +- `bridge_status` +- `withdrawal_intent_list` +- `withdrawal_intent_get` +- `release_evidence_list` +- `release_evidence_get` +- `replay_rejection_list` +- `replay_rejection_get` + +Tightened: + +- `transaction_submit` +- `receipt_get` +- `balance_get` +- `bridge_observation_submit` +- `chain_status` +- `node_status` + +## Request And Response Schemas + +Schema catalog path: + +```text +schemas/flowmemory/control-plane-production-l1.schema.json +``` + +The full method-to-schema matrix is in: + +```text +docs/agent-runs/production-l1-rpc/ENDPOINT_MATRIX.md +``` + +## Smoke Command + +```powershell +npm run control-plane:smoke +``` + +Observed result: + +- `methodCount: 91` +- `successCount: 87` +- `expectedErrorCount: 4` +- `noSecretScan.findingCount: 0` + +## Dashboard Fields + +Dashboard field contract: + +```text +docs/agent-runs/production-l1-rpc/DASHBOARD_CONTRACT.md +``` + +High-priority views: + +- status: `chain_status`, `node_status`, `sync_status`, `finality_status`; +- explorer: `block_list`, `transaction_list`, `event_list`, `receipt_get`; +- accounts: `account_list`, `balance_get`, `wallet_metadata_list`; +- tokens/DEX: `token_list`, `pool_list`, `lp_position_list`, `swap_list`; +- bridge: `bridge_config_get`, `bridge_status`, `bridge_observation_list`, `bridge_credit_list`, `withdrawal_intent_list`, `release_evidence_list`, `replay_rejection_list`; +- diagnostics: `raw_json_get`, `provenance_get`. + +## Known Blockers And Caveats + +- Live long-running runtime state is not present in this worktree (`devnet/local/state.json` is absent), so responses mark fallback provenance instead of claiming live state. +- Signed transaction verification uses a deterministic local digest scheme for the private/local testnet path. It is not production custody or audited cryptography. +- Accepted submits write local intake rows and expose local receipts immediately. Public-chain broadcast is not implemented or claimed. +- DEX detail methods return explicit diagnostic empty projections when no live pools, positions, or swaps are present, so dashboard detail contracts stay stable without hiding fallback provenance. +- Release evidence returns a pending projection when withdrawal intent exists but no release record has been exported. diff --git a/docs/agent-runs/production-l1-rpc/LIVE_WALLET_DASHBOARD_USE.md b/docs/agent-runs/production-l1-rpc/LIVE_WALLET_DASHBOARD_USE.md new file mode 100644 index 00000000..8c9ac202 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/LIVE_WALLET_DASHBOARD_USE.md @@ -0,0 +1,95 @@ +# Live Wallet Dashboard Use + +Final status: EXTERNAL-BLOCKED + +Reason: the local runtime state is readable through the new control-plane and dashboard paths, but this machine does not have an applied Base chain ID `8453` bridge credit to a non-placeholder FlowChain account. The only loaded bridge credit during this run came from `fixtures/bridge/local-runtime-bridge-handoff.json`, used source chain `84532`, and credited the placeholder `0x5555...5555` account. The code correctly labels that state `NOT READY` and refuses transfer from the placeholder. + +## What Changed + +- Added `bridge_credit_status` and `transfer_send` to the control-plane API. +- Added HTTP routes: + - `GET /bridge/status` + - `GET /bridge/deposits?limit=` + - `GET /bridge/credits?limit=` + - `GET /bridge/credit-status` + - `POST /transfer/send` +- Updated `bridge_credit_get` so lookup by `txHash` or `baseTxHash` prefers an applied runtime credit over projected artifacts. +- Updated `balance_get` so applied bridge credits contribute to `spendableBalance`. +- Added dashboard `/bridge`: + - generates an in-memory FlowChain recipient or selects a non-placeholder account, + - requires operator confirmation before preparing `lockNative(bytes32 flowchainRecipient, bytes32 metadataHash)`, + - never defaults a real-funds transfer to `0x5555...5555`, + - shows live credit status, tx-hash lookup, spendable balance, transfer status, first usable timestamp, and latency, + - calls `transfer_send` for local FlowChain transfers and renders the machine-readable receipt. +- Added root gate `npm run flowchain:no-secret:scan`. + +## How To Use + +Start the control-plane: + +```powershell +npm run control-plane:serve +``` + +Start the dashboard against that control-plane: + +```powershell +$env:VITE_FLOWCHAIN_CONTROL_PLANE_URL = "http://127.0.0.1:8787" +npm run dev --prefix apps/dashboard +``` + +Open: + +```text +http://127.0.0.1:5173/bridge +``` + +On `/bridge`: + +1. Select an existing non-placeholder account, generate an in-memory FlowChain account, or enter a 32-byte FlowChain account. +2. Confirm the exact recipient shown in the panel. +3. Prepare the `lockNative` call draft. The dashboard does not broadcast it. +4. After a real Base `8453` deposit is observed and credited, use lookup by Base tx hash and then send a local transfer from the credited FlowChain account. + +## Readiness Labels + +- `LIVE PILOT`: only when running local node/control-plane state has an applied Base `8453` bridge credit to a non-placeholder account. +- `LOCAL ONLY`: the control-plane is local and not externally exposed. +- `NOT READY`: fixture/mock/Base Sepolia/placeholder fallback is visible. + +## Evidence + +Reports: + +- `devnet/local/live-rpc-wallet-dashboard/control-plane-smoke.json` +- `devnet/local/live-rpc-wallet-dashboard/dashboard-control-plane-smoke.json` +- `devnet/local/live-rpc-wallet-dashboard/bridge-credit-lookup-transfer-probe.json` +- `devnet/local/live-rpc-wallet-dashboard/no-secret-scan-report.json` + +Observed current state: + +- `health.localOnly`: `true` +- `bridge_credit_status.source.runtime`: `live` +- `bridge_credit_status.source.bridge`: `imported` +- `bridge_credit_status.readinessLabel`: `NOT READY` +- `bridge_credit_status.creditedAccount`: placeholder `0x5555...5555` +- `bridge_credit_status.noBaseReleaseBroadcast`: `true` + +The transfer receipt path is covered by `services/control-plane/test/control-plane.test.ts` with an applied Base `8453` non-placeholder credit and by `control-plane-smoke.json` for `transfer_send`. The current running state transfer is intentionally blocked because the loaded credit belongs to the placeholder account. + +## Checks Run + +- `npm test --prefix services/control-plane`: PASS +- `npm test --prefix apps/dashboard`: PASS +- `npm run build --prefix apps/dashboard`: PASS +- `npm run control-plane:smoke --silent`: PASS +- temporary control-plane/dashboard HTTP smoke on `8799`/`5199`: PASS for route and status response, external-blocked for live Base `8453` credit +- `npm run flowchain:no-secret:scan`: PASS +- `git diff --check`: PASS + +## Risks And Follow-Ups + +- EXTERNAL-BLOCKED: ingest a real Base `8453` lockbox deposit for a non-placeholder FlowChain recipient before claiming `LIVE PILOT`. +- EXTERNAL-BLOCKED: after that ingest, rerun `bridge_credit_get` by tx hash and `transfer_send` from the credited account against the running node state. +- LOCAL ONLY: no external/public RPC exposure was claimed or documented as ready. +- Browser private keys remain out of dashboard storage; the dashboard only creates in-memory recipient IDs and local transfer requests. diff --git a/docs/agent-runs/production-l1-rpc/NOTES.md b/docs/agent-runs/production-l1-rpc/NOTES.md new file mode 100644 index 00000000..70f41f8b --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/NOTES.md @@ -0,0 +1,24 @@ +# Private/Local L1-Shaped RPC Notes + +## Initial Context + +- Current branch: `agent/production-l1-rpc`. +- The existing control-plane API is JSON-RPC 2.0 under `services/control-plane/`. +- Existing documentation describes the service as local runtime/fixture-backed V0, not a production RPC endpoint. +- This task must expose the private/local L1-shaped surface through the existing control-plane boundary while keeping live/fixture provenance explicit. + +## Guardrails + +- Do not create a second API service. +- Do not edit forbidden folders. +- Do not hardcode or return secrets. +- Do not silently substitute fixtures for live state. +- Keep schemas, smoke cases, error shapes, and no-secret assertions in sync. + +## Implementation Notes + +- `transaction_submit` now accepts only `flowchain.signed_transaction_envelope.v1`. +- The smoke client builds a no-secret local signed transfer using `flowchain-local-digest-v1`. +- Every success result is annotated with `responseProvenance`. +- The schema catalog is source-checked by smoke before success. +- The current worktree has no live `devnet/local/state.json`; fallback state is marked as deterministic fixture/imported provenance. diff --git a/docs/agent-runs/production-l1-rpc/NO_SECRET_PROOF.md b/docs/agent-runs/production-l1-rpc/NO_SECRET_PROOF.md new file mode 100644 index 00000000..d38a4199 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/NO_SECRET_PROOF.md @@ -0,0 +1,44 @@ +# No-Secret Proof + +## Scanner + +The control-plane has two secret gates: + +- `services/shared/src/secret-scan.ts` is used by JSON-RPC dispatch before any result is returned. +- `services/control-plane/src/no-secret.ts` is used by the smoke client to scan every response in the 91-call private/local L1-shaped batch. +- `services/control-plane/test/control-plane.test.ts` scans browser-safe HTTP route responses, including `/health`, `/state`, `/explorer/summary`, `/product-flow/status`, `/rpc`, `/bridge/observations`, and mapped `/pilot/*` routes. + +The scanner rejects secret-shaped keys or values including: + +- private keys and secret keys; +- seed phrases and mnemonics; +- RPC credentials and credentialed URLs; +- API keys and bearer-style tokens; +- Slack/Discord/webhook-shaped URLs; +- raw secret-bearing env maps. + +## Smoke Output + +`npm run control-plane:smoke` passed with: + +```json +{ + "methodCount": 91, + "successCount": 87, + "expectedErrorCount": 4, + "noSecretScan": { + "schema": "flowmemory.control_plane.no_secret_scan.v1", + "scannedResponses": 91, + "findingCount": 0 + } +} +``` + +## Tested Rejections + +- `transaction_submit` rejects secret-shaped signed-envelope material. +- `bridge_observation_submit` rejects private key, seed phrase, mnemonic, RPC credential, API key, and webhook-shaped payloads. +- `raw_json_get` rejects responses if a loaded local raw source contains secret-shaped keys or values. +- Browser-safe HTTP routes are scanned in the control-plane test suite before assertions use the response bodies. + +No route in the smoke batch returned private keys, seed phrases, mnemonics, RPC URLs with credentials, API keys, webhooks, raw env maps, or local vault contents. diff --git a/docs/agent-runs/production-l1-rpc/PLAN.md b/docs/agent-runs/production-l1-rpc/PLAN.md new file mode 100644 index 00000000..87b438a1 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/PLAN.md @@ -0,0 +1,40 @@ +# Private/Local L1-Shaped RPC Plan + +## Mission + +Expose the full FlowChain L1 through the existing `services/control-plane/` API surface without creating a second service, while keeping responses free of secrets and explicit about runtime provenance. + +## Scope + +Allowed folders: + +- `services/control-plane/` +- `services/shared/` +- `schemas/flowmemory/` +- `docs/FLOWCHAIN_CONTROL_PLANE_API.md` +- `docs/agent-runs/production-l1-rpc/` +- `package.json` only for API aliases + +Forbidden folders: + +- `crates/` implementation +- `contracts/` +- `crypto/` secret-handling internals +- `apps/dashboard/` +- `hardware/` +- local secret files + +## Phases + +1. Inventory current control-plane routes, schemas, tests, and runtime/storage handoffs. +2. Map every required L1 surface to an existing JSON-RPC method or a new method inside the current service. +3. Add versioned request/response schemas and one versioned error envelope. +4. Wire handlers to live local runtime state where available, with explicit fallback provenance. +5. Add signed transaction intake validation, rejection paths, duplicate detection, and receipt visibility. +6. Add events, account, token, DEX, bridge, finality, sync, and diagnostics query coverage. +7. Extend smoke tests to call every private/local L1-shaped method, validate responses, and run the no-secret scanner. +8. Run required commands and record proof artifacts. + +## Stop Condition + +Stop only when the existing control-plane API can submit and inspect a signed local L1 transaction, expose all dashboard and bridge query surfaces, return structured errors, validate schemas in smoke, and prove no route returns secret-shaped material. diff --git a/docs/agent-runs/production-l1-rpc/SCHEMA_VALIDATION_PROOF.md b/docs/agent-runs/production-l1-rpc/SCHEMA_VALIDATION_PROOF.md new file mode 100644 index 00000000..d1d640ae --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/SCHEMA_VALIDATION_PROOF.md @@ -0,0 +1,44 @@ +# Schema Validation Proof + +Published schema catalog: + +```text +schemas/flowmemory/control-plane-production-l1.schema.json +``` + +The catalog defines: + +- every JSON-RPC method name; +- request schema name; +- response schema name; +- success result schema name; +- versioned error envelope fields and required error codes. + +Smoke validation behavior: + +1. Loads the catalog from `schemas/flowmemory/control-plane-production-l1.schema.json`. +2. Calls the 91-case private/local L1-shaped smoke batch. +3. For every success response, verifies the method is in the catalog. +4. Verifies each result `schema` matches the catalog result schema. +5. For expected errors, verifies `flowmemory.control_plane.error.v1` and required fields: + - `errorCode` + - `message` + - `correlationId` + - `recoverable` + - `retryable` + - `sourceComponent` +6. Scans every response for secret-shaped material. + +Command result: + +```text +npm run control-plane:smoke +methodCount: 91 +successCount: 87 +expectedErrorCount: 4 +findingCount: 0 +``` + +Backward compatibility note: + +Most existing result shapes keep their `*.v0` schema names. The new transaction submit result uses `flowmemory.control_plane.transaction_submit_result.v1`, and the error envelope uses `flowmemory.control_plane.error.v1`. Existing dashboard-facing field names were preserved where possible; new fields are additive. diff --git a/docs/agent-runs/production-l1-rpc/SUBMIT_LOOP_PROOF.md b/docs/agent-runs/production-l1-rpc/SUBMIT_LOOP_PROOF.md new file mode 100644 index 00000000..362620ce --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/SUBMIT_LOOP_PROOF.md @@ -0,0 +1,30 @@ +# Submit Loop Proof + +The submit/query loop is implemented inside the existing control-plane boundary: + +1. `transaction_submit` accepts only `flowchain.signed_transaction_envelope.v1`. +2. It rejects unsigned/plain `transaction`, `tx`, and `txs` payloads. +3. It rejects secret-shaped requests before intake. +4. It checks the submitted `chainId` against `chain_status.chainId`. +5. It checks signer hex format. +6. It checks nonce ordering per signer. +7. It verifies the local deterministic signature digest. +8. It rejects duplicate transaction ids. +9. It appends accepted rows to `devnet/local/intake/transactions.ndjson`. +10. It exposes the accepted row through `transaction_get`, `mempool_list`, `receipt_get`, `event_list`, and `balance_get`. + +Smoke evidence: + +```json +{ + "methodCount": 91, + "successCount": 87, + "expectedErrorCount": 4, + "queried": { + "submittedTxId": "0x...", + "tokenId": "local-test-unit" + } +} +``` + +The submitted transfer is local/private and no-value. The API does not claim public-chain broadcast or production custody. diff --git a/docs/agent-runs/production-l1-rpc/SUBMIT_QUERY_PROOF.md b/docs/agent-runs/production-l1-rpc/SUBMIT_QUERY_PROOF.md new file mode 100644 index 00000000..a82186a0 --- /dev/null +++ b/docs/agent-runs/production-l1-rpc/SUBMIT_QUERY_PROOF.md @@ -0,0 +1,50 @@ +# Submit Query Proof + +Command: + +```powershell +npm run control-plane:smoke +``` + +Smoke submitted a versioned signed transfer envelope: + +```json +{ + "schema": "flowchain.signed_transaction_envelope.v1", + "chainId": "flowmemory-local-devnet-v0", + "signer": "0x<20-byte-local-smoke-signer>", + "nonce": "0", + "signatureScheme": "flowchain-local-digest-v1", + "payload": { + "schema": "flowchain.transaction.transfer.v1", + "type": "transfer", + "from": "account:submit:alice", + "to": "account:submit:bob", + "tokenId": "local-test-unit", + "amount": "7" + } +} +``` + +The API returned: + +- method: `transaction_submit` +- result schema: `flowmemory.control_plane.transaction_submit_result.v1` +- status: `accepted_local` +- source: `local-file-intake` +- receipt source: `flowmemory.control_plane.transaction_receipt.v1` + +The same smoke then queried: + +- `transaction_get` with the submitted `txId`; +- `receipt_get` with `{ "txId": submittedTxId }`; +- `balance_get` with `{ "accountId": "account:submit:bob", "tokenId": "local-test-unit" }`. + +The balance proof returned `amount: "7"` with `pendingAcceptedDelta: "7"`, proving the submitted local transfer is visible through stable account/balance fields without file scraping. + +The smoke also verified expected rejection paths: + +- invalid signature -> `BAD_SIGNATURE`; +- duplicate transaction -> `DUPLICATE_TX`; +- wrong chain id -> `WRONG_CHAIN_ID`; +- stale nonce -> `STALE_NONCE`. diff --git a/infra/scripts/flowchain-no-secret-scan.mjs b/infra/scripts/flowchain-no-secret-scan.mjs new file mode 100644 index 00000000..b8921be1 --- /dev/null +++ b/infra/scripts/flowchain-no-secret-scan.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const reportDir = join(repoRoot, "devnet", "local", "live-rpc-wallet-dashboard"); +const reportPath = join(reportDir, "no-secret-scan-report.json"); +const npmCommand = "npm"; +const nodeCommand = process.execPath; + +const scannedSourceFiles = [ + "apps/dashboard/src/data/bridge.ts", + "apps/dashboard/src/views/BridgeView.tsx", + "services/control-plane/src/methods.ts", + "services/control-plane/src/server.ts", + "services/control-plane/src/transaction-envelope.ts", +].filter((file) => existsSync(join(repoRoot, file))); + +const sourceSecretPatterns = [ + { label: "browser localStorage", pattern: /\blocalStorage\b/ }, + { label: "browser sessionStorage", pattern: /\bsessionStorage\b/ }, + { label: "private key marker", pattern: /\bprivate[_ -]?key\b/i }, + { label: "seed phrase marker", pattern: /\bseed[_ -]?phrase\b/i }, + { label: "mnemonic marker", pattern: /\bmnemonic\b/i }, + { label: "api key marker", pattern: /\bapi[_ -]?key\b/i }, + { label: "webhook url marker", pattern: /\bwebhook[_ -]?url\b/i }, + { label: "bearer token marker", pattern: /\bbearer[_ -]?token\b/i }, +]; + +function run(command, args) { + const file = process.platform === "win32" && command === "npm" ? "cmd.exe" : command; + const finalArgs = process.platform === "win32" && command === "npm" ? ["/d", "/s", "/c", "npm", ...args] : args; + const stdout = execFileSync(file, finalArgs, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return stdout.trim(); +} + +function parseSmoke(stdout) { + const start = stdout.indexOf("{"); + if (start < 0) { + throw new Error("control-plane smoke did not print JSON"); + } + return JSON.parse(stdout.slice(start)); +} + +function scanSourceFiles() { + const findings = []; + for (const file of scannedSourceFiles) { + const text = readFileSync(join(repoRoot, file), "utf8"); + for (const { label, pattern } of sourceSecretPatterns) { + if (pattern.test(text)) { + findings.push({ file, label }); + } + } + } + return findings; +} + +mkdirSync(reportDir, { recursive: true }); + +const smokeStdout = run(npmCommand, ["run", "control-plane:smoke", "--silent"]); +const smoke = parseSmoke(smokeStdout); +if (smoke?.noSecretScan?.findingCount !== 0) { + throw new Error(`control-plane smoke reported secret findings: ${JSON.stringify(smoke.noSecretScan)}`); +} + +const unsafeClaimOutput = run(nodeCommand, ["infra/scripts/check-unsafe-claims.mjs"]); +const sourceFindings = scanSourceFiles(); +const passed = sourceFindings.length === 0; +const report = { + schema: "flowchain.live_rpc_wallet_dashboard.no_secret_scan_report.v1", + generatedAt: new Date().toISOString(), + passed, + checks: { + controlPlaneSmoke: { + command: "npm run control-plane:smoke --silent", + ok: smoke.ok === true, + methodCount: smoke.methodCount, + noSecretScan: smoke.noSecretScan, + }, + unsafeClaimScan: { + command: "node infra/scripts/check-unsafe-claims.mjs", + ok: true, + output: unsafeClaimOutput, + }, + sourceScan: { + files: scannedSourceFiles, + findingCount: sourceFindings.length, + findings: sourceFindings, + excludesIgnoredWalletVaults: true, + }, + }, + noBaseReleaseBroadcast: true, + localOnly: true, +}; + +writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify(report, null, 2)); + +if (!passed) { + process.exit(1); +} diff --git a/package.json b/package.json index d79d409c..8664fd57 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "flowchain:real-value-pilot:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot-export.ps1", "flowchain:real-value-pilot:control-dashboard": "npm run real-value-pilot:e2e --prefix services/control-plane", "flowchain:real-value-pilot:wallet": "npm run wallet:pilot-e2e --prefix crypto", + "flowchain:no-secret:scan": "node infra/scripts/flowchain-no-secret-scan.mjs", "flowchain:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-export.ps1", "flowchain:import": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-import.ps1", "workbench:dev": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-workbench.ps1", diff --git a/schemas/flowmemory/README.md b/schemas/flowmemory/README.md index ffe6c891..f6e9a448 100644 --- a/schemas/flowmemory/README.md +++ b/schemas/flowmemory/README.md @@ -29,6 +29,7 @@ These schemas are the canonical local/test V0 shapes for generated Flow Memory a - `real-value-pilot-operator-config.schema.json` - `real-value-pilot-public-metadata.schema.json` - `control-plane-provenance-response.schema.json` +- `control-plane-production-l1.schema.json` `memory-signal.schema.json` also embeds the `flowmemory.flowpulse_contract_event.v0` shape, which records the `IFlowPulse.FlowPulse` event signature, indexed fields, @@ -58,6 +59,13 @@ transfer, token launch, pool create, add liquidity, remove liquidity, swap, and bridge credit acknowledgement. Bridge withdrawal intent uses `bridge-withdrawal-intent.schema.json` and the same local transaction envelope. +`control-plane-production-l1.schema.json` is the JSON-RPC method catalog for +the existing control-plane API. It publishes request schema names, response +schema names, result schema names, and the `flowmemory.control_plane.error.v1` +error envelope used by smoke validation. The catalog includes the bridge wallet +operator methods `bridge_credit_status` and `transfer_send`, including result +schemas for live credit lookup labels and local transfer receipts. + The `real-value-pilot-*` schemas describe capped pilot operator messages, env-derived non-secret config, and secret-free public metadata export. Pilot messages include cap fields and are verified through the crypto package without diff --git a/schemas/flowmemory/control-plane-production-l1.schema.json b/schemas/flowmemory/control-plane-production-l1.schema.json new file mode 100644 index 00000000..20286f2d --- /dev/null +++ b/schemas/flowmemory/control-plane-production-l1.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "flowmemory.control_plane.production_l1_schema_catalog.v1", + "schema": "flowmemory.control_plane.production_l1_schema_catalog.v1", + "title": "FlowChain Control Plane Private Local L1 Method Schema Catalog V1", + "description": "Versioned request/response schema catalog for the existing JSON-RPC control-plane surface. Result schemas remain explicit and versioned per method.", + "errorEnvelope": { + "schema": "flowmemory.control_plane.error.v1", + "requiredFields": [ + "errorCode", + "message", + "correlationId", + "recoverable", + "retryable", + "sourceComponent", + "localOnly" + ], + "codes": [ + "MALFORMED_REQUEST", + "UNSIGNED_TRANSACTION", + "BAD_SIGNATURE", + "WRONG_CHAIN_ID", + "STALE_NONCE", + "DUPLICATE_TX", + "UNKNOWN_BLOCK", + "UNKNOWN_TX", + "UNKNOWN_ACCOUNT", + "UNKNOWN_TOKEN", + "UNKNOWN_POOL", + "BRIDGE_REPLAY", + "LIVE_RUNTIME_UNAVAILABLE", + "STORAGE_UNAVAILABLE", + "UNSAFE_SECRET_DETECTED" + ] + }, + "methods": { + "health": { "requestSchema": "flowmemory.control_plane.health_request.v1", "responseSchema": "flowmemory.control_plane.health_response.v1", "resultSchema": "flowmemory.control_plane.health.v0" }, + "node_status": { "requestSchema": "flowmemory.control_plane.node_status_request.v1", "responseSchema": "flowmemory.control_plane.node_status_response.v1", "resultSchema": "flowmemory.control_plane.node_status.v0" }, + "peer_list": { "requestSchema": "flowmemory.control_plane.peer_list_request.v1", "responseSchema": "flowmemory.control_plane.peer_list_response.v1", "resultSchema": "flowmemory.control_plane.peer_list.v0" }, + "sync_status": { "requestSchema": "flowmemory.control_plane.sync_status_request.v1", "responseSchema": "flowmemory.control_plane.sync_status_response.v1", "resultSchema": "flowmemory.control_plane.sync_status.v0" }, + "chain_status": { "requestSchema": "flowmemory.control_plane.chain_status_request.v1", "responseSchema": "flowmemory.control_plane.chain_status_response.v1", "resultSchema": "flowmemory.control_plane.chain_status.v0" }, + "finality_status": { "requestSchema": "flowmemory.control_plane.finality_status_request.v1", "responseSchema": "flowmemory.control_plane.finality_status_response.v1", "resultSchema": "flowmemory.control_plane.finality_status.v0" }, + "pilot_status": { "requestSchema": "flowmemory.control_plane.pilot_status_request.v1", "responseSchema": "flowmemory.control_plane.pilot_status_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_status.v0" }, + "pilot_deposit_observation_list": { "requestSchema": "flowmemory.control_plane.pilot_deposit_observation_list_request.v1", "responseSchema": "flowmemory.control_plane.pilot_deposit_observation_list_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_deposit_observation_list.v0" }, + "pilot_credit_list": { "requestSchema": "flowmemory.control_plane.pilot_credit_list_request.v1", "responseSchema": "flowmemory.control_plane.pilot_credit_list_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_credit_list.v0" }, + "pilot_withdrawal_intent_list": { "requestSchema": "flowmemory.control_plane.pilot_withdrawal_intent_list_request.v1", "responseSchema": "flowmemory.control_plane.pilot_withdrawal_intent_list_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_withdrawal_intent_list.v0" }, + "pilot_release_evidence_list": { "requestSchema": "flowmemory.control_plane.pilot_release_evidence_list_request.v1", "responseSchema": "flowmemory.control_plane.pilot_release_evidence_list_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_release_evidence_list.v0" }, + "pilot_cap_status": { "requestSchema": "flowmemory.control_plane.pilot_cap_status_request.v1", "responseSchema": "flowmemory.control_plane.pilot_cap_status_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_cap_status.v0" }, + "pilot_pause_status": { "requestSchema": "flowmemory.control_plane.pilot_pause_status_request.v1", "responseSchema": "flowmemory.control_plane.pilot_pause_status_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_pause_status.v0" }, + "pilot_retry_status": { "requestSchema": "flowmemory.control_plane.pilot_retry_status_request.v1", "responseSchema": "flowmemory.control_plane.pilot_retry_status_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_retry_status.v0" }, + "pilot_emergency_status": { "requestSchema": "flowmemory.control_plane.pilot_emergency_status_request.v1", "responseSchema": "flowmemory.control_plane.pilot_emergency_status_response.v1", "resultSchema": "flowmemory.control_plane.real_value_pilot_emergency_status.v0" }, + "devnet_state": { "requestSchema": "flowmemory.control_plane.devnet_state_request.v1", "responseSchema": "flowmemory.control_plane.devnet_state_response.v1", "resultSchema": "flowmemory.control_plane.devnet_state.v0" }, + "block_list": { "requestSchema": "flowmemory.control_plane.block_list_request.v1", "responseSchema": "flowmemory.control_plane.block_list_response.v1", "resultSchema": "flowmemory.control_plane.block_list.v0" }, + "block_get": { "requestSchema": "flowmemory.control_plane.block_get_request.v1", "responseSchema": "flowmemory.control_plane.block_get_response.v1", "resultSchema": "flowmemory.control_plane.block_detail.v0" }, + "transaction_list": { "requestSchema": "flowmemory.control_plane.transaction_list_request.v1", "responseSchema": "flowmemory.control_plane.transaction_list_response.v1", "resultSchema": "flowmemory.control_plane.transaction_list.v0" }, + "transaction_get": { "requestSchema": "flowmemory.control_plane.transaction_get_request.v1", "responseSchema": "flowmemory.control_plane.transaction_get_response.v1", "resultSchema": "flowmemory.control_plane.transaction_detail.v0" }, + "transaction_submit": { "requestSchema": "flowmemory.control_plane.transaction_submit_request.v1", "responseSchema": "flowmemory.control_plane.transaction_submit_response.v1", "resultSchema": "flowmemory.control_plane.transaction_submit_result.v1" }, + "transfer_send": { "requestSchema": "flowmemory.control_plane.transfer_send_request.v1", "responseSchema": "flowmemory.control_plane.transfer_send_response.v1", "resultSchema": "flowmemory.control_plane.transfer_send_result.v1" }, + "event_list": { "requestSchema": "flowmemory.control_plane.event_list_request.v1", "responseSchema": "flowmemory.control_plane.event_list_response.v1", "resultSchema": "flowmemory.control_plane.event_list.v0" }, + "event_get": { "requestSchema": "flowmemory.control_plane.event_get_request.v1", "responseSchema": "flowmemory.control_plane.event_get_response.v1", "resultSchema": "flowmemory.control_plane.event_detail.v0" }, + "mempool_list": { "requestSchema": "flowmemory.control_plane.mempool_list_request.v1", "responseSchema": "flowmemory.control_plane.mempool_list_response.v1", "resultSchema": "flowmemory.control_plane.mempool_list.v0" }, + "account_list": { "requestSchema": "flowmemory.control_plane.account_list_request.v1", "responseSchema": "flowmemory.control_plane.account_list_response.v1", "resultSchema": "flowmemory.control_plane.account_list.v0" }, + "account_get": { "requestSchema": "flowmemory.control_plane.account_get_request.v1", "responseSchema": "flowmemory.control_plane.account_get_response.v1", "resultSchema": "flowmemory.control_plane.account_detail.v0" }, + "balance_get": { "requestSchema": "flowmemory.control_plane.balance_get_request.v1", "responseSchema": "flowmemory.control_plane.balance_get_response.v1", "resultSchema": "flowmemory.control_plane.balance.v0" }, + "token_list": { "requestSchema": "flowmemory.control_plane.token_list_request.v1", "responseSchema": "flowmemory.control_plane.token_list_response.v1", "resultSchema": "flowmemory.control_plane.token_list.v0" }, + "token_get": { "requestSchema": "flowmemory.control_plane.token_get_request.v1", "responseSchema": "flowmemory.control_plane.token_get_response.v1", "resultSchema": "flowmemory.control_plane.token_detail.v0" }, + "token_balance_list": { "requestSchema": "flowmemory.control_plane.token_balance_list_request.v1", "responseSchema": "flowmemory.control_plane.token_balance_list_response.v1", "resultSchema": "flowmemory.control_plane.token_balance_list.v0" }, + "token_balance_get": { "requestSchema": "flowmemory.control_plane.token_balance_get_request.v1", "responseSchema": "flowmemory.control_plane.token_balance_get_response.v1", "resultSchema": "flowmemory.control_plane.token_balance_detail.v0" }, + "pool_list": { "requestSchema": "flowmemory.control_plane.pool_list_request.v1", "responseSchema": "flowmemory.control_plane.pool_list_response.v1", "resultSchema": "flowmemory.control_plane.pool_list.v0" }, + "pool_get": { "requestSchema": "flowmemory.control_plane.pool_get_request.v1", "responseSchema": "flowmemory.control_plane.pool_get_response.v1", "resultSchema": "flowmemory.control_plane.pool_detail.v0" }, + "lp_position_list": { "requestSchema": "flowmemory.control_plane.lp_position_list_request.v1", "responseSchema": "flowmemory.control_plane.lp_position_list_response.v1", "resultSchema": "flowmemory.control_plane.lp_position_list.v0" }, + "lp_position_get": { "requestSchema": "flowmemory.control_plane.lp_position_get_request.v1", "responseSchema": "flowmemory.control_plane.lp_position_get_response.v1", "resultSchema": "flowmemory.control_plane.lp_position_detail.v0" }, + "swap_list": { "requestSchema": "flowmemory.control_plane.swap_list_request.v1", "responseSchema": "flowmemory.control_plane.swap_list_response.v1", "resultSchema": "flowmemory.control_plane.swap_list.v0" }, + "swap_get": { "requestSchema": "flowmemory.control_plane.swap_get_request.v1", "responseSchema": "flowmemory.control_plane.swap_get_response.v1", "resultSchema": "flowmemory.control_plane.swap_detail.v0" }, + "product_flow_status": { "requestSchema": "flowmemory.control_plane.product_flow_status_request.v1", "responseSchema": "flowmemory.control_plane.product_flow_status_response.v1", "resultSchema": "flowmemory.control_plane.product_flow_status.v0" }, + "faucet_event_list": { "requestSchema": "flowmemory.control_plane.faucet_event_list_request.v1", "responseSchema": "flowmemory.control_plane.faucet_event_list_response.v1", "resultSchema": "flowmemory.control_plane.faucet_event_list.v0" }, + "wallet_metadata_list": { "requestSchema": "flowmemory.control_plane.wallet_metadata_list_request.v1", "responseSchema": "flowmemory.control_plane.wallet_metadata_list_response.v1", "resultSchema": "flowmemory.control_plane.wallet_public_metadata_list.v0" }, + "wallet_metadata_get": { "requestSchema": "flowmemory.control_plane.wallet_metadata_get_request.v1", "responseSchema": "flowmemory.control_plane.wallet_metadata_get_response.v1", "resultSchema": "flowmemory.control_plane.wallet_public_metadata_detail.v0" }, + "rootfield_list": { "requestSchema": "flowmemory.control_plane.rootfield_list_request.v1", "responseSchema": "flowmemory.control_plane.rootfield_list_response.v1", "resultSchema": "flowmemory.control_plane.rootfield_list.v0" }, + "rootfield_get": { "requestSchema": "flowmemory.control_plane.rootfield_get_request.v1", "responseSchema": "flowmemory.control_plane.rootfield_get_response.v1", "resultSchema": "flowmemory.control_plane.rootfield.v0" }, + "agent_list": { "requestSchema": "flowmemory.control_plane.agent_list_request.v1", "responseSchema": "flowmemory.control_plane.agent_list_response.v1", "resultSchema": "flowmemory.control_plane.agent_list.v0" }, + "agent_get": { "requestSchema": "flowmemory.control_plane.agent_get_request.v1", "responseSchema": "flowmemory.control_plane.agent_get_response.v1", "resultSchema": "flowmemory.control_plane.agent.v0" }, + "model_list": { "requestSchema": "flowmemory.control_plane.model_list_request.v1", "responseSchema": "flowmemory.control_plane.model_list_response.v1", "resultSchema": "flowmemory.control_plane.model_list.v0" }, + "model_get": { "requestSchema": "flowmemory.control_plane.model_get_request.v1", "responseSchema": "flowmemory.control_plane.model_get_response.v1", "resultSchema": "flowmemory.control_plane.model_detail.v0" }, + "work_receipt_list": { "requestSchema": "flowmemory.control_plane.work_receipt_list_request.v1", "responseSchema": "flowmemory.control_plane.work_receipt_list_response.v1", "resultSchema": "flowmemory.control_plane.work_receipt_list.v0" }, + "work_receipt_get": { "requestSchema": "flowmemory.control_plane.work_receipt_get_request.v1", "responseSchema": "flowmemory.control_plane.work_receipt_get_response.v1", "resultSchema": "flowmemory.control_plane.work_receipt.v0" }, + "artifact_get": { "requestSchema": "flowmemory.control_plane.artifact_get_request.v1", "responseSchema": "flowmemory.control_plane.artifact_get_response.v1", "resultSchema": "flowmemory.control_plane.artifact.v0" }, + "artifact_availability_list": { "requestSchema": "flowmemory.control_plane.artifact_availability_list_request.v1", "responseSchema": "flowmemory.control_plane.artifact_availability_list_response.v1", "resultSchema": "flowmemory.control_plane.artifact_availability_list.v0" }, + "artifact_availability_get": { "requestSchema": "flowmemory.control_plane.artifact_availability_get_request.v1", "responseSchema": "flowmemory.control_plane.artifact_availability_get_response.v1", "resultSchema": "flowmemory.control_plane.artifact_availability_detail.v0" }, + "verifier_module_list": { "requestSchema": "flowmemory.control_plane.verifier_module_list_request.v1", "responseSchema": "flowmemory.control_plane.verifier_module_list_response.v1", "resultSchema": "flowmemory.control_plane.verifier_module_list.v0" }, + "verifier_module_get": { "requestSchema": "flowmemory.control_plane.verifier_module_get_request.v1", "responseSchema": "flowmemory.control_plane.verifier_module_get_response.v1", "resultSchema": "flowmemory.control_plane.verifier_module_detail.v0" }, + "verifier_report_list": { "requestSchema": "flowmemory.control_plane.verifier_report_list_request.v1", "responseSchema": "flowmemory.control_plane.verifier_report_list_response.v1", "resultSchema": "flowmemory.control_plane.verifier_report_list.v0" }, + "verifier_report_get": { "requestSchema": "flowmemory.control_plane.verifier_report_get_request.v1", "responseSchema": "flowmemory.control_plane.verifier_report_get_response.v1", "resultSchema": "flowmemory.control_plane.verifier_report.v0" }, + "receipt_list": { "requestSchema": "flowmemory.control_plane.receipt_list_request.v1", "responseSchema": "flowmemory.control_plane.receipt_list_response.v1", "resultSchema": "flowmemory.control_plane.receipt_list.v0" }, + "receipt_get": { "requestSchema": "flowmemory.control_plane.receipt_get_request.v1", "responseSchema": "flowmemory.control_plane.receipt_get_response.v1", "resultSchema": "flowmemory.control_plane.receipt.v0" }, + "memory_cell_list": { "requestSchema": "flowmemory.control_plane.memory_cell_list_request.v1", "responseSchema": "flowmemory.control_plane.memory_cell_list_response.v1", "resultSchema": "flowmemory.control_plane.memory_cell_list.v0" }, + "memory_cell_get": { "requestSchema": "flowmemory.control_plane.memory_cell_get_request.v1", "responseSchema": "flowmemory.control_plane.memory_cell_get_response.v1", "resultSchema": "flowmemory.control_plane.memory_cell.v0" }, + "challenge_list": { "requestSchema": "flowmemory.control_plane.challenge_list_request.v1", "responseSchema": "flowmemory.control_plane.challenge_list_response.v1", "resultSchema": "flowmemory.control_plane.challenge_list.v0" }, + "challenge_get": { "requestSchema": "flowmemory.control_plane.challenge_get_request.v1", "responseSchema": "flowmemory.control_plane.challenge_get_response.v1", "resultSchema": "flowmemory.control_plane.challenge.v0" }, + "finality_list": { "requestSchema": "flowmemory.control_plane.finality_list_request.v1", "responseSchema": "flowmemory.control_plane.finality_list_response.v1", "resultSchema": "flowmemory.control_plane.finality_list.v0" }, + "finality_get": { "requestSchema": "flowmemory.control_plane.finality_get_request.v1", "responseSchema": "flowmemory.control_plane.finality_get_response.v1", "resultSchema": "flowmemory.control_plane.finality.v0" }, + "bridge_observation_list": { "requestSchema": "flowmemory.control_plane.bridge_observation_list_request.v1", "responseSchema": "flowmemory.control_plane.bridge_observation_list_response.v1", "resultSchema": "flowmemory.control_plane.bridge_observation_list.v0" }, + "bridge_observation_get": { "requestSchema": "flowmemory.control_plane.bridge_observation_get_request.v1", "responseSchema": "flowmemory.control_plane.bridge_observation_get_response.v1", "resultSchema": "flowmemory.control_plane.bridge_observation.v0" }, + "bridge_observation_submit": { "requestSchema": "flowmemory.control_plane.bridge_observation_submit_request.v1", "responseSchema": "flowmemory.control_plane.bridge_observation_submit_response.v1", "resultSchema": "flowmemory.control_plane.bridge_observation_submit_result.v0" }, + "bridge_config_get": { "requestSchema": "flowmemory.control_plane.bridge_config_get_request.v1", "responseSchema": "flowmemory.control_plane.bridge_config_get_response.v1", "resultSchema": "flowmemory.control_plane.bridge_config.v0" }, + "bridge_status": { "requestSchema": "flowmemory.control_plane.bridge_status_request.v1", "responseSchema": "flowmemory.control_plane.bridge_status_response.v1", "resultSchema": "flowmemory.control_plane.bridge_status.v0" }, + "bridge_credit_status": { "requestSchema": "flowmemory.control_plane.bridge_credit_status_request.v1", "responseSchema": "flowmemory.control_plane.bridge_credit_status_response.v1", "resultSchema": "flowmemory.control_plane.bridge_credit_status.v1" }, + "bridge_deposit_list": { "requestSchema": "flowmemory.control_plane.bridge_deposit_list_request.v1", "responseSchema": "flowmemory.control_plane.bridge_deposit_list_response.v1", "resultSchema": "flowmemory.control_plane.bridge_deposit_list.v0" }, + "bridge_deposit_get": { "requestSchema": "flowmemory.control_plane.bridge_deposit_get_request.v1", "responseSchema": "flowmemory.control_plane.bridge_deposit_get_response.v1", "resultSchema": "flowmemory.control_plane.bridge_deposit_detail.v0" }, + "bridge_credit_list": { "requestSchema": "flowmemory.control_plane.bridge_credit_list_request.v1", "responseSchema": "flowmemory.control_plane.bridge_credit_list_response.v1", "resultSchema": "flowmemory.control_plane.bridge_credit_list.v0" }, + "bridge_credit_get": { "requestSchema": "flowmemory.control_plane.bridge_credit_get_request.v1", "responseSchema": "flowmemory.control_plane.bridge_credit_get_response.v1", "resultSchema": "flowmemory.control_plane.bridge_credit_detail.v0" }, + "withdrawal_intent_list": { "requestSchema": "flowmemory.control_plane.withdrawal_intent_list_request.v1", "responseSchema": "flowmemory.control_plane.withdrawal_intent_list_response.v1", "resultSchema": "flowmemory.control_plane.withdrawal_intent_list.v0" }, + "withdrawal_intent_get": { "requestSchema": "flowmemory.control_plane.withdrawal_intent_get_request.v1", "responseSchema": "flowmemory.control_plane.withdrawal_intent_get_response.v1", "resultSchema": "flowmemory.control_plane.withdrawal_intent_detail.v0" }, + "release_evidence_list": { "requestSchema": "flowmemory.control_plane.release_evidence_list_request.v1", "responseSchema": "flowmemory.control_plane.release_evidence_list_response.v1", "resultSchema": "flowmemory.control_plane.release_evidence_list.v0" }, + "release_evidence_get": { "requestSchema": "flowmemory.control_plane.release_evidence_get_request.v1", "responseSchema": "flowmemory.control_plane.release_evidence_get_response.v1", "resultSchema": "flowmemory.control_plane.release_evidence_detail.v0" }, + "replay_rejection_list": { "requestSchema": "flowmemory.control_plane.replay_rejection_list_request.v1", "responseSchema": "flowmemory.control_plane.replay_rejection_list_response.v1", "resultSchema": "flowmemory.control_plane.replay_rejection_list.v0" }, + "replay_rejection_get": { "requestSchema": "flowmemory.control_plane.replay_rejection_get_request.v1", "responseSchema": "flowmemory.control_plane.replay_rejection_get_response.v1", "resultSchema": "flowmemory.control_plane.replay_rejection_detail.v0" }, + "withdrawal_list": { "requestSchema": "flowmemory.control_plane.withdrawal_list_request.v1", "responseSchema": "flowmemory.control_plane.withdrawal_list_response.v1", "resultSchema": "flowmemory.control_plane.withdrawal_list.v0" }, + "withdrawal_get": { "requestSchema": "flowmemory.control_plane.withdrawal_get_request.v1", "responseSchema": "flowmemory.control_plane.withdrawal_get_response.v1", "resultSchema": "flowmemory.control_plane.withdrawal_detail.v0" }, + "provenance_get": { "requestSchema": "flowmemory.control_plane.provenance_get_request.v1", "responseSchema": "flowmemory.control_plane.provenance_get_response.v1", "resultSchema": "flowmemory.control_plane.provenance.v0" }, + "raw_json_get": { "requestSchema": "flowmemory.control_plane.raw_json_get_request.v1", "responseSchema": "flowmemory.control_plane.raw_json_get_response.v1", "resultSchema": "flowmemory.control_plane.raw_json.v0" } + } +} diff --git a/services/control-plane/README.md b/services/control-plane/README.md index dd80a05c..f52ba322 100644 --- a/services/control-plane/README.md +++ b/services/control-plane/README.md @@ -26,7 +26,9 @@ The dispatcher supports: - `health` - `node_status` - `peer_list` +- `sync_status` - `chain_status` +- `finality_status` - `pilot_status` - `pilot_deposit_observation_list` - `pilot_credit_list` @@ -43,6 +45,9 @@ The dispatcher supports: - `transaction_get` - `transaction_list` - `transaction_submit` +- `transfer_send` +- `event_get` +- `event_list` - `account_get` - `account_list` - `balance_get` @@ -86,10 +91,19 @@ The dispatcher supports: - `bridge_observation_get` - `bridge_observation_list` - `bridge_observation_submit` +- `bridge_config_get` +- `bridge_status` - `bridge_deposit_get` - `bridge_deposit_list` - `bridge_credit_get` - `bridge_credit_list` +- `bridge_credit_status` +- `withdrawal_intent_get` +- `withdrawal_intent_list` +- `release_evidence_get` +- `release_evidence_list` +- `replay_rejection_get` +- `replay_rejection_list` - `withdrawal_get` - `withdrawal_list` - `provenance_get` @@ -117,9 +131,11 @@ The loader reads local runtime state first, then committed deterministic outputs If the launch-core fixture is missing, the loader rebuilds the in-memory view from indexer/verifier outputs or the raw fixture receipts and artifact resolver. It does not fetch from live RPC or write production state. -`transaction_submit` accepts signed local test transaction envelopes only and writes them to `devnet/local/intake/transactions.ndjson` by default. `bridge_observation_submit` writes bridge-agent observations to `devnet/local/intake/bridge-observations.ndjson`. These files are local runtime intake, not committed fixtures. +`transaction_submit` accepts signed local test transaction envelopes only and writes them to `devnet/local/intake/transactions.ndjson` by default. `transfer_send` builds a deterministic local signed transfer for an already credited FlowChain account, verifies spendable balance, refuses the `0x5555...5555` placeholder recipient, writes through the same local transaction intake path, and returns a machine-readable receipt. `bridge_observation_submit` writes bridge-agent observations to `devnet/local/intake/bridge-observations.ndjson`. These files are local runtime intake, not committed fixtures. -`npm run control-plane:smoke` runs an in-process JSON-RPC client over the complete local lifecycle surface: health, node status, peers, chain status, real-value pilot status/list/status methods, blocks, transactions, mempool, accounts, balances, tokens, token balances, pools, LP positions, swaps, product-flow status, faucet events, wallet public metadata, rootfields, agents, models, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, bridge observations, bridge deposits, bridge credits, withdrawals, provenance, and raw JSON. +`bridge_credit_status` backs the dashboard wallet panel. It reports Base tx hash, confirmation/lifecycle/idempotent status, credited account, spendable balance, latest transfer action status, first usable timestamp, latency, `LIVE PILOT`/`LOCAL ONLY`/`NOT READY` labels, and `noBaseReleaseBroadcast: true`. + +`npm run control-plane:smoke` runs an in-process JSON-RPC client over the complete local lifecycle surface: health, node status, peers, chain status, real-value pilot status/list/status methods, blocks, transactions, local transfer send, mempool, accounts, balances, tokens, token balances, pools, LP positions, swaps, product-flow status, faucet events, wallet public metadata, rootfields, agents, models, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, bridge observations, bridge deposits, bridge credits, bridge credit status, withdrawals, provenance, and raw JSON. `npm run flowchain:real-value-pilot:control-dashboard` verifies that the API exposes the capped owner-testing pilot lifecycle, rejects secret-shaped material, and that the dashboard source renders the pilot evidence and next operator command without browser secret storage. The root `flowchain:real-value-pilot:e2e` command is the upstream final HQ pilot gate and depends on proof commands from multiple owner branches. diff --git a/services/control-plane/src/errors.ts b/services/control-plane/src/errors.ts index 06acc6e1..73e3f431 100644 --- a/services/control-plane/src/errors.ts +++ b/services/control-plane/src/errors.ts @@ -1,53 +1,150 @@ +import { findSecret } from "../../shared/src/index.ts"; import type { JsonValue, RpcErrorObject } from "./types.ts"; export const JSON_RPC_ERROR_CODES = { + parseError: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602, internalError: -32603, objectNotFound: -32004, secretRejected: -32040, + transactionRejected: -32041, + runtimeUnavailable: -32042, + storageUnavailable: -32043, } as const; +export type ControlPlaneErrorCode = + | "MALFORMED_REQUEST" + | "UNSIGNED_TRANSACTION" + | "BAD_SIGNATURE" + | "WRONG_CHAIN_ID" + | "STALE_NONCE" + | "DUPLICATE_TX" + | "UNKNOWN_BLOCK" + | "UNKNOWN_TX" + | "UNKNOWN_ACCOUNT" + | "UNKNOWN_TOKEN" + | "UNKNOWN_POOL" + | "BRIDGE_REPLAY" + | "LIVE_RUNTIME_UNAVAILABLE" + | "STORAGE_UNAVAILABLE" + | "UNSAFE_SECRET_DETECTED" + | "METHOD_NOT_FOUND" + | "INTERNAL_ERROR"; + export class ControlPlaneError extends Error { readonly code: number; readonly reasonCode: string; + readonly errorCode: ControlPlaneErrorCode; readonly details?: JsonValue; + readonly recoverable: boolean; + readonly retryable: boolean; + readonly sourceComponent: string; - constructor(code: number, message: string, reasonCode: string, details?: JsonValue) { + constructor( + code: number, + message: string, + reasonCode: string, + details?: JsonValue, + errorCode: ControlPlaneErrorCode = "MALFORMED_REQUEST", + recoverable = true, + retryable = false, + sourceComponent = "control-plane", + ) { super(message); this.name = "ControlPlaneError"; this.code = code; this.reasonCode = reasonCode; + this.errorCode = errorCode; this.details = details; + this.recoverable = recoverable; + this.retryable = retryable; + this.sourceComponent = sourceComponent; } } export function invalidParams(message: string, details?: JsonValue): ControlPlaneError { - return new ControlPlaneError(JSON_RPC_ERROR_CODES.invalidParams, message, "params.invalid", details); + return new ControlPlaneError(JSON_RPC_ERROR_CODES.invalidParams, message, "params.invalid", details, "MALFORMED_REQUEST"); } -export function objectNotFound(message: string, details?: JsonValue): ControlPlaneError { - return new ControlPlaneError(JSON_RPC_ERROR_CODES.objectNotFound, message, "object.not_found", details); +export function objectNotFound( + message: string, + details?: JsonValue, + errorCode: ControlPlaneErrorCode = "MALFORMED_REQUEST", +): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.objectNotFound, message, "object.not_found", details, errorCode, true, false); } export function methodNotFound(message: string, details?: JsonValue): ControlPlaneError { - return new ControlPlaneError(JSON_RPC_ERROR_CODES.methodNotFound, message, "method.not_found", details); + return new ControlPlaneError(JSON_RPC_ERROR_CODES.methodNotFound, message, "method.not_found", details, "METHOD_NOT_FOUND"); } export function secretRejected(message: string, details?: JsonValue): ControlPlaneError { - return new ControlPlaneError(JSON_RPC_ERROR_CODES.secretRejected, message, "secret.rejected", details); + return new ControlPlaneError(JSON_RPC_ERROR_CODES.secretRejected, message, "secret.rejected", details, "UNSAFE_SECRET_DETECTED", false, false); +} + +export function unsignedTransaction(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.transactionRejected, message, "transaction.unsigned", details, "UNSIGNED_TRANSACTION"); +} + +export function badSignature(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.transactionRejected, message, "transaction.bad_signature", details, "BAD_SIGNATURE"); +} + +export function wrongChainId(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.transactionRejected, message, "transaction.wrong_chain_id", details, "WRONG_CHAIN_ID"); +} + +export function staleNonce(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.transactionRejected, message, "transaction.stale_nonce", details, "STALE_NONCE"); +} + +export function duplicateTx(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.transactionRejected, message, "transaction.duplicate", details, "DUPLICATE_TX"); +} + +export function bridgeReplay(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.transactionRejected, message, "bridge.replay", details, "BRIDGE_REPLAY"); +} + +export function liveRuntimeUnavailable(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.runtimeUnavailable, message, "runtime.unavailable", details, "LIVE_RUNTIME_UNAVAILABLE", true, true, "runtime"); +} + +export function storageUnavailable(message: string, details?: JsonValue): ControlPlaneError { + return new ControlPlaneError(JSON_RPC_ERROR_CODES.storageUnavailable, message, "storage.unavailable", details, "STORAGE_UNAVAILABLE", true, true, "storage"); +} + +function safeErrorMessage(error: unknown): string { + if (!(error instanceof Error)) { + return "internal control-plane error"; + } + return findSecret(error.message) === null ? error.message : "internal control-plane error"; +} + +function safeDetails(details: JsonValue | undefined): JsonValue | undefined { + if (details === undefined) { + return undefined; + } + return findSecret(details) === null ? details : { redacted: true, reason: "secret-shaped error details" }; } -export function rpcError(error: unknown): RpcErrorObject { +export function rpcError(error: unknown, correlationId = "control-plane-local"): RpcErrorObject { if (error instanceof ControlPlaneError) { return { code: error.code, message: error.message, data: { - schema: "flowmemory.control_plane.error.v0", + schema: "flowmemory.control_plane.error.v1", reasonCode: error.reasonCode, - details: error.details, + errorCode: error.errorCode, + message: error.message, + correlationId, + recoverable: error.recoverable, + retryable: error.retryable, + sourceComponent: error.sourceComponent, + details: safeDetails(error.details), localOnly: true, }, }; @@ -55,10 +152,16 @@ export function rpcError(error: unknown): RpcErrorObject { return { code: JSON_RPC_ERROR_CODES.internalError, - message: error instanceof Error ? error.message : "internal control-plane error", + message: safeErrorMessage(error), data: { - schema: "flowmemory.control_plane.error.v0", + schema: "flowmemory.control_plane.error.v1", reasonCode: "internal.error", + errorCode: "INTERNAL_ERROR", + message: "internal control-plane error", + correlationId, + recoverable: false, + retryable: false, + sourceComponent: "control-plane", localOnly: true, }, }; diff --git a/services/control-plane/src/index.ts b/services/control-plane/src/index.ts index c35d07b2..02d6494e 100644 --- a/services/control-plane/src/index.ts +++ b/services/control-plane/src/index.ts @@ -5,4 +5,5 @@ export * from "./methods.ts"; export * from "./no-secret.ts"; export * from "./pilot.ts"; export * from "./runtime-intake.ts"; +export * from "./transaction-envelope.ts"; export * from "./types.ts"; diff --git a/services/control-plane/src/methods.ts b/services/control-plane/src/methods.ts index 7f21f156..0e2570bb 100644 --- a/services/control-plane/src/methods.ts +++ b/services/control-plane/src/methods.ts @@ -2,7 +2,18 @@ import { appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; import { dirname } from "node:path"; import { canonicalJson, findSecret, keccak256Hex } from "../../shared/src/index.ts"; -import { invalidParams, methodNotFound, objectNotFound, secretRejected } from "./errors.ts"; +import { + badSignature, + bridgeReplay, + duplicateTx, + invalidParams, + methodNotFound, + objectNotFound, + secretRejected, + staleNonce, + unsignedTransaction, + wrongChainId, +} from "./errors.ts"; import { loadControlPlaneState, resolveControlPlanePath } from "./fixture-state.ts"; import { pilotCapStatus, @@ -15,6 +26,7 @@ import { pilotStatus, pilotWithdrawalIntentList, } from "./pilot.ts"; +import { buildLocalSignedTransferEnvelope, validateSignedEnvelope } from "./transaction-envelope.ts"; import type { ControlPlaneContext, ControlPlaneMethod, @@ -24,6 +36,8 @@ import type { } from "./types.ts"; const ZERO_ROOT = "0x0000000000000000000000000000000000000000000000000000000000000000"; +const BASE_MAINNET_CHAIN_ID = "8453"; +const PLACEHOLDER_FLOWCHAIN_RECIPIENT = /^0x5{64}$/i; type MethodHandler = (params: JsonValue | undefined, context: ControlPlaneContext) => JsonValue; @@ -102,6 +116,37 @@ function stringList(value: JsonValue | undefined): string[] { .filter((entry): entry is string => entry !== null); } +function numberString(value: JsonValue | undefined): string | null { + const text = stringValue(value); + return text !== null && /^[0-9]+$/.test(text) ? text : null; +} + +function statusIsApplied(value: JsonValue | undefined): boolean { + const status = stringValue(value)?.toLowerCase(); + return status === "applied" || status === "credited" || status === "included" || status === "validated"; +} + +function firstTimestamp(...values: Array): string | null { + return values.map((value) => stringValue(value)).find((value): value is string => value !== null) ?? null; +} + +function latencyMs(start: string | null, end: string | null): string | null { + if (start === null || end === null) { + return null; + } + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) { + return null; + } + return String(endMs - startMs); +} + +function isPlaceholderFlowchainRecipient(value: JsonValue | undefined): boolean { + const text = stringValue(value); + return text !== null && PLACEHOLDER_FLOWCHAIN_RECIPIENT.test(text); +} + function compareStringNumbers(left: string, right: string): number { if (/^\d+$/.test(left) && /^\d+$/.test(right)) { const diff = BigInt(left) - BigInt(right); @@ -143,6 +188,68 @@ function finalizedBlock(state: LoadedControlPlaneState): string { return finalized.reduce((max, block) => block > max ? block : max, 0n).toString(); } +function sourceKind(record: LoadedControlPlaneState["sources"][string] | undefined): string { + if (record === undefined || record.status === "missing") { + return "unavailable"; + } + if ((record.status === "loaded" || record.status === "recovered") && record.path.replaceAll("\\", "/").startsWith("devnet/local/")) { + return "live"; + } + if (record.status === "loaded") { + return "imported"; + } + return "deterministic_fixture"; +} + +function currentChainId(state: LoadedControlPlaneState): string { + const config = asJsonObject(state.devnet?.config) ?? asJsonObject(state.devnet?.genesisConfig); + return stringValue(state.devnet?.chainId) + ?? stringValue(config?.chainId) + ?? "flowmemory-local-devnet-v0"; +} + +function latestDevnetBlock(state: LoadedControlPlaneState): JsonObject | null { + const blocks = devnetBlocksArray(state); + return blocks[blocks.length - 1] ?? null; +} + +function latestStateRoot(state: LoadedControlPlaneState): string | null { + const latest = latestDevnetBlock(state); + return stringValue(latest?.stateRoot) + ?? stringValue(state.devnetControlPlaneHandoff?.stateRoot) + ?? stringValue(asJsonObject(state.devnetControlPlaneHandoff?.mapRoots)?.stateRoot) + ?? null; +} + +function responseMetadata(state: LoadedControlPlaneState, method: string): JsonObject { + return { + schema: "flowmemory.control_plane.response_provenance.v1", + apiVersion: "flowchain-control-plane-production-l1.v1", + method, + runtimeSource: sourceKind(state.sources.devnet), + runtimeSourcePath: runtimeSourcePath(state), + storageSource: sourceKind(state.sources.devnetControlPlaneHandoff), + storageSourcePath: state.sources.devnetControlPlaneHandoff?.path ?? state.paths.devnetControlPlaneHandoffPath, + indexerSource: sourceKind(state.sources.indexer), + bridgeSource: sourceKind(state.sources.bridgeRuntimeHandoff), + }; +} + +function withResponseMetadata(result: JsonValue, method: string, context: ControlPlaneContext): JsonValue { + const object = asJsonObject(result); + if (object === null) { + return result; + } + if (object.responseProvenance !== undefined) { + return object; + } + const state = stateFor(context); + return { + ...object, + responseProvenance: responseMetadata(state, method), + }; +} + function provenanceSource(subsystem: string, path: string, schema: string, note?: string): JsonObject { return { schema: "flowmemory.control_plane.provenance_source.v0", @@ -349,8 +456,11 @@ function txIntakeRows(state: LoadedControlPlaneState): JsonObject[] { function bridgeObservationRows(state: LoadedControlPlaneState): JsonObject[] { const intakeRows = readNdjson(state.paths.bridgeObservationIntakePath); + const runtimeRows = asJsonArray(state.bridgeRuntimeHandoff?.observations) + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null); const byId = new Map(); - for (const observation of [...state.bridgeObservations, ...intakeRows]) { + for (const observation of [...state.bridgeObservations, ...runtimeRows, ...intakeRows]) { const id = stringValue(observation.observationId) ?? stringValue(asJsonObject(observation.deposit)?.depositId) ?? stableId("flowmemory.control_plane.bridge_observation.row.v0", observation); @@ -359,6 +469,12 @@ function bridgeObservationRows(state: LoadedControlPlaneState): JsonObject[] { return [...byId.values()].sort((left, right) => String(left.observationId ?? "").localeCompare(String(right.observationId ?? ""))); } +function bridgeRuntimeRows(state: LoadedControlPlaneState, key: string): JsonObject[] { + return asJsonArray(state.bridgeRuntimeHandoff?.[key]) + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null); +} + function nodeAccountRows(state: LoadedControlPlaneState): JsonObject[] { const rows: JsonObject[] = []; for (const [agentId, value] of Object.entries(devnetAgentAccounts(state))) { @@ -399,6 +515,26 @@ function nodeAccountRows(state: LoadedControlPlaneState): JsonObject[] { localOnly: true, }); } + for (const credit of bridgeCreditRows(state)) { + const accountId = stringValue(credit.accountId); + if (accountId === null || rows.some((row) => row.accountId === accountId)) { + continue; + } + rows.push({ + schema: "flowmemory.control_plane.account.v0", + accountId, + accountType: "bridge-credit", + balance: stringValue(credit.amount) ?? "0", + tokenId: stringValue(credit.token) ?? "local-test-unit", + latestBridgeCreditId: credit.creditId, + latestBaseTxHash: credit.baseTxHash ?? credit.txHash ?? null, + placeholderRecipient: isPlaceholderFlowchainRecipient(accountId), + source: "bridge-credit-projection", + noValue: stringValue(credit.sourceChainId) !== BASE_MAINNET_CHAIN_ID, + valueBearingPilot: stringValue(credit.sourceChainId) === BASE_MAINNET_CHAIN_ID, + localOnly: true, + }); + } if (rows.length === 0) { rows.push({ schema: "flowmemory.control_plane.account.v0", @@ -461,6 +597,7 @@ function bridgeDepositRows(state: LoadedControlPlaneState): JsonObject[] { schema: "flowmemory.control_plane.bridge_deposit.v0", depositId, observationId: stringValue(observation.observationId) ?? null, + replayKey: stringValue(observation.replayKey) ?? stringValue(deposit.replayKey) ?? null, status: stringValue(deposit.status) ?? "observed", sourceChainId: deposit.sourceChainId ?? null, sourceContract: deposit.sourceContract ?? null, @@ -480,34 +617,82 @@ function bridgeDepositRows(state: LoadedControlPlaneState): JsonObject[] { function bridgeCreditRows(state: LoadedControlPlaneState): JsonObject[] { const rows = new Map(); + const deposits = bridgeDepositRows(state); for (const entry of devnetProductEntries(state, ["bridgeCredits", "bridgeCreditReceipts", "runtimeBridgeCredits"], ["creditId", "bridgeCreditId", "id", "depositId"])) { const credit = entry.object; const creditId = entry.id; + const source = asJsonObject(credit.source); + const depositId = stringValue(credit.depositId); + const matchedDeposit = deposits.find((deposit) => deposit.depositId === depositId); rows.set(creditId, { schema: "flowmemory.control_plane.bridge_credit.v0", creditId, - depositId: stringValue(credit.depositId) ?? null, + depositId: depositId ?? null, + observationId: stringValue(credit.observationId) ?? stringValue(matchedDeposit?.observationId) ?? null, + replayKey: stringValue(credit.replayKey) ?? stringValue(matchedDeposit?.replayKey) ?? null, + sourceChainId: stringValue(credit.sourceChainId) ?? stringValue(source?.chainId) ?? stringValue(matchedDeposit?.sourceChainId) ?? null, + sourceContract: stringValue(credit.sourceContract) ?? stringValue(source?.contract) ?? stringValue(matchedDeposit?.sourceContract) ?? null, + txHash: stringValue(credit.txHash) ?? stringValue(credit.baseTxHash) ?? stringValue(source?.txHash) ?? stringValue(matchedDeposit?.txHash) ?? null, + baseTxHash: stringValue(credit.baseTxHash) ?? stringValue(credit.txHash) ?? stringValue(source?.txHash) ?? stringValue(matchedDeposit?.txHash) ?? null, + logIndex: stringValue(credit.logIndex) ?? stringValue(source?.logIndex) ?? stringValue(matchedDeposit?.logIndex) ?? null, accountId: stringValue(credit.accountId) ?? stringValue(credit.recipient) ?? stringValue(credit.flowchainRecipient) ?? null, amount: stringValue(credit.amount) ?? stringValue(credit.amountUnits) ?? stringValue(credit.units) ?? "0", token: credit.token ?? credit.tokenId ?? credit.assetId ?? null, status: stringValue(credit.status) ?? "local_credit", + appliedAt: firstTimestamp(credit.appliedAt, credit.creditedAt, credit.creditedAtUnixMs), credit, source: productSource(entry.sourceKey), localOnly: true, }); } - for (const deposit of bridgeDepositRows(state)) { + for (const credit of bridgeRuntimeRows(state, "credits")) { + const creditId = stringValue(credit.creditId) ?? stableId("flowmemory.control_plane.bridge_credit.runtime.v0", credit); + const source = asJsonObject(credit.source); + const depositId = stringValue(credit.depositId); + const matchedDeposit = deposits.find((deposit) => deposit.depositId === depositId || deposit.replayKey === credit.replayKey); + rows.set(creditId, { + schema: "flowmemory.control_plane.bridge_credit.v0", + creditId, + depositId: depositId ?? null, + observationId: stringValue(credit.observationId) ?? null, + replayKey: stringValue(credit.replayKey) ?? null, + sourceChainId: stringValue(credit.sourceChainId) ?? stringValue(source?.chainId) ?? stringValue(matchedDeposit?.sourceChainId) ?? null, + sourceContract: stringValue(credit.sourceContract) ?? stringValue(source?.contract) ?? stringValue(matchedDeposit?.sourceContract) ?? null, + txHash: stringValue(credit.txHash) ?? stringValue(credit.baseTxHash) ?? stringValue(source?.txHash) ?? stringValue(matchedDeposit?.txHash) ?? null, + baseTxHash: stringValue(credit.baseTxHash) ?? stringValue(credit.txHash) ?? stringValue(source?.txHash) ?? stringValue(matchedDeposit?.txHash) ?? null, + logIndex: stringValue(credit.logIndex) ?? stringValue(source?.logIndex) ?? stringValue(matchedDeposit?.logIndex) ?? null, + accountId: stringValue(credit.accountId) ?? stringValue(credit.flowchainRecipient) ?? null, + amount: stringValue(credit.amount) ?? "0", + token: credit.token ?? null, + status: stringValue(credit.status) ?? "local_credit", + appliedAt: firstTimestamp(credit.appliedAt, credit.creditedAt, credit.creditedAtUnixMs), + credit, + source: "bridge-runtime-handoff", + localOnly: true, + }); + } + + for (const deposit of deposits) { const creditId = stableId("flowmemory.control_plane.bridge_credit.v0", deposit.depositId); - if (!rows.has(creditId)) { + const hasCreditForDeposit = [...rows.values()].some((row) => row.depositId === deposit.depositId || row.observationId === deposit.observationId); + if (!rows.has(creditId) && !hasCreditForDeposit) { rows.set(creditId, { schema: "flowmemory.control_plane.bridge_credit.v0", creditId, depositId: deposit.depositId, + observationId: deposit.observationId, + replayKey: deposit.replayKey, + sourceChainId: deposit.sourceChainId, + sourceContract: deposit.sourceContract, + txHash: deposit.txHash, + baseTxHash: deposit.txHash, + logIndex: deposit.logIndex, accountId: deposit.flowchainRecipient, amount: deposit.amount ?? "0", token: deposit.token ?? null, status: deposit.status === "rejected" ? "rejected" : "pending_local_credit", + appliedAt: null, source: "bridge-deposit-projection", localOnly: true, }); @@ -517,6 +702,59 @@ function bridgeCreditRows(state: LoadedControlPlaneState): JsonObject[] { return [...rows.values()].sort((left, right) => String(left.creditId).localeCompare(String(right.creditId))); } +function bridgeCreditToken(credit: JsonObject): string { + return stringValue(credit.token) ?? "local-test-unit"; +} + +function appliedBridgeCreditsForAccount(state: LoadedControlPlaneState, accountId: string, tokenId?: string): JsonObject[] { + return bridgeCreditRows(state).filter((credit) => { + const creditAccount = stringValue(credit.accountId); + const creditToken = bridgeCreditToken(credit); + const amount = numberString(credit.amount); + return creditAccount === accountId + && statusIsApplied(credit.status) + && amount !== null + && BigInt(amount) > 0n + && (tokenId === undefined || creditToken === tokenId); + }); +} + +function firstBridgeCreditTokenForAccount(state: LoadedControlPlaneState, accountId: string): string | null { + return appliedBridgeCreditsForAccount(state, accountId) + .map((credit) => bridgeCreditToken(credit)) + .find((tokenId) => tokenId.length > 0) ?? null; +} + +function bridgeCreditAmountForAccount(state: LoadedControlPlaneState, accountId: string, tokenId: string): bigint { + return appliedBridgeCreditsForAccount(state, accountId, tokenId).reduce((sum, credit) => { + return sum + BigInt(numberString(credit.amount) ?? "0"); + }, 0n); +} + +function accountHasBaseMainnetBridgeCredit(state: LoadedControlPlaneState, accountId: string, tokenId?: string): boolean { + return appliedBridgeCreditsForAccount(state, accountId, tokenId).some((credit) => { + return stringValue(credit.sourceChainId) === BASE_MAINNET_CHAIN_ID; + }); +} + +function balanceAmountForAccount(state: LoadedControlPlaneState, accountId: string, tokenId: string): { + localAmount: bigint; + bridgeAmount: bigint; + transferDelta: bigint; + total: bigint; +} { + const tokenBalance = tokenBalanceRows(state).find((row) => row.accountId === accountId && row.tokenId === tokenId); + const localAmount = BigInt(numberString(tokenBalance?.amount) ?? "0"); + const bridgeAmount = bridgeCreditAmountForAccount(state, accountId, tokenId); + const transferDelta = transferDeltaForAccount(state, accountId, tokenId); + return { + localAmount, + bridgeAmount, + transferDelta, + total: localAmount + bridgeAmount + transferDelta, + }; +} + function withdrawalRows(state: LoadedControlPlaneState): JsonObject[] { const native = firstDevnetMap(state, ["withdrawals", "bridgeWithdrawals"]); const rows = Object.entries(native).map(([withdrawalId, value]) => ({ @@ -530,6 +768,23 @@ function withdrawalRows(state: LoadedControlPlaneState): JsonObject[] { if (rows.length > 0) { return rows; } + const runtime = bridgeRuntimeRows(state, "withdrawalIntents").map((intent) => ({ + schema: "flowmemory.control_plane.withdrawal.v0", + withdrawalId: stringValue(intent.withdrawalIntentId) ?? stringValue(intent.withdrawalId) ?? stableId("flowmemory.control_plane.withdrawal.runtime.v0", intent), + withdrawalIntentId: stringValue(intent.withdrawalIntentId) ?? null, + creditId: stringValue(intent.creditId) ?? null, + depositId: stringValue(intent.depositId) ?? null, + accountId: stringValue(intent.flowchainAccount) ?? null, + amount: stringValue(intent.amount) ?? "0", + token: intent.token ?? null, + status: stringValue(intent.status) ?? "requested", + withdrawal: intent, + source: "bridge-runtime-handoff", + localOnly: true, + })); + if (runtime.length > 0) { + return runtime; + } return bridgeCreditRows(state).slice(0, 1).map((credit) => ({ schema: "flowmemory.control_plane.withdrawal.v0", withdrawalId: stableId("flowmemory.control_plane.withdrawal.projected.v0", credit.creditId), @@ -544,6 +799,66 @@ function withdrawalRows(state: LoadedControlPlaneState): JsonObject[] { })); } +function releaseEvidenceRows(state: LoadedControlPlaneState): JsonObject[] { + const rows = bridgeRuntimeRows(state, "releaseEvidence").map((evidence) => ({ + schema: "flowmemory.control_plane.release_evidence.v0", + releaseEvidenceId: stringValue(evidence.releaseEvidenceId) ?? stableId("flowmemory.control_plane.release_evidence.v0", evidence), + withdrawalIntentId: stringValue(evidence.withdrawalIntentId) ?? null, + creditId: stringValue(evidence.creditId) ?? null, + depositId: stringValue(evidence.depositId) ?? null, + status: stringValue(evidence.status) ?? "recorded", + releaseTxHash: stringValue(evidence.releaseTxHash) ?? null, + evidence, + source: "bridge-runtime-handoff", + localOnly: true, + })).sort((left, right) => String(left.releaseEvidenceId).localeCompare(String(right.releaseEvidenceId))); + if (rows.length > 0) { + return rows; + } + return withdrawalRows(state).map((withdrawal) => ({ + schema: "flowmemory.control_plane.release_evidence.v0", + releaseEvidenceId: stableId("flowmemory.control_plane.release_evidence.projected.v0", withdrawal.withdrawalIntentId ?? withdrawal.withdrawalId), + withdrawalIntentId: stringValue(withdrawal.withdrawalIntentId) ?? stringValue(withdrawal.withdrawalId) ?? null, + creditId: stringValue(withdrawal.creditId) ?? null, + depositId: stringValue(withdrawal.depositId) ?? null, + status: "pending_operator_release_evidence", + releaseTxHash: null, + evidence: { + schema: "flowmemory.control_plane.release_evidence_projection.v1", + note: "Withdrawal intent is visible, but no release evidence record has been exported yet.", + }, + source: "bridge-withdrawal-projection", + localOnly: true, + })).sort((left, right) => String(left.releaseEvidenceId).localeCompare(String(right.releaseEvidenceId))); +} + +function replayRejectionRows(state: LoadedControlPlaneState): JsonObject[] { + const replay = asJsonObject(state.bridgeRuntimeHandoff?.replayProtection); + const duplicateReplayKeys = stringList(replay?.duplicateReplayKeys); + const replayKeys = stringList(replay?.replayKeys); + const rows = duplicateReplayKeys.map((replayKey) => ({ + schema: "flowmemory.control_plane.replay_rejection.v0", + replayRejectionId: stableId("flowmemory.control_plane.replay_rejection.v0", replayKey), + replayKey, + status: "rejected_duplicate", + reasonCode: "BRIDGE_REPLAY", + source: "bridge-runtime-handoff", + localOnly: true, + })); + if (rows.length > 0) { + return rows; + } + return [{ + schema: "flowmemory.control_plane.replay_rejection.v0", + replayRejectionId: stableId("flowmemory.control_plane.replay_rejection.idempotent.v0", replayKeys.join(",")), + replayKey: replayKeys[0] ?? null, + status: "idempotent_no_duplicate", + reasonCode: null, + source: replay === null ? "bridge-runtime-unavailable" : "bridge-runtime-handoff", + localOnly: true, + }]; +} + function tokenRows(state: LoadedControlPlaneState): JsonObject[] { const rows = devnetProductEntries( state, @@ -642,8 +957,35 @@ function tokenBalanceRows(state: LoadedControlPlaneState): JsonObject[] { }).sort((left, right) => String(left.balanceId).localeCompare(String(right.balanceId))); } +function transferDeltaForAccount(state: LoadedControlPlaneState, accountId: string, tokenId: string): bigint { + let delta = 0n; + for (const row of txIntakeRows(state)) { + const status = stringValue(row.status) ?? ""; + if (!["accepted_local", "applied", "included", "validated"].includes(status)) { + continue; + } + const payloadSummary = asJsonObject(row.payloadSummary); + const envelope = asJsonObject(row.signedEnvelope); + const payload = asJsonObject(envelope?.payload) ?? asJsonObject(envelope?.tx) ?? asJsonObject(envelope?.transaction); + const from = stringValue(payloadSummary?.from) ?? stringValue(payload?.from); + const to = stringValue(payloadSummary?.to) ?? stringValue(payload?.to); + const rowTokenId = stringValue(payloadSummary?.tokenId) ?? stringValue(payload?.tokenId) ?? "local-test-unit"; + const amount = stringValue(payloadSummary?.amount) ?? stringValue(payload?.amount); + if (rowTokenId !== tokenId || amount === null || !/^[0-9]+$/.test(amount)) { + continue; + } + if (to === accountId) { + delta += BigInt(amount); + } + if (from === accountId) { + delta -= BigInt(amount); + } + } + return delta; +} + function poolRows(state: LoadedControlPlaneState): JsonObject[] { - return devnetProductEntries( + const rows = devnetProductEntries( state, ["pools", "dexPools", "liquidityPools", "ammPools"], ["poolId", "id", "address"], @@ -664,10 +1006,33 @@ function poolRows(state: LoadedControlPlaneState): JsonObject[] { localOnly: true, }; }); + if (rows.length > 0) { + return rows; + } + if (tokenRows(state).length === 0) { + return []; + } + return [{ + schema: "flowmemory.control_plane.pool.v0", + poolId: "pool:local-test-unit:diagnostic", + token0: "local-test-unit", + token1: "local-test-unit", + reserve0: "0", + reserve1: "0", + lpSupply: "0", + status: "diagnostic_empty_projection", + pool: { + schema: "flowmemory.control_plane.pool_projection.v1", + note: "No live DEX pool is present; this row keeps the detail contract queryable with explicit provenance.", + }, + source: "deterministic-fixture:empty-dex-projection", + noValue: true, + localOnly: true, + }]; } function lpPositionRows(state: LoadedControlPlaneState): JsonObject[] { - return devnetProductEntries( + const rows = devnetProductEntries( state, ["lpPositions", "liquidityPositions", "poolPositions"], ["positionId", "lpPositionId", "id"], @@ -686,10 +1051,29 @@ function lpPositionRows(state: LoadedControlPlaneState): JsonObject[] { localOnly: true, }; }); + if (rows.length > 0) { + return rows; + } + const pool = poolRows(state)[0]; + return pool === undefined ? [] : [{ + schema: "flowmemory.control_plane.lp_position.v0", + positionId: "lp:local-test-unit:diagnostic", + poolId: pool.poolId, + accountId: "local-control-plane", + liquidity: "0", + status: "diagnostic_empty_projection", + position: { + schema: "flowmemory.control_plane.lp_position_projection.v1", + note: "No live LP position is present; this row keeps the detail contract queryable with explicit provenance.", + }, + source: "deterministic-fixture:empty-dex-projection", + noValue: true, + localOnly: true, + }]; } function swapRows(state: LoadedControlPlaneState): JsonObject[] { - return devnetProductEntries( + const rows = devnetProductEntries( state, ["swaps", "swapReceipts", "dexSwaps"], ["swapId", "receiptId", "txId", "transactionId", "id"], @@ -712,6 +1096,29 @@ function swapRows(state: LoadedControlPlaneState): JsonObject[] { localOnly: true, }; }); + if (rows.length > 0) { + return rows; + } + const pool = poolRows(state)[0]; + return pool === undefined ? [] : [{ + schema: "flowmemory.control_plane.swap.v0", + swapId: "swap:local-test-unit:diagnostic", + txId: null, + poolId: pool.poolId, + accountId: "local-control-plane", + tokenIn: pool.token0, + tokenOut: pool.token1, + amountIn: "0", + amountOut: "0", + status: "diagnostic_empty_projection", + swap: { + schema: "flowmemory.control_plane.swap_projection.v1", + note: "No live swap is present; this row keeps the detail contract queryable with explicit provenance.", + }, + source: "deterministic-fixture:empty-dex-projection", + noValue: true, + localOnly: true, + }]; } function transactionRows(state: LoadedControlPlaneState): JsonObject[] { @@ -801,6 +1208,66 @@ function transactionRows(state: LoadedControlPlaneState): JsonObject[] { } rows.push(...byHash.values()); + for (const intake of txIntakeRows(state)) { + const signedEnvelope = asJsonObject(intake.signedEnvelope) ?? {}; + const payload = asJsonObject(signedEnvelope.payload) + ?? asJsonObject(signedEnvelope.tx) + ?? asJsonObject(signedEnvelope.transaction) + ?? asJsonObject(intake.transaction) + ?? {}; + const payloadSummary = asJsonObject(intake.payloadSummary) ?? { + schema: "flowmemory.control_plane.transaction_payload_summary.v1", + payloadSchema: stringValue(payload.schema), + type: stringValue(payload.type) ?? stringValue(payload.action) ?? "unknown", + from: stringValue(payload.from) ?? null, + to: stringValue(payload.to) ?? null, + tokenId: stringValue(payload.tokenId) ?? "local-test-unit", + amount: stringValue(payload.amount) ?? null, + }; + const txId = stringValue(intake.txId) + ?? stableId("flowmemory.control_plane.transaction_intake.tx.v0", intake); + const status = stringValue(intake.status) ?? "accepted_local"; + rows.push({ + schema: "flowmemory.control_plane.transaction.v0", + transactionId: txId, + txId, + txHash: txId, + chainId: stringValue(intake.chainId) ?? stringValue(signedEnvelope.chainId) ?? currentChainId(state), + blockNumber: stringValue(intake.acceptedHeight) ?? null, + blockHash: stringValue(intake.blockHash) ?? null, + transactionIndex: null, + status, + type: stringValue(payloadSummary.type) ?? "unknown", + signer: stringValue(intake.signer) ?? stringValue(signedEnvelope.signer) ?? null, + nonce: stringValue(intake.nonce) ?? stringValue(signedEnvelope.nonce) ?? null, + envelopeMetadata: { + schema: "flowmemory.control_plane.signed_envelope_metadata.v1", + envelopeSchema: stringValue(signedEnvelope.schema), + chainId: stringValue(signedEnvelope.chainId), + signer: stringValue(signedEnvelope.signer), + nonce: stringValue(signedEnvelope.nonce), + signatureScheme: stringValue(signedEnvelope.signatureScheme), + signatureVerified: asJsonObject(intake.signatureVerification)?.verified ?? null, + }, + payloadSummary, + payload, + receipt: asJsonObject(intake.receipt) ?? { + schema: "flowmemory.control_plane.transaction_receipt.v1", + txId, + status, + reason: stringValue(intake.reason) ?? null, + acceptedHeight: stringValue(intake.acceptedHeight) ?? null, + source: "local-file-intake", + localOnly: true, + }, + receiptRef: { + txId, + method: "receipt_get", + }, + source: "local-file-intake", + localOnly: true, + }); + } return rows.sort((left, right) => { const byBlock = compareStringNumbers(stringValue(left.blockNumber) ?? "0", stringValue(right.blockNumber) ?? "0"); if (byBlock !== 0) { @@ -886,6 +1353,86 @@ function blockRows(state: LoadedControlPlaneState, includeTransactions = false): return rows.sort((left, right) => compareStringNumbers(stringValue(left.blockNumber) ?? "0", stringValue(right.blockNumber) ?? "0")); } +function eventRows(state: LoadedControlPlaneState): JsonObject[] { + const rows = state.indexer.state.observations.map((observation) => ({ + schema: "flowmemory.control_plane.event.v0", + eventId: observation.observationId, + eventType: observation.pulseTypeName ?? "FlowPulse", + status: observation.lifecycleState, + chainId: observation.chainId, + blockNumber: observation.blockNumber, + blockHash: observation.blockHash, + txId: observation.txHash, + txHash: observation.txHash, + logIndex: observation.logIndex, + accountId: observation.actor, + actor: observation.actor, + rootfieldId: observation.rootfieldId, + pulseId: observation.pulseId, + sourceContract: observation.sourceContract, + eventName: "FlowPulse", + payload: { + subject: observation.subject, + commitment: observation.commitment, + parentPulseId: observation.parentPulseId, + sequence: observation.sequence, + uri: observation.uri, + }, + source: "flowpulse-indexer", + localOnly: true, + } as JsonObject)); + + for (const rejected of state.indexer.state.rejectedLogs) { + rows.push({ + schema: "flowmemory.control_plane.event.v0", + eventId: stableId("flowmemory.control_plane.rejected_event.v0", rejected as unknown as JsonValue), + eventType: "FlowPulseRejectedLog", + status: "rejected", + chainId: rejected.chainId, + blockNumber: rejected.blockNumber, + blockHash: rejected.blockHash, + txId: rejected.txHash, + txHash: rejected.txHash, + logIndex: rejected.logIndex, + accountId: null, + eventName: "RejectedLog", + reasonCode: rejected.reasonCode, + message: rejected.message, + source: "flowpulse-indexer", + localOnly: true, + }); + } + + for (const tx of transactionRows(state)) { + const receipt = asJsonObject(tx.receipt); + if (tx.source === "local-file-intake" && receipt !== null) { + rows.push({ + schema: "flowmemory.control_plane.event.v0", + eventId: stableId("flowmemory.control_plane.transaction_intake_event.v0", tx.txId ?? tx.transactionId), + eventType: "TransactionIntake", + status: stringValue(tx.status) ?? "accepted_local", + chainId: tx.chainId, + blockNumber: tx.blockNumber ?? null, + blockHash: tx.blockHash ?? null, + txId: tx.txId ?? tx.transactionId, + txHash: tx.txHash ?? tx.txId ?? tx.transactionId, + accountId: stringValue(asJsonObject(tx.payloadSummary)?.from) ?? stringValue(tx.signer) ?? null, + receipt, + source: "local-file-intake", + localOnly: true, + }); + } + } + + return rows.sort((left, right) => { + const byBlock = compareStringNumbers(stringValue(left.blockNumber) ?? "0", stringValue(right.blockNumber) ?? "0"); + if (byBlock !== 0) { + return byBlock; + } + return String(left.eventId).localeCompare(String(right.eventId)); + }); +} + function rootfieldRows(state: LoadedControlPlaneState): JsonObject[] { const rows = new Map(); for (const bundle of state.launchCore.rootfieldBundles) { @@ -1170,23 +1717,65 @@ function finalityRows(state: LoadedControlPlaneState): JsonObject[] { function nodeStatus(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); - const blocks = devnetBlocksArray(state); - const latest = blocks[blocks.length - 1] ?? null; + const latest = latestDevnetBlock(state); + const peerCount = devnetPeers(state).length; + const runtimeKind = sourceKind(state.sources.devnet); + const missing = Object.values(state.sources).filter((source) => source.status === "missing").map((source) => source.name); return { schema: "flowmemory.control_plane.node_status.v0", nodeId: "flowmemory-local-control-plane", - status: state.devnet === null ? "degraded" : "ok", + startTime: state.launchCore.generatedAt, + uptimeSeconds: "0", + status: runtimeKind === "unavailable" ? "degraded" : "ok", runtimeStateSource: runtimeSourcePath(state), - chainId: typeof state.devnet?.chainId === "string" ? state.devnet.chainId : "flowmemory-local-devnet-v0", + runtimeSource: runtimeKind, + storageSource: sourceKind(state.sources.devnetControlPlaneHandoff), + chainId: currentChainId(state), + networkName: stringValue(asJsonObject(state.devnet?.config)?.networkId) + ?? stringValue(asJsonObject(state.devnet?.genesisConfig)?.networkId) + ?? "flowmemory-private-local", latestBlockNumber: latest?.blockNumber ?? null, latestBlockHash: latest?.blockHash ?? null, - peerCount: devnetPeers(state).length, + stateRoot: latestStateRoot(state), + dataDirectory: "devnet/local", + listeningAddresses: [ + { + protocol: "http-json-rpc", + address: "127.0.0.1:8787", + redacted: false, + }, + ], + peerCount, mempoolSize: mempoolRows(state).length, + syncMode: "local-file-runtime-first", + syncTarget: latest?.blockNumber ?? "0", + catchUpState: runtimeKind === "live" ? "caught_up" : "runtime_unavailable_using_fallback", + lastError: missing.length === 0 ? null : `missing optional sources: ${missing.join(", ")}`, noValue: true, localOnly: true, }; } +function syncStatus(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const latest = latestDevnetBlock(state); + const runtimeKind = sourceKind(state.sources.devnet); + return { + schema: "flowmemory.control_plane.sync_status.v0", + chainId: currentChainId(state), + syncMode: "local-file-runtime-first", + source: runtimeKind, + targetHeight: stringValue(latest?.blockNumber) ?? latestBlock(state).blockNumber, + currentHeight: stringValue(latest?.blockNumber) ?? latestBlock(state).blockNumber, + finalizedHeight: finalizedBlock(state), + catchUpState: runtimeKind === "live" ? "caught_up" : "degraded_fallback", + liveRuntimeAvailable: runtimeKind === "live", + fallbackUsed: runtimeKind !== "live", + lastError: runtimeKind === "live" ? null : "live runtime state file is unavailable; using deterministic committed state", + localOnly: true, + }; +} + function peerList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "peer_list"); @@ -1220,6 +1809,16 @@ function health(_params: JsonValue | undefined, context: ControlPlaneContext): J service: "flowmemory-control-plane-v0", status: criticalMissing.length === 0 ? "ok" : "degraded", localOnly: true, + routes: [ + "GET /health", + "GET /state", + "GET /bridge/status", + "GET /bridge/deposits", + "GET /bridge/credits", + "GET /bridge/credit-status", + "POST /rpc", + "POST /transfer/send", + ], checks: { launchCore: state.sources.launchCore.status, indexer: state.sources.indexer.status, @@ -1254,16 +1853,33 @@ function health(_params: JsonValue | undefined, context: ControlPlaneContext): J function chainStatus(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const latest = latestBlock(state); + const latestDevnet = latestDevnetBlock(state); + const finalizedHeight = finalizedBlock(state); + const finalized = blockRows(state).find((block) => String(block.blockNumber) === finalizedHeight); return { schema: "flowmemory.control_plane.chain_status.v0", - chainId: typeof state.devnet?.chainId === "string" ? state.devnet.chainId : "flowmemory-local-alpha", + chainId: currentChainId(state), + networkName: stringValue(asJsonObject(state.devnet?.config)?.networkId) + ?? stringValue(asJsonObject(state.devnet?.genesisConfig)?.networkId) + ?? "flowmemory-private-local", + genesisHash: stringValue(state.devnet?.genesisHash) + ?? stringValue(asJsonObject(state.devnet?.config)?.genesisHash) + ?? stringValue(asJsonObject(state.devnet?.genesisConfig)?.genesisHash) + ?? ZERO_ROOT, settlementContext: "local no-value devnet runtime over FlowPulse fixtures", environment: "local-devnet", source: "local-runtime-first", + runtimeSource: sourceKind(state.sources.devnet), + storageSource: sourceKind(state.sources.devnetControlPlaneHandoff), currentBlock: latest.blockNumber, currentBlockHash: latest.blockHash, - finalizedBlock: finalizedBlock(state), + latestHeight: stringValue(latestDevnet?.blockNumber) ?? latest.blockNumber, + latestBlockHash: stringValue(latestDevnet?.blockHash) ?? latest.blockHash, + finalizedBlock: finalizedHeight, + finalizedHeight, + finalizedHash: stringValue(finalized?.blockHash) ?? ZERO_ROOT, + stateRoot: latestStateRoot(state) ?? ZERO_ROOT, generatedAt: state.launchCore.generatedAt, localOnly: true, counts: { @@ -1308,6 +1924,7 @@ function chainStatus(_params: JsonValue | undefined, context: ControlPlaneContex "block_reads", "transaction_reads", "local_transaction_file_intake", + "local_transfer_send", "mempool_reads", "account_reads", "balance_reads", @@ -1326,6 +1943,7 @@ function chainStatus(_params: JsonValue | undefined, context: ControlPlaneContex "bridge_observation_file_intake", "bridge_deposit_reads", "bridge_credit_reads", + "bridge_credit_status_reads", "withdrawal_reads", "real_value_pilot_reads", "real_value_pilot_operator_steps", @@ -1436,7 +2054,7 @@ function blockGet(params: JsonValue | undefined, context: ControlPlaneContext): return candidate.blockHash === key || String(candidate.blockNumber) === key; }); if (block === undefined) { - throw objectNotFound(`block not found: ${key}`, { id: key }); + throw objectNotFound(`block not found: ${key}`, { id: key }, "UNKNOWN_BLOCK"); } return { schema: "flowmemory.control_plane.block_detail.v0", @@ -1483,7 +2101,7 @@ function transactionGet(params: JsonValue | undefined, context: ControlPlaneCont return candidate.transactionId === key || candidate.txHash === key; }); if (transaction === undefined) { - throw objectNotFound(`transaction not found: ${key}`, { id: key }); + throw objectNotFound(`transaction not found: ${key}`, { id: key }, "UNKNOWN_TX"); } return { schema: "flowmemory.control_plane.transaction_detail.v0", @@ -1499,6 +2117,46 @@ function transactionGet(params: JsonValue | undefined, context: ControlPlaneCont }; } +function eventList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "event_list"); + const limit = pageLimit(objectParams); + const blockNumber = optionalString(objectParams, "blockNumber"); + const blockHash = optionalString(objectParams, "blockHash"); + const txId = optionalString(objectParams, "txId") ?? optionalString(objectParams, "txHash"); + const accountId = optionalString(objectParams, "accountId") ?? optionalString(objectParams, "actor"); + const eventType = optionalString(objectParams, "eventType") ?? optionalString(objectParams, "type"); + const rows = eventRows(state) + .filter((event) => blockNumber === undefined || String(event.blockNumber) === blockNumber) + .filter((event) => blockHash === undefined || event.blockHash === blockHash) + .filter((event) => txId === undefined || event.txId === txId || event.txHash === txId) + .filter((event) => accountId === undefined || event.accountId === accountId || event.actor === accountId) + .filter((event) => eventType === undefined || event.eventType === eventType || event.eventName === eventType) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.event_list.v0", + count: rows.length, + nextCursor: null, + events: rows, + localOnly: true, + }; +} + +function eventGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "event_get"); + const key = requiredString(objectParams, ["eventId", "observationId", "txId", "txHash"], "event_get"); + const event = eventRows(state).find((row) => row.eventId === key || row.txId === key || row.txHash === key); + if (event === undefined) { + throw objectNotFound(`event not found: ${key}`, { id: key }); + } + return { + schema: "flowmemory.control_plane.event_detail.v0", + event, + localOnly: true, + }; +} + function mempoolList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "mempool_list"); @@ -1541,25 +2199,49 @@ function parseSignedEnvelope(value: JsonValue | undefined, label: string): JsonO function signedEnvelopeForSubmit(params: JsonObject): JsonObject { if (params.transaction !== undefined || params.tx !== undefined || params.txs !== undefined) { - throw invalidParams("transaction_submit accepts signed envelopes only; use signedTransaction or signedEnvelope"); + throw unsignedTransaction("transaction_submit accepts signed envelopes only; use signedTransaction or signedEnvelope"); } const envelope = parseSignedEnvelope(params.signedEnvelope, "signedEnvelope") ?? parseSignedEnvelope(params.signedTransaction, "signedTransaction"); if (envelope === null) { - throw invalidParams("transaction_submit requires signedTransaction or signedEnvelope"); + throw unsignedTransaction("transaction_submit requires signedTransaction or signedEnvelope"); } - const transaction = asJsonObject(envelope.tx) ?? asJsonObject(envelope.transaction) ?? asJsonObject(envelope.payload); - const signature = envelope.signature ?? envelope.signatures ?? envelope.proof ?? envelope.authorization; - const hasSignature = typeof signature === "string" - || Array.isArray(signature) - || asJsonObject(signature) !== null; - if (transaction === null || !hasSignature) { - throw invalidParams("signed envelope must include tx/transaction/payload and signature/signatures/proof/authorization"); - } return envelope; } +function currentNonceForSigner(state: LoadedControlPlaneState, signer: string): bigint { + let maxSeen = -1n; + for (const row of txIntakeRows(state)) { + const rowSigner = stringValue(row.signer) ?? stringValue(asJsonObject(row.signedEnvelope)?.signer); + const rowNonce = stringValue(row.nonce) ?? stringValue(asJsonObject(row.signedEnvelope)?.nonce); + const status = stringValue(row.status) ?? ""; + if (rowSigner === signer && rowNonce !== null && /^[0-9]+$/.test(rowNonce) && !status.includes("rejected")) { + const nonce = BigInt(rowNonce); + if (nonce > maxSeen) { + maxSeen = nonce; + } + } + } + return maxSeen + 1n; +} + +function throwEnvelopeValidationFailure(failure: ReturnType): never { + if ("txId" in failure) { + throw new Error("expected validation failure"); + } + switch (failure.code) { + case "UNSIGNED_TRANSACTION": + throw unsignedTransaction(failure.message, failure.details); + case "BAD_SIGNATURE": + throw badSignature(failure.message, failure.details); + case "WRONG_CHAIN_ID": + throw wrongChainId(failure.message, failure.details); + default: + throw invalidParams(failure.message, failure.details); + } +} + function transactionSubmit(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "transaction_submit"); @@ -1572,27 +2254,154 @@ function transactionSubmit(params: JsonValue | undefined, context: ControlPlaneC if (finding !== null) { throw secretRejected("transaction intake contained secret-shaped material", finding); } - const intakeId = stableId("flowmemory.control_plane.transaction_intake.v0", intakePayload); + const validation = validateSignedEnvelope(signedEnvelope, currentChainId(state)); + if (!("txId" in validation)) { + throwEnvelopeValidationFailure(validation); + } + if (transactionRows(state).some((tx) => tx.txId === validation.txId || tx.transactionId === validation.txId || tx.txHash === validation.txId)) { + throw duplicateTx(`duplicate transaction: ${validation.txId}`, { txId: validation.txId }); + } + const expectedNonce = currentNonceForSigner(state, validation.signer); + const submittedNonce = BigInt(validation.nonce); + if (submittedNonce < expectedNonce) { + throw staleNonce(`stale nonce ${validation.nonce}; expected ${expectedNonce.toString()}`, { + signer: validation.signer, + submittedNonce: validation.nonce, + expectedNonce: expectedNonce.toString(), + }); + } + if (submittedNonce > expectedNonce) { + throw invalidParams(`nonce gap ${validation.nonce}; expected ${expectedNonce.toString()}`, { + signer: validation.signer, + submittedNonce: validation.nonce, + expectedNonce: expectedNonce.toString(), + }); + } + + const intakeId = stableId("flowmemory.control_plane.transaction_intake.v1", intakePayload); + const latest = latestDevnetBlock(state); const row: JsonObject = { - schema: "flowmemory.control_plane.transaction_intake.v0", + schema: "flowmemory.control_plane.transaction_intake.v1", intakeId, - txId: stableId("flowmemory.control_plane.transaction.local_tx_id.v0", intakePayload), + txId: validation.txId, + chainId: validation.chainId, + signer: validation.signer, + nonce: validation.nonce, receivedAt: "2026-05-13T00:00:00.000Z", status: "accepted_local", + acceptedHeight: stringValue(latest?.blockNumber) ?? null, intakeMode: "local-file", runtimeIntakePath: state.paths.txIntakePath, + payloadSummary: validation.payloadSummary, + signatureVerification: validation.signatureVerification, + receipt: { + schema: "flowmemory.control_plane.transaction_receipt.v1", + txId: validation.txId, + status: "accepted_local", + acceptedHeight: stringValue(latest?.blockNumber) ?? null, + reason: null, + source: "local-file-intake", + localOnly: true, + }, ...intakePayload, localOnly: true, }; appendNdjson(state.paths.txIntakePath, row); return { - schema: "flowmemory.control_plane.transaction_submit_result.v0", + schema: "flowmemory.control_plane.transaction_submit_result.v1", accepted: true, intakeId, txId: row.txId, status: row.status, + intakeStatus: row.status, + acceptedHeight: row.acceptedHeight, + signatureVerification: validation.signatureVerification, + payloadSummary: validation.payloadSummary, forwardedTo: "local-file-intake", runtimeIntakePath: state.paths.txIntakePath, + source: "local-file-intake", + localOnly: true, + }; +} + +function localSignerForAccount(accountId: string): string { + return /^0x[0-9a-fA-F]{40}$/.test(accountId) || /^0x[0-9a-fA-F]{64}$/.test(accountId) + ? accountId + : stableId("flowmemory.control_plane.local_transfer_signer.v1", accountId); +} + +function transferSend(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "transfer_send"); + const from = requiredString(objectParams, ["from", "fromAccountId", "accountId"], "transfer_send"); + const to = requiredString(objectParams, ["to", "toAccountId", "recipient"], "transfer_send"); + const amount = requiredString(objectParams, ["amount", "units"], "transfer_send"); + if (!/^[0-9]+$/.test(amount) || BigInt(amount) <= 0n) { + throw invalidParams("transfer_send amount must be a positive integer string", { amount }); + } + if (from === to) { + throw invalidParams("transfer_send requires distinct from and to accounts", { from, to }); + } + if (isPlaceholderFlowchainRecipient(from) || isPlaceholderFlowchainRecipient(to)) { + throw invalidParams("transfer_send refuses the placeholder FlowChain recipient", { from, to }); + } + const tokenId = optionalString(objectParams, "tokenId") + ?? firstBridgeCreditTokenForAccount(state, from) + ?? "local-test-unit"; + const before = balanceAmountForAccount(state, from, tokenId); + if (before.total < BigInt(amount)) { + throw invalidParams("transfer_send amount exceeds spendable balance", { + from, + tokenId, + spendableBalance: before.total.toString(), + amount, + }); + } + const signer = optionalString(objectParams, "signer") ?? localSignerForAccount(from); + const nonce = optionalString(objectParams, "nonce") ?? currentNonceForSigner(state, signer).toString(); + const signedEnvelope = buildLocalSignedTransferEnvelope({ + chainId: currentChainId(state), + signer, + nonce, + from, + to, + tokenId, + amount, + memo: optionalString(objectParams, "memo") ?? "bridge-credit-transfer-test", + }); + const submit = transactionSubmit({ + signedEnvelope, + submittedBy: optionalString(objectParams, "submittedBy") ?? "control-plane-transfer-send", + }, context) as JsonObject; + const afterFrom = balanceAmountForAccount(state, from, tokenId); + const afterTo = balanceAmountForAccount(state, to, tokenId); + return { + schema: "flowmemory.control_plane.transfer_send_result.v1", + accepted: true, + txId: submit.txId, + status: submit.status, + from, + to, + tokenId, + amount, + signer, + nonce, + receipt: { + schema: "flowmemory.control_plane.transfer_receipt.v1", + txId: submit.txId, + status: submit.status, + from, + to, + tokenId, + amount, + balanceBefore: before.total.toString(), + balanceAfter: afterFrom.total.toString(), + recipientBalanceAfter: afterTo.total.toString(), + source: "local-file-intake", + localOnly: true, + }, + transactionSubmit: submit, + noBaseReleaseBroadcast: true, localOnly: true, }; } @@ -1617,7 +2426,7 @@ function accountGet(params: JsonValue | undefined, context: ControlPlaneContext) const accountId = requiredString(objectParams, ["accountId", "agentId", "operatorId"], "account_get"); const account = nodeAccountRows(state).find((row) => row.accountId === accountId || row.keyReferenceId === accountId); if (account === undefined) { - throw objectNotFound(`account not found: ${accountId}`, { accountId }); + throw objectNotFound(`account not found: ${accountId}`, { accountId }, "UNKNOWN_ACCOUNT"); } return { schema: "flowmemory.control_plane.account_detail.v0", @@ -1637,16 +2446,30 @@ function balanceGet(params: JsonValue | undefined, context: ControlPlaneContext) const state = stateFor(context); const objectParams = asObjectParams(params, "balance_get"); const accountId = requiredString(objectParams, ["accountId", "agentId", "operatorId"], "balance_get"); + const tokenId = optionalString(objectParams, "tokenId") + ?? firstBridgeCreditTokenForAccount(state, accountId) + ?? "local-test-unit"; const account = nodeAccountRows(state).find((row) => row.accountId === accountId || row.keyReferenceId === accountId); - if (account === undefined) { - throw objectNotFound(`balance account not found: ${accountId}`, { accountId }); + const tokenBalance = tokenBalanceRows(state).find((row) => row.accountId === accountId && row.tokenId === tokenId); + const amounts = balanceAmountForAccount(state, accountId, tokenId); + if (account === undefined && tokenBalance === undefined && amounts.bridgeAmount === 0n && amounts.transferDelta === 0n) { + throw objectNotFound(`balance account not found: ${accountId}`, { accountId }, "UNKNOWN_ACCOUNT"); } + const valueBearingPilot = accountHasBaseMainnetBridgeCredit(state, accountId, tokenId); return { schema: "flowmemory.control_plane.balance.v0", - accountId: account.accountId, - amount: "0", - unit: "no-value-local-credit", - noValue: true, + accountId, + tokenId, + amount: amounts.total.toString(), + spendableBalance: amounts.total.toString(), + baseAmount: amounts.localAmount.toString(), + bridgeCreditAmount: amounts.bridgeAmount.toString(), + pendingAcceptedDelta: amounts.transferDelta.toString(), + unit: tokenId, + source: amounts.bridgeAmount > 0n ? "bridge-credit-plus-local-intake" : tokenBalance === undefined ? "local-file-intake-projection" : tokenBalance.source, + noValue: !valueBearingPilot, + valueBearingPilot, + cappedOwnerTesting: valueBearingPilot, localOnly: true, }; } @@ -1674,11 +2497,23 @@ function tokenGet(params: JsonValue | undefined, context: ControlPlaneContext): const key = requiredString(objectParams, ["tokenId", "assetId", "symbol"], "token_get"); const token = tokenRows(state).find((row) => row.tokenId === key || row.symbol === key || asJsonObject(row.token)?.assetId === key); if (token === undefined) { - throw objectNotFound(`token not found: ${key}`, { id: key }); + throw objectNotFound(`token not found: ${key}`, { id: key }, "UNKNOWN_TOKEN"); } return { schema: "flowmemory.control_plane.token_detail.v0", token, + supply: token.totalSupply ?? "0", + holderCount: tokenBalanceRows(state).filter((balance) => balance.tokenId === token.tokenId).length, + transferHistory: transactionRows(state) + .filter((tx) => asJsonObject(tx.payloadSummary)?.tokenId === token.tokenId) + .map((tx) => ({ + txId: tx.txId ?? tx.transactionId, + status: tx.status, + from: asJsonObject(tx.payloadSummary)?.from, + to: asJsonObject(tx.payloadSummary)?.to, + amount: asJsonObject(tx.payloadSummary)?.amount, + })), + launchTransaction: transactionRows(state).find((tx) => asJsonObject(tx.payloadSummary)?.type === "token_launch" && asJsonObject(tx.payloadSummary)?.tokenId === token.tokenId)?.txId ?? null, provenance: { sources: [provenanceSource("devnet", "devnet/local/state.json", "flowmemory.local_devnet.token.v0")], }, @@ -1747,7 +2582,7 @@ function poolGet(params: JsonValue | undefined, context: ControlPlaneContext): J const poolId = requiredString(objectParams, ["poolId", "address"], "pool_get"); const pool = poolRows(state).find((row) => row.poolId === poolId || asJsonObject(row.pool)?.address === poolId); if (pool === undefined) { - throw objectNotFound(`pool not found: ${poolId}`, { poolId }); + throw objectNotFound(`pool not found: ${poolId}`, { poolId }, "UNKNOWN_POOL"); } return { schema: "flowmemory.control_plane.pool_detail.v0", @@ -2062,7 +2897,7 @@ function artifactAvailabilityGet(params: JsonValue | undefined, context: Control function receiptGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "receipt_get"); - const key = requiredString(objectParams, ["receiptId", "observationId", "reportId"], "receipt_get"); + const key = requiredString(objectParams, ["receiptId", "observationId", "reportId", "txId", "txHash", "transactionId"], "receipt_get"); const receipt = receiptByAnyId(state, key); if (receipt !== undefined) { @@ -2094,7 +2929,32 @@ function receiptGet(params: JsonValue | undefined, context: ControlPlaneContext) }; } - throw objectNotFound(`receipt not found: ${key}`, { id: key }); + const transaction = transactionRows(state).find((candidate) => { + return candidate.txId === key || candidate.transactionId === key || candidate.txHash === key; + }); + const txReceipt = asJsonObject(transaction?.receipt); + if (transaction !== undefined && txReceipt !== null) { + return { + schema: "flowmemory.control_plane.receipt.v0", + receipt: txReceipt, + transaction: { + txId: transaction.txId ?? transaction.transactionId, + status: transaction.status, + payloadSummary: transaction.payloadSummary, + blockNumber: transaction.blockNumber, + blockHash: transaction.blockHash, + }, + signal: null, + transition: null, + verifierReport: null, + provenance: { + sources: [provenanceSource("control-plane", "devnet/local/intake/transactions.ndjson", "flowmemory.control_plane.transaction_receipt.v1")], + }, + localOnly: true, + }; + } + + throw objectNotFound(`receipt not found: ${key}`, { id: key }, "UNKNOWN_TX"); } function receiptList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { @@ -2538,6 +3398,206 @@ function finalityList(params: JsonValue | undefined, context: ControlPlaneContex }; } +function finalityStatus(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const finalizedHeight = finalizedBlock(state); + const finalized = blockRows(state).find((block) => String(block.blockNumber) === finalizedHeight); + const rows = finalityRows(state); + return { + schema: "flowmemory.control_plane.finality_status.v0", + chainId: currentChainId(state), + finalizedHeight, + finalizedHash: stringValue(finalized?.blockHash) ?? ZERO_ROOT, + latestHeight: stringValue(latestDevnetBlock(state)?.blockNumber) ?? latestBlock(state).blockNumber, + finalityState: sourceKind(state.sources.devnet) === "live" ? "live_local" : "degraded_fallback", + pendingCount: rows.filter((row) => row.status === "local-pending").length, + finalizedCount: rows.filter((row) => row.status === "local-finalized").length, + rejectedCount: rows.filter((row) => row.status === "local-rejected").length, + source: sourceKind(state.sources.devnet), + localOnly: true, + }; +} + +function bridgeConfigGet(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const handoff = state.bridgeRuntimeHandoff; + const replay = asJsonObject(handoff?.replayProtection); + return { + schema: "flowmemory.control_plane.bridge_config.v0", + mode: stringValue(handoff?.mode) ?? "mock", + productionReady: false, + cappedOwnerTesting: true, + pauseStatus: "not_paused_local", + pilotCaps: { + maxUsd: asJsonObject(bridgeObservationRows(state)[0]?.guardrails)?.maxUsd ?? null, + publicBridgeReady: false, + }, + replayProtection: { + strategy: stringValue(replay?.strategy) ?? "source-chain-contract-tx-log-deposit", + replayKeyCount: stringList(replay?.replayKeys).length, + duplicateReplayKeyCount: stringList(replay?.duplicateReplayKeys).length, + }, + runtimeIntake: asJsonObject(handoff?.runtimeIntake) ?? { + status: "unavailable", + consumer: "flowchain-runtime-agent", + expectedPath: state.paths.bridgeRuntimeHandoffPath, + }, + source: sourceKind(state.sources.bridgeRuntimeHandoff), + localOnly: true, + }; +} + +function bridgeStatus(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const status = bridgeCreditStatus(undefined, context) as JsonObject; + return { + schema: "flowmemory.control_plane.bridge_status.v0", + readiness: status.readinessLabel === "LIVE PILOT" ? "live_pilot" : status.readinessLabel === "LOCAL ONLY" ? "local_only" : "not_ready", + readinessLabel: status.readinessLabel, + bridgeSource: sourceKind(state.sources.bridgeRuntimeHandoff), + observationCount: bridgeObservationRows(state).length, + creditCount: bridgeCreditRows(state).length, + withdrawalIntentCount: withdrawalRows(state).length, + releaseEvidenceCount: releaseEvidenceRows(state).length, + replayRejectionCount: replayRejectionRows(state).length, + envValuesExposed: false, + lastError: sourceKind(state.sources.bridgeRuntimeHandoff) === "unavailable" ? "bridge runtime handoff unavailable" : null, + localOnly: true, + }; +} + +function latestTransferForAccount(state: LoadedControlPlaneState, accountId: string | null, tokenId: string | null): JsonObject | null { + if (accountId === null) { + return null; + } + const transfers = transactionRows(state).filter((tx) => { + const payload = asJsonObject(tx.payloadSummary) ?? asJsonObject(tx.payload) ?? {}; + return stringValue(payload.from) === accountId + && (tokenId === null || stringValue(payload.tokenId) === tokenId); + }); + return transfers[transfers.length - 1] ?? null; +} + +function selectBridgeCreditStatusTarget( + state: LoadedControlPlaneState, + params: JsonObject, +): { credit: JsonObject | null; deposit: JsonObject | null; matchedCredits: JsonObject[]; matchedDeposits: JsonObject[] } { + const txHash = optionalString(params, "baseTxHash") ?? optionalString(params, "txHash"); + const accountId = optionalString(params, "accountId") ?? optionalString(params, "flowchainAccount"); + const creditId = optionalString(params, "creditId"); + const depositId = optionalString(params, "depositId"); + const credits = bridgeCreditRows(state); + const deposits = bridgeDepositRows(state); + const matchedCredits = credits.filter((credit) => { + return (txHash === undefined || credit.txHash === txHash || credit.baseTxHash === txHash) + && (accountId === undefined || credit.accountId === accountId) + && (creditId === undefined || credit.creditId === creditId) + && (depositId === undefined || credit.depositId === depositId); + }); + const matchedDeposits = deposits.filter((deposit) => { + return (txHash === undefined || deposit.txHash === txHash) + && (accountId === undefined || deposit.flowchainRecipient === accountId) + && (depositId === undefined || deposit.depositId === depositId); + }); + const credit = matchedCredits.find((candidate) => statusIsApplied(candidate.status)) + ?? matchedCredits[0] + ?? credits.find((candidate) => statusIsApplied(candidate.status)) + ?? credits[0] + ?? null; + const deposit = matchedDeposits.find((candidate) => candidate.depositId === credit?.depositId) + ?? deposits.find((candidate) => candidate.depositId === credit?.depositId || candidate.txHash === credit?.txHash) + ?? matchedDeposits[0] + ?? null; + return { credit, deposit, matchedCredits, matchedDeposits }; +} + +function bridgeCreditStatus(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_credit_status"); + const { credit, deposit, matchedCredits, matchedDeposits } = selectBridgeCreditStatusTarget(state, objectParams); + const accountId = stringValue(credit?.accountId) ?? stringValue(deposit?.flowchainRecipient); + const tokenId = stringValue(credit?.token) ?? stringValue(deposit?.token) ?? "local-test-unit"; + const amount = numberString(credit?.amount) ?? numberString(deposit?.amount) ?? "0"; + const status = stringValue(credit?.status) ?? (deposit === null ? "missing" : "observed"); + const applied = statusIsApplied(status); + const sourceChainId = stringValue(credit?.sourceChainId) ?? stringValue(deposit?.sourceChainId); + const baseTxHash = stringValue(credit?.baseTxHash) ?? stringValue(credit?.txHash) ?? stringValue(deposit?.txHash); + const firstObservedAt = firstTimestamp(deposit?.observedAt, asJsonObject(deposit?.observation)?.observedAt); + const firstUsableAt = firstTimestamp(credit?.appliedAt, asJsonObject(credit?.credit)?.appliedAt, firstObservedAt); + const balance = accountId === null + ? null + : balanceAmountForAccount(state, accountId, tokenId); + const transfer = latestTransferForAccount(state, accountId, tokenId); + const runtimeLive = sourceKind(state.sources.devnet) === "live"; + const bridgeSource = sourceKind(state.sources.bridgeRuntimeHandoff); + const realBridgeCredit = runtimeLive + && sourceChainId === BASE_MAINNET_CHAIN_ID + && applied + && BigInt(amount) > 0n + && accountId !== null + && !isPlaceholderFlowchainRecipient(accountId); + const usingFixtureFallback = bridgeSource !== "live" || sourceChainId !== BASE_MAINNET_CHAIN_ID || isPlaceholderFlowchainRecipient(accountId ?? undefined); + + return { + schema: "flowmemory.control_plane.bridge_credit_status.v1", + lookup: { + baseTxHash: optionalString(objectParams, "baseTxHash") ?? optionalString(objectParams, "txHash") ?? null, + accountId: optionalString(objectParams, "accountId") ?? optionalString(objectParams, "flowchainAccount") ?? null, + creditId: optionalString(objectParams, "creditId") ?? null, + depositId: optionalString(objectParams, "depositId") ?? null, + }, + readinessLabel: realBridgeCredit ? "LIVE PILOT" : usingFixtureFallback ? "NOT READY" : "LOCAL ONLY", + exposureLabel: "LOCAL ONLY", + livePilot: realBridgeCredit, + localOnly: true, + usingFixtureFallback, + source: { + runtime: sourceKind(state.sources.devnet), + bridge: bridgeSource, + runtimePath: runtimeSourcePath(state), + bridgePath: state.sources.bridgeRuntimeHandoff?.path ?? state.paths.bridgeRuntimeHandoffPath, + }, + baseTxHash, + confirmationStatus: deposit === null + ? "not_observed" + : sourceChainId === BASE_MAINNET_CHAIN_ID + ? "base_observed" + : "mock_or_test_observed", + lifecycleStatus: { + observed: deposit === null ? "missing" : "observed", + queued: credit === null ? "not_queued" : "queued", + applied: applied ? "applied" : status, + idempotent: stringValue(credit?.rejectionReason) === "duplicate_replay_key" ? "duplicate_rejected" : "unique_or_idempotent", + }, + creditedAccount: accountId, + tokenId, + amount, + spendableBalance: balance === null ? null : balance.total.toString(), + balanceBreakdown: balance === null ? null : { + localAmount: balance.localAmount.toString(), + bridgeCreditAmount: balance.bridgeAmount.toString(), + pendingAcceptedDelta: balance.transferDelta.toString(), + }, + transferActionStatus: transfer === null ? "not_run" : stringValue(transfer.status) ?? "accepted_local", + latestTransferReceipt: transfer === null ? null : { + txId: transfer.txId ?? transfer.transactionId, + status: transfer.status, + receipt: transfer.receipt, + }, + firstUsableAt, + latencyMs: latencyMs(firstObservedAt, firstUsableAt), + placeholderRecipient: isPlaceholderFlowchainRecipient(accountId ?? undefined), + matchedCounts: { + credits: matchedCredits.length, + deposits: matchedDeposits.length, + }, + credit, + deposit, + noBaseReleaseBroadcast: true, + cappedOwnerTesting: true, + }; +} + function bridgeObservationList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "bridge_observation_list"); @@ -2578,6 +3638,10 @@ function bridgeObservationSubmit(params: JsonValue | undefined, context: Control if (finding !== null) { throw secretRejected("bridge observation intake contained secret-shaped material", finding); } + const replayKey = stringValue(observation.replayKey); + if (replayKey !== null && bridgeObservationRows(state).some((row) => row.replayKey === replayKey)) { + throw bridgeReplay(`bridge replay rejected: ${replayKey}`, { replayKey }); + } const observationId = stringValue(observation.observationId) ?? stableId("flowmemory.control_plane.bridge_observation_intake.v0", observation); const row: JsonObject = { @@ -2647,8 +3711,15 @@ function bridgeCreditList(params: JsonValue | undefined, context: ControlPlaneCo function bridgeCreditGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "bridge_credit_get"); - const key = requiredString(objectParams, ["creditId", "depositId", "accountId"], "bridge_credit_get"); - const credit = bridgeCreditRows(state).find((row) => row.creditId === key || row.depositId === key || row.accountId === key); + const key = requiredString(objectParams, ["creditId", "depositId", "accountId", "flowchainAccount", "txHash", "baseTxHash"], "bridge_credit_get"); + const matches = bridgeCreditRows(state).filter((row) => + row.creditId === key + || row.depositId === key + || row.accountId === key + || row.txHash === key + || row.baseTxHash === key, + ); + const credit = matches.find((row) => statusIsApplied(row.status)) ?? matches[0]; if (credit === undefined) { throw objectNotFound(`bridge credit not found: ${key}`, { id: key }); } @@ -2659,6 +3730,97 @@ function bridgeCreditGet(params: JsonValue | undefined, context: ControlPlaneCon }; } +function withdrawalIntentList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "withdrawal_intent_list"); + const limit = pageLimit(objectParams); + const rows = withdrawalRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.withdrawal_intent_list.v0", + count: rows.length, + nextCursor: null, + withdrawalIntents: rows, + localOnly: true, + }; +} + +function withdrawalIntentGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "withdrawal_intent_get"); + const key = requiredString(objectParams, ["withdrawalIntentId", "withdrawalId", "creditId", "depositId", "accountId"], "withdrawal_intent_get"); + const withdrawal = withdrawalRows(state).find((row) => { + return row.withdrawalIntentId === key || row.withdrawalId === key || row.creditId === key || row.depositId === key || row.accountId === key; + }); + if (withdrawal === undefined) { + throw objectNotFound(`withdrawal intent not found: ${key}`, { id: key }); + } + return { + schema: "flowmemory.control_plane.withdrawal_intent_detail.v0", + withdrawalIntent: withdrawal, + localOnly: true, + }; +} + +function releaseEvidenceList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "release_evidence_list"); + const limit = pageLimit(objectParams); + const rows = releaseEvidenceRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.release_evidence_list.v0", + count: rows.length, + nextCursor: null, + releaseEvidence: rows, + localOnly: true, + }; +} + +function releaseEvidenceGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "release_evidence_get"); + const key = requiredString(objectParams, ["releaseEvidenceId", "withdrawalIntentId", "creditId", "depositId"], "release_evidence_get"); + const evidence = releaseEvidenceRows(state).find((row) => { + return row.releaseEvidenceId === key || row.withdrawalIntentId === key || row.creditId === key || row.depositId === key; + }); + if (evidence === undefined) { + throw objectNotFound(`release evidence not found: ${key}`, { id: key }); + } + return { + schema: "flowmemory.control_plane.release_evidence_detail.v0", + releaseEvidence: evidence, + localOnly: true, + }; +} + +function replayRejectionList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "replay_rejection_list"); + const limit = pageLimit(objectParams); + const rows = replayRejectionRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.replay_rejection_list.v0", + count: rows.length, + nextCursor: null, + replayRejections: rows, + localOnly: true, + }; +} + +function replayRejectionGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "replay_rejection_get"); + const key = requiredString(objectParams, ["replayRejectionId", "replayKey"], "replay_rejection_get"); + const rejection = replayRejectionRows(state).find((row) => row.replayRejectionId === key || row.replayKey === key); + if (rejection === undefined) { + throw objectNotFound(`replay rejection not found: ${key}`, { id: key }); + } + return { + schema: "flowmemory.control_plane.replay_rejection_detail.v0", + replayRejection: rejection, + localOnly: true, + }; +} + function withdrawalList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "withdrawal_list"); @@ -2983,7 +4145,9 @@ export const CONTROL_PLANE_METHODS: Record = health, node_status: nodeStatus, peer_list: peerList, + sync_status: syncStatus, chain_status: chainStatus, + finality_status: finalityStatus, pilot_status: pilotStatus, pilot_deposit_observation_list: pilotDepositObservationList, pilot_credit_list: pilotCreditList, @@ -3000,6 +4164,9 @@ export const CONTROL_PLANE_METHODS: Record = transaction_get: transactionGet, transaction_list: transactionList, transaction_submit: transactionSubmit, + transfer_send: transferSend, + event_get: eventGet, + event_list: eventList, account_get: accountGet, account_list: accountList, balance_get: balanceGet, @@ -3043,10 +4210,19 @@ export const CONTROL_PLANE_METHODS: Record = bridge_observation_get: bridgeObservationGet, bridge_observation_list: bridgeObservationList, bridge_observation_submit: bridgeObservationSubmit, + bridge_config_get: bridgeConfigGet, + bridge_status: bridgeStatus, + bridge_credit_status: bridgeCreditStatus, bridge_deposit_get: bridgeDepositGet, bridge_deposit_list: bridgeDepositList, bridge_credit_get: bridgeCreditGet, bridge_credit_list: bridgeCreditList, + withdrawal_intent_get: withdrawalIntentGet, + withdrawal_intent_list: withdrawalIntentList, + release_evidence_get: releaseEvidenceGet, + release_evidence_list: releaseEvidenceList, + replay_rejection_get: replayRejectionGet, + replay_rejection_list: replayRejectionList, withdrawal_get: withdrawalGet, withdrawal_list: withdrawalList, provenance_get: provenanceGet, @@ -3062,5 +4238,5 @@ export function callControlPlaneMethod( if (handler === undefined) { throw methodNotFound(`control-plane method not found: ${method}`, { method }); } - return handler(params, context); + return withResponseMetadata(handler(params, context), method, context); } diff --git a/services/control-plane/src/server.ts b/services/control-plane/src/server.ts index cbfe4d35..a69b8ef6 100644 --- a/services/control-plane/src/server.ts +++ b/services/control-plane/src/server.ts @@ -121,7 +121,49 @@ export function startControlPlaneServer(options: ServerOptions): ReturnType; +} + function firstDevnetBlock(state: ReturnType): JsonObject { const blocksResponse = dispatchJsonRpc( { jsonrpc: "2.0", id: "blocks-prefetch", method: "block_list", params: { limit: 10 } }, @@ -30,8 +44,47 @@ function stringField(value: unknown, name: string): string { return String(value); } +function loadSchemaCatalog(): MethodSchemaCatalog { + return JSON.parse(readFileSync(resolve(repoRoot(), "schemas/flowmemory/control-plane-production-l1.schema.json"), "utf8")) as MethodSchemaCatalog; +} + +function assertNoSecretResponse(value: unknown, label: string): void { + const findings = scanJsonForSecrets(value as never); + if (findings.length > 0) { + throw new Error(`control-plane smoke secret scan failed for ${label}: ${JSON.stringify(findings, null, 2)}`); + } +} + +function assertCatalogResponse(catalog: MethodSchemaCatalog, method: string, response: RpcSuccessResponse): void { + const entry = catalog.methods[method]; + if (entry === undefined) { + throw new Error(`schema catalog missing method ${method}`); + } + const result = response.result as JsonObject; + const schema = result.schema; + const allowed = entry.allowedResultSchemas ?? (entry.resultSchema === undefined ? [] : [entry.resultSchema]); + if (typeof schema !== "string" || !allowed.includes(schema)) { + throw new Error(`schema catalog mismatch for ${method}: got ${String(schema)}, expected ${allowed.join(", ")}`); + } +} + +function assertCatalogError(response: RpcErrorResponse): void { + assertNoSecretResponse(response, String(response.id ?? "error")); + if (response.error.data.schema !== "flowmemory.control_plane.error.v1") { + throw new Error(`error response used unexpected schema ${response.error.data.schema}`); + } + for (const field of ["errorCode", "message", "correlationId", "recoverable", "retryable", "sourceComponent"]) { + if (!(field in response.error.data)) { + throw new Error(`error response missing ${field}`); + } + } +} + export function runControlPlaneSmoke(pathOverrides: Partial = {}): JsonObject { + const catalog = loadSchemaCatalog(); const state = loadControlPlaneState(pathOverrides); + const chainStatus = dispatchJsonRpc({ jsonrpc: "2.0", id: "chain-prefetch", method: "chain_status" }, { state }) as RpcSuccessResponse; + const chainId = stringField((chainStatus.result as JsonObject).chainId, "chainId"); const rootfieldId = state.launchCore.rootfieldBundles[0]?.rootfieldId; const receipt = state.launchCore.memoryReceipts[0]; const reportId = receipt?.reportId; @@ -48,6 +101,7 @@ export function runControlPlaneSmoke(pathOverrides: Partial = const credits = dispatchJsonRpc({ jsonrpc: "2.0", id: "bridge-credits-prefetch", method: "bridge_credit_list" }, { state }) as RpcSuccessResponse; const credit = ((credits.result as JsonObject).credits as JsonObject[])[0]; const creditId = stringField(credit.creditId, "creditId"); + const creditTxHash = stringField(credit.txHash ?? credit.baseTxHash, "bridge credit txHash"); const withdrawals = dispatchJsonRpc({ jsonrpc: "2.0", id: "withdrawals-prefetch", method: "withdrawal_list" }, { state }) as RpcSuccessResponse; const withdrawal = ((withdrawals.result as JsonObject).withdrawals as JsonObject[])[0]; const withdrawalId = stringField(withdrawal.withdrawalId, "withdrawalId"); @@ -57,6 +111,58 @@ export function runControlPlaneSmoke(pathOverrides: Partial = const tokenBalances = dispatchJsonRpc({ jsonrpc: "2.0", id: "token-balances-prefetch", method: "token_balance_list" }, { state }) as RpcSuccessResponse; const tokenBalance = ((tokenBalances.result as JsonObject).balances as JsonObject[])[0]; const tokenBalanceId = stringField(tokenBalance.balanceId, "tokenBalanceId"); + const pools = dispatchJsonRpc({ jsonrpc: "2.0", id: "pools-prefetch", method: "pool_list" }, { state }) as RpcSuccessResponse; + const pool = ((pools.result as JsonObject).pools as JsonObject[])[0]; + const poolId = stringField(pool.poolId, "poolId"); + const lpPositions = dispatchJsonRpc({ jsonrpc: "2.0", id: "lp-prefetch", method: "lp_position_list" }, { state }) as RpcSuccessResponse; + const lpPosition = ((lpPositions.result as JsonObject).positions as JsonObject[])[0]; + const lpPositionId = stringField(lpPosition.positionId, "lpPositionId"); + const swaps = dispatchJsonRpc({ jsonrpc: "2.0", id: "swap-prefetch", method: "swap_list" }, { state }) as RpcSuccessResponse; + const swap = ((swaps.result as JsonObject).swaps as JsonObject[])[0]; + const swapId = stringField(swap.swapId, "swapId"); + const events = dispatchJsonRpc({ jsonrpc: "2.0", id: "events-prefetch", method: "event_list", params: { limit: 10 } }, { state }) as RpcSuccessResponse; + const event = ((events.result as JsonObject).events as JsonObject[])[0]; + const eventId = stringField(event.eventId, "eventId"); + const releaseEvidenceList = dispatchJsonRpc({ jsonrpc: "2.0", id: "release-prefetch", method: "release_evidence_list" }, { state }) as RpcSuccessResponse; + const releaseEvidence = ((releaseEvidenceList.result as JsonObject).releaseEvidence as JsonObject[])[0]; + const releaseEvidenceId = stringField(releaseEvidence.releaseEvidenceId, "releaseEvidenceId"); + const replayRejections = dispatchJsonRpc({ jsonrpc: "2.0", id: "replay-prefetch", method: "replay_rejection_list" }, { state }) as RpcSuccessResponse; + const replayRejection = ((replayRejections.result as JsonObject).replayRejections as JsonObject[])[0]; + const replayRejectionId = stringField(replayRejection.replayRejectionId, "replayRejectionId"); + const signer = `0x${Date.now().toString(16).padStart(40, "0").slice(-40)}`; + const submitAccountSuffix = Date.now().toString(16); + const transferFrom = `account:submit:alice:${submitAccountSuffix}`; + const transferTo = `account:submit:bob:${submitAccountSuffix}`; + const bridgeObservationId = `0x${(Date.now() + 1).toString(16).padStart(64, "0").slice(-64)}`; + const bridgeReplayKey = `0x${(Date.now() + 2).toString(16).padStart(64, "0").slice(-64)}`; + const signedEnvelope = buildLocalSignedTransferEnvelope({ + chainId, + signer, + nonce: "0", + from: transferFrom, + to: transferTo, + amount: "7", + memo: "control-plane-smoke", + }); + const submittedTxId = signedEnvelopeTxId(signedEnvelope); + const invalidSignatureEnvelope = { ...signedEnvelope, signature: `0x${"0".repeat(64)}` }; + const wrongChainEnvelope = buildLocalSignedTransferEnvelope({ + chainId: "flowmemory-wrong-chain", + signer: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + nonce: "0", + from: "account:submit:wrong-chain", + to: "account:submit:bob", + amount: "1", + }); + const staleNonceEnvelope = buildLocalSignedTransferEnvelope({ + chainId, + signer, + nonce: "0", + from: transferFrom, + to: "account:submit:charlie", + amount: "8", + }); + staleNonceEnvelope.signature = localSignatureDigest(staleNonceEnvelope); if (rootfieldId === undefined || receipt === undefined || reportId === undefined || artifactUri === undefined) { throw new Error("control-plane smoke requires launch-core rootfield, receipt, report, and artifact fixture data"); @@ -66,7 +172,9 @@ export function runControlPlaneSmoke(pathOverrides: Partial = { jsonrpc: "2.0", id: "health", method: "health" }, { jsonrpc: "2.0", id: "node", method: "node_status" }, { jsonrpc: "2.0", id: "peers", method: "peer_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "sync", method: "sync_status" }, { jsonrpc: "2.0", id: "chain", method: "chain_status" }, + { jsonrpc: "2.0", id: "finalityStatus", method: "finality_status" }, { jsonrpc: "2.0", id: "pilotStatus", method: "pilot_status" }, { jsonrpc: "2.0", id: "pilotDeposits", method: "pilot_deposit_observation_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "pilotCredits", method: "pilot_credit_list", params: { limit: 10 } }, @@ -81,23 +189,31 @@ export function runControlPlaneSmoke(pathOverrides: Partial = { jsonrpc: "2.0", id: "block", method: "block_get", params: { blockNumber: stringField(block.blockNumber, "blockNumber"), includeTransactions: true } }, { jsonrpc: "2.0", id: "transactions", method: "transaction_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "transaction", method: "transaction_get", params: { txId } }, + { jsonrpc: "2.0", id: "events", method: "event_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "event", method: "event_get", params: { eventId } }, { jsonrpc: "2.0", id: "transactionSubmit", method: "transaction_submit", params: { - signedEnvelope: { - schema: "flowmemory.control_plane.smoke_signed_envelope.v0", - tx: { - schema: "flowmemory.control_plane.smoke_transaction.v0", - action: "local-smoke", - nonce: "0", - }, - signature: "0xlocal-smoke-signature", - }, + signedEnvelope, submittedBy: "control-plane-smoke", }, }, + { jsonrpc: "2.0", id: "submittedTransaction", method: "transaction_get", params: { txId: submittedTxId } }, + { jsonrpc: "2.0", id: "submittedReceipt", method: "receipt_get", params: { txId: submittedTxId } }, + { jsonrpc: "2.0", id: "submittedBalance", method: "balance_get", params: { accountId: transferTo, tokenId: "local-test-unit" } }, + { + jsonrpc: "2.0", + id: "transferSend", + method: "transfer_send", + params: { + from: transferTo, + to: `account:submit:carol:${submitAccountSuffix}`, + tokenId: "local-test-unit", + amount: "2", + }, + }, { jsonrpc: "2.0", id: "mempool", method: "mempool_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "accounts", method: "account_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "account", method: "account_get", params: { accountId } }, @@ -107,8 +223,11 @@ export function runControlPlaneSmoke(pathOverrides: Partial = { jsonrpc: "2.0", id: "tokenBalances", method: "token_balance_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "tokenBalance", method: "token_balance_get", params: { balanceId: tokenBalanceId } }, { jsonrpc: "2.0", id: "pools", method: "pool_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "pool", method: "pool_get", params: { poolId } }, { jsonrpc: "2.0", id: "lpPositions", method: "lp_position_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "lpPosition", method: "lp_position_get", params: { positionId: lpPositionId } }, { jsonrpc: "2.0", id: "swaps", method: "swap_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "swap", method: "swap_get", params: { swapId } }, { jsonrpc: "2.0", id: "productFlowStatus", method: "product_flow_status" }, { jsonrpc: "2.0", id: "faucet", method: "faucet_event_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "wallets", method: "wallet_metadata_list", params: { limit: 10 } }, @@ -121,6 +240,7 @@ export function runControlPlaneSmoke(pathOverrides: Partial = { jsonrpc: "2.0", id: "model", method: "model_get", params: { rootfieldId } }, { jsonrpc: "2.0", id: "workReceipts", method: "work_receipt_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "workReceipt", method: "work_receipt_get", params: { receiptId: receipt.receiptId } }, + { jsonrpc: "2.0", id: "artifactResolver", method: "artifact_get", params: { uri: artifactUri } }, { jsonrpc: "2.0", id: "artifactAvailability", method: "artifact_availability_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "artifact", method: "artifact_availability_get", params: { uri: artifactUri } }, { jsonrpc: "2.0", id: "modules", method: "verifier_module_list", params: { limit: 10 } }, @@ -137,14 +257,58 @@ export function runControlPlaneSmoke(pathOverrides: Partial = { jsonrpc: "2.0", id: "finality", method: "finality_get", params: { receiptId: receipt.receiptId } }, { jsonrpc: "2.0", id: "bridgeObservationList", method: "bridge_observation_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "bridgeObservation", method: "bridge_observation_get", params: { depositId } }, + { + jsonrpc: "2.0", + id: "bridgeObservationSubmit", + method: "bridge_observation_submit", + params: { + observation: { + schema: "flowmemory.bridge_deposit_observation.v0", + observationId: bridgeObservationId, + replayKey: bridgeReplayKey, + observedAt: "2026-05-13T00:00:00.000Z", + mode: "mock", + productionReady: false, + deposit: { + schema: "flowmemory.bridge_deposit.v0", + depositId: bridgeObservationId, + sourceChainId: 84532, + sourceContract: "0x1111111111111111111111111111111111111111", + txHash: bridgeReplayKey, + logIndex: 0, + token: "0x3333333333333333333333333333333333333333", + amount: "1", + sender: "0x4444444444444444444444444444444444444444", + flowchainRecipient: transferTo, + nonce: "1", + status: "observed" + } + } + } + }, + { jsonrpc: "2.0", id: "submittedBridgeObservation", method: "bridge_observation_get", params: { observationId: bridgeObservationId } }, + { jsonrpc: "2.0", id: "bridgeConfig", method: "bridge_config_get" }, + { jsonrpc: "2.0", id: "bridgeStatus", method: "bridge_status" }, + { jsonrpc: "2.0", id: "bridgeCreditStatus", method: "bridge_credit_status", params: { txHash: creditTxHash } }, { jsonrpc: "2.0", id: "bridgeDeposits", method: "bridge_deposit_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "bridgeDeposit", method: "bridge_deposit_get", params: { depositId } }, { jsonrpc: "2.0", id: "bridgeCredits", method: "bridge_credit_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "bridgeCredit", method: "bridge_credit_get", params: { creditId } }, + { jsonrpc: "2.0", id: "bridgeCreditByTxHash", method: "bridge_credit_get", params: { txHash: creditTxHash } }, + { jsonrpc: "2.0", id: "withdrawalIntents", method: "withdrawal_intent_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "withdrawalIntent", method: "withdrawal_intent_get", params: { withdrawalId } }, + { jsonrpc: "2.0", id: "releaseEvidenceList", method: "release_evidence_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "releaseEvidence", method: "release_evidence_get", params: { releaseEvidenceId } }, + { jsonrpc: "2.0", id: "replayRejectionList", method: "replay_rejection_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "replayRejection", method: "replay_rejection_get", params: { replayRejectionId } }, { jsonrpc: "2.0", id: "withdrawals", method: "withdrawal_list", params: { limit: 10 } }, { jsonrpc: "2.0", id: "withdrawal", method: "withdrawal_get", params: { withdrawalId } }, { jsonrpc: "2.0", id: "provenance", method: "provenance_get", params: { receiptId: receipt.receiptId } }, { jsonrpc: "2.0", id: "raw", method: "raw_json_get", params: { source: "launchCore" } }, + { jsonrpc: "2.0", id: "invalidSignature", method: "transaction_submit", params: { signedEnvelope: invalidSignatureEnvelope } }, + { jsonrpc: "2.0", id: "duplicateTransaction", method: "transaction_submit", params: { signedEnvelope } }, + { jsonrpc: "2.0", id: "wrongChain", method: "transaction_submit", params: { signedEnvelope: wrongChainEnvelope } }, + { jsonrpc: "2.0", id: "staleNonce", method: "transaction_submit", params: { signedEnvelope: staleNonceEnvelope } }, ] as const; const response = dispatchJsonRpc([...requests], { state }); @@ -152,17 +316,55 @@ export function runControlPlaneSmoke(pathOverrides: Partial = throw new Error("control-plane smoke expected batch JSON-RPC response"); } - const errors = response.filter((entry): entry is RpcErrorResponse => "error" in entry); + response.forEach((entry) => assertNoSecretResponse(entry, String(entry.id ?? "batch-entry"))); + const expectedErrorIds = new Set(["invalidSignature", "duplicateTransaction", "wrongChain", "staleNonce"]); + const unexpectedErrors = response.filter((entry): entry is RpcErrorResponse => "error" in entry && !expectedErrorIds.has(String(entry.id))); + const expectedErrors = response.filter((entry): entry is RpcErrorResponse => "error" in entry && expectedErrorIds.has(String(entry.id))); + expectedErrors.forEach(assertCatalogError); + const expectedCodes = new Map(expectedErrors.map((entry) => [String(entry.id), entry.error.data.errorCode])); + if (expectedCodes.get("invalidSignature") !== "BAD_SIGNATURE" + || expectedCodes.get("duplicateTransaction") !== "DUPLICATE_TX" + || expectedCodes.get("wrongChain") !== "WRONG_CHAIN_ID" + || expectedCodes.get("staleNonce") !== "STALE_NONCE") { + throw new Error(`control-plane smoke expected transaction rejection codes, got ${JSON.stringify(Object.fromEntries(expectedCodes))}`); + } + const errors = unexpectedErrors; if (errors.length > 0) { throw new Error(`control-plane smoke failed: ${JSON.stringify(errors, null, 2)}`); } - const successes = response as RpcSuccessResponse[]; + if (expectedErrors.length !== expectedErrorIds.size) { + throw new Error("control-plane smoke did not exercise every expected transaction rejection"); + } + + const successes = response.filter((entry): entry is RpcSuccessResponse => "result" in entry); + successes.forEach((entry) => { + const request = requests.find((candidate) => candidate.id === entry.id); + if (request !== undefined) { + assertCatalogResponse(catalog, request.method, entry); + } + }); + const submittedBalance = successes.find((entry) => entry.id === "submittedBalance")?.result as JsonObject | undefined; + if (submittedBalance?.amount !== "7") { + throw new Error(`submitted transfer balance proof failed: ${String(submittedBalance?.amount)}`); + } + const transferSendResult = successes.find((entry) => entry.id === "transferSend")?.result as JsonObject | undefined; + if (transferSendResult?.schema !== "flowmemory.control_plane.transfer_send_result.v1" + || (transferSendResult.receipt as JsonObject | undefined)?.status !== "accepted_local") { + throw new Error(`transfer_send receipt proof failed: ${JSON.stringify(transferSendResult)}`); + } return { schema: "flowmemory.control_plane.smoke.v0", ok: true, methodCount: requests.length, + successCount: successes.length, + expectedErrorCount: expectedErrors.length, responseSchemas: successes.map((entry) => (entry.result as JsonObject).schema), + noSecretScan: { + schema: "flowmemory.control_plane.no_secret_scan.v1", + scannedResponses: response.length, + findingCount: 0, + }, queried: { rootfieldId, receiptId: receipt.receiptId, @@ -170,9 +372,15 @@ export function runControlPlaneSmoke(pathOverrides: Partial = artifactUri, blockNumber: stringField(block.blockNumber, "blockNumber"), txId, + submittedTxId, accountId, depositId, tokenId, + poolId, + lpPositionId, + swapId, + releaseEvidenceId, + replayRejectionId, }, localOnly: true, }; diff --git a/services/control-plane/src/transaction-envelope.ts b/services/control-plane/src/transaction-envelope.ts new file mode 100644 index 00000000..b0990952 --- /dev/null +++ b/services/control-plane/src/transaction-envelope.ts @@ -0,0 +1,234 @@ +import { canonicalJson, keccak256Hex } from "../../shared/src/index.ts"; +import type { JsonObject, JsonValue } from "./types.ts"; + +export const FLOWCHAIN_SIGNED_ENVELOPE_V1 = "flowchain.signed_transaction_envelope.v1"; +export const FLOWCHAIN_TRANSFER_PAYLOAD_V1 = "flowchain.transaction.transfer.v1"; +export const FLOWCHAIN_LOCAL_SIGNATURE_SCHEME_V1 = "flowchain-local-digest-v1"; + +export interface SignedEnvelopeValidation { + envelope: JsonObject; + chainId: string; + signer: string; + nonce: string; + txId: string; + payload: JsonObject; + payloadSummary: JsonObject; + signatureVerification: JsonObject; +} + +export interface SignedEnvelopeValidationFailure { + code: + | "UNSIGNED_TRANSACTION" + | "BAD_SIGNATURE" + | "WRONG_CHAIN_ID" + | "MALFORMED_REQUEST"; + message: string; + details?: JsonValue; +} + +function asObject(value: JsonValue | undefined): JsonObject | null { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : null; +} + +function stringValue(value: JsonValue | undefined): string | null { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return null; +} + +function hashJson(schema: string, value: JsonValue): string { + return keccak256Hex(new TextEncoder().encode(canonicalJson({ schema, value }))); +} + +function signatureValue(envelope: JsonObject): string | null { + const signature = envelope.signature; + if (typeof signature === "string") { + return signature; + } + const signatureObject = asObject(signature); + return signatureObject === null ? null : stringValue(signatureObject.value); +} + +function signingPayload(envelope: JsonObject): JsonObject { + return { + schema: "flowchain.signed_transaction_payload.v1", + chainId: stringValue(envelope.chainId), + signer: stringValue(envelope.signer), + nonce: stringValue(envelope.nonce), + payload: asObject(envelope.payload), + }; +} + +export function localSignatureDigest(envelope: JsonObject): string { + return hashJson(FLOWCHAIN_LOCAL_SIGNATURE_SCHEME_V1, signingPayload(envelope)); +} + +export function signedEnvelopeTxId(envelope: JsonObject): string { + return hashJson("flowchain.signed_transaction.tx_id.v1", signingPayload(envelope)); +} + +function payloadSummary(payload: JsonObject): JsonObject { + return { + schema: "flowmemory.control_plane.transaction_payload_summary.v1", + payloadSchema: stringValue(payload.schema), + type: stringValue(payload.type) ?? stringValue(payload.action) ?? "unknown", + from: stringValue(payload.from) ?? stringValue(payload.accountId) ?? null, + to: stringValue(payload.to) ?? stringValue(payload.recipient) ?? null, + tokenId: stringValue(payload.tokenId) ?? stringValue(payload.assetId) ?? "local-test-unit", + amount: stringValue(payload.amount) ?? stringValue(payload.units) ?? null, + }; +} + +function validatePayload(payload: JsonObject): SignedEnvelopeValidationFailure | null { + const payloadSchema = stringValue(payload.schema); + if (payloadSchema === null || !/^(flowchain|flowmemory)\.[a-z0-9_.-]+\.v[0-9]+$/.test(payloadSchema)) { + return { + code: "MALFORMED_REQUEST", + message: "signed transaction payload requires a versioned flowchain/flowmemory schema", + details: { payloadSchema }, + }; + } + + if (payloadSchema === FLOWCHAIN_TRANSFER_PAYLOAD_V1 || stringValue(payload.type) === "transfer") { + const from = stringValue(payload.from); + const to = stringValue(payload.to); + const amount = stringValue(payload.amount); + if (from === null || to === null || amount === null || !/^[0-9]+$/.test(amount) || BigInt(amount) <= 0n) { + return { + code: "MALFORMED_REQUEST", + message: "transfer payload requires from, to, and a positive integer amount", + details: { payloadSchema }, + }; + } + } + + return null; +} + +export function validateSignedEnvelope( + envelope: JsonObject, + expectedChainId: string, +): SignedEnvelopeValidation | SignedEnvelopeValidationFailure { + const schema = stringValue(envelope.schema); + if (schema !== FLOWCHAIN_SIGNED_ENVELOPE_V1) { + return { + code: "UNSIGNED_TRANSACTION", + message: "transaction_submit accepts only versioned FlowChain signed envelopes", + details: { schema }, + }; + } + + const chainId = stringValue(envelope.chainId); + const signer = stringValue(envelope.signer); + const nonce = stringValue(envelope.nonce); + const payload = asObject(envelope.payload); + const signature = signatureValue(envelope); + const signatureScheme = stringValue(envelope.signatureScheme) ?? FLOWCHAIN_LOCAL_SIGNATURE_SCHEME_V1; + + if (chainId === null || signer === null || nonce === null || payload === null || signature === null) { + return { + code: "UNSIGNED_TRANSACTION", + message: "signed envelope requires chainId, signer, nonce, payload, and signature", + details: { schema }, + }; + } + + if (chainId !== expectedChainId) { + return { + code: "WRONG_CHAIN_ID", + message: `signed envelope chainId ${chainId} does not match ${expectedChainId}`, + details: { expectedChainId, actualChainId: chainId }, + }; + } + + if (!/^0x[0-9a-fA-F]{40}$/.test(signer) && !/^0x[0-9a-fA-F]{64}$/.test(signer)) { + return { + code: "MALFORMED_REQUEST", + message: "signed envelope signer must be a 20-byte or 32-byte hex identifier", + details: { signer }, + }; + } + + if (!/^[0-9]+$/.test(nonce)) { + return { + code: "MALFORMED_REQUEST", + message: "signed envelope nonce must be a non-negative integer string", + details: { nonce }, + }; + } + + const payloadFailure = validatePayload(payload); + if (payloadFailure !== null) { + return payloadFailure; + } + + if (signatureScheme !== FLOWCHAIN_LOCAL_SIGNATURE_SCHEME_V1) { + return { + code: "BAD_SIGNATURE", + message: "signed envelope uses an unsupported local signature scheme", + details: { signatureScheme }, + }; + } + + const expectedSignature = localSignatureDigest(envelope); + if (signature !== expectedSignature) { + return { + code: "BAD_SIGNATURE", + message: "signed envelope signature verification failed", + details: { + signatureScheme, + expectedDigest: expectedSignature, + }, + }; + } + + return { + envelope, + chainId, + signer, + nonce, + txId: signedEnvelopeTxId(envelope), + payload, + payloadSummary: payloadSummary(payload), + signatureVerification: { + schema: "flowmemory.control_plane.signature_verification.v1", + scheme: signatureScheme, + verified: true, + digest: expectedSignature, + }, + }; +} + +export function buildLocalSignedTransferEnvelope(options: { + chainId: string; + signer: string; + nonce: string; + from: string; + to: string; + tokenId?: string; + amount: string; + memo?: string; +}): JsonObject { + const envelope: JsonObject = { + schema: FLOWCHAIN_SIGNED_ENVELOPE_V1, + chainId: options.chainId, + signer: options.signer, + nonce: options.nonce, + signatureScheme: FLOWCHAIN_LOCAL_SIGNATURE_SCHEME_V1, + payload: { + schema: FLOWCHAIN_TRANSFER_PAYLOAD_V1, + type: "transfer", + from: options.from, + to: options.to, + tokenId: options.tokenId ?? "local-test-unit", + amount: options.amount, + memo: options.memo, + }, + }; + envelope.signature = localSignatureDigest(envelope); + return envelope; +} diff --git a/services/control-plane/src/types.ts b/services/control-plane/src/types.ts index d5a000d8..6dc76913 100644 --- a/services/control-plane/src/types.ts +++ b/services/control-plane/src/types.ts @@ -16,7 +16,9 @@ export type ControlPlaneMethod = | "health" | "node_status" | "peer_list" + | "sync_status" | "chain_status" + | "finality_status" | "pilot_status" | "pilot_deposit_observation_list" | "pilot_credit_list" @@ -33,6 +35,9 @@ export type ControlPlaneMethod = | "transaction_get" | "transaction_list" | "transaction_submit" + | "transfer_send" + | "event_get" + | "event_list" | "account_get" | "account_list" | "balance_get" @@ -76,10 +81,19 @@ export type ControlPlaneMethod = | "bridge_observation_get" | "bridge_observation_list" | "bridge_observation_submit" + | "bridge_config_get" + | "bridge_status" + | "bridge_credit_status" | "bridge_deposit_get" | "bridge_deposit_list" | "bridge_credit_get" | "bridge_credit_list" + | "withdrawal_intent_get" + | "withdrawal_intent_list" + | "release_evidence_get" + | "release_evidence_list" + | "replay_rejection_get" + | "replay_rejection_list" | "withdrawal_get" | "withdrawal_list" | "provenance_get" @@ -145,8 +159,14 @@ export interface RpcErrorObject { code: number; message: string; data: { - schema: "flowmemory.control_plane.error.v0"; + schema: "flowmemory.control_plane.error.v1"; reasonCode: string; + errorCode: string; + message: string; + correlationId: string; + recoverable: boolean; + retryable: boolean; + sourceComponent: string; details?: JsonValue; localOnly: true; }; diff --git a/services/control-plane/test/control-plane.test.ts b/services/control-plane/test/control-plane.test.ts index c54ea933..cfc835c8 100644 --- a/services/control-plane/test/control-plane.test.ts +++ b/services/control-plane/test/control-plane.test.ts @@ -1,14 +1,17 @@ import assert from "node:assert/strict"; import { once } from "node:events"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { canonicalJson } from "../../shared/src/index.ts"; import { + buildLocalSignedTransferEnvelope, dispatchJsonRpc, loadControlPlaneState, + repoRoot, + scanJsonForSecrets, type JsonObject, type RpcErrorResponse, type RpcSuccessResponse, @@ -76,7 +79,7 @@ test("keeps deterministic chain status response snapshots", () => { assert.equal(snapshot(first), snapshot(second)); assert.equal( snapshot(first), - "{\"capabilities\":[\"health_reads\",\"node_status_reads\",\"peer_reads\",\"local_runtime_status_reads\",\"block_reads\",\"transaction_reads\",\"local_transaction_file_intake\",\"mempool_reads\",\"account_reads\",\"balance_reads\",\"faucet_event_reads\",\"wallet_public_metadata_reads\",\"token_reads\",\"token_balance_reads\",\"dex_pool_reads\",\"lp_position_reads\",\"swap_reads\",\"product_flow_status_reads\",\"receipt_lookup\",\"verifier_report_lookup\",\"memory_lineage_lookup\",\"artifact_fixture_lookup\",\"bridge_observation_file_intake\",\"bridge_deposit_reads\",\"bridge_credit_reads\",\"withdrawal_reads\",\"real_value_pilot_reads\",\"real_value_pilot_operator_steps\",\"devnet_handoff_reads\",\"no_secret_response_checks\",\"raw_json_reads\"],\"chainId\":\"flowmemory-local-devnet-v0\",\"counts\":{\"accounts\":2,\"agents\":2,\"artifactAvailability\":5,\"balances\":2,\"blocks\":11,\"bridgeCredits\":1,\"bridgeDeposits\":1,\"challenges\":1,\"devnetBlocks\":2,\"duplicates\":1,\"faucetEvents\":1,\"finalityRows\":9,\"lpPositions\":0,\"memoryCells\":1,\"memoryReceipts\":8,\"memorySignals\":8,\"mempool\":0,\"models\":2,\"observations\":8,\"pilotStatus\":1,\"pools\":0,\"rejectedLogs\":2,\"rootfields\":2,\"swaps\":0,\"tokenBalances\":1,\"tokens\":1,\"transactions\":25,\"verifierModules\":3,\"verifierReports\":8,\"walletPublicMetadata\":2,\"withdrawals\":1,\"workReceipts\":9},\"schema\":\"flowmemory.control_plane.chain_status.v0\"}", + "{\"capabilities\":[\"health_reads\",\"node_status_reads\",\"peer_reads\",\"local_runtime_status_reads\",\"block_reads\",\"transaction_reads\",\"local_transaction_file_intake\",\"local_transfer_send\",\"mempool_reads\",\"account_reads\",\"balance_reads\",\"faucet_event_reads\",\"wallet_public_metadata_reads\",\"token_reads\",\"token_balance_reads\",\"dex_pool_reads\",\"lp_position_reads\",\"swap_reads\",\"product_flow_status_reads\",\"receipt_lookup\",\"verifier_report_lookup\",\"memory_lineage_lookup\",\"artifact_fixture_lookup\",\"bridge_observation_file_intake\",\"bridge_deposit_reads\",\"bridge_credit_reads\",\"bridge_credit_status_reads\",\"withdrawal_reads\",\"real_value_pilot_reads\",\"real_value_pilot_operator_steps\",\"devnet_handoff_reads\",\"no_secret_response_checks\",\"raw_json_reads\"],\"chainId\":\"flowmemory-local-devnet-v0\",\"counts\":{\"accounts\":3,\"agents\":2,\"artifactAvailability\":5,\"balances\":3,\"blocks\":11,\"bridgeCredits\":1,\"bridgeDeposits\":1,\"challenges\":1,\"devnetBlocks\":2,\"duplicates\":1,\"faucetEvents\":1,\"finalityRows\":9,\"lpPositions\":1,\"memoryCells\":1,\"memoryReceipts\":8,\"memorySignals\":8,\"mempool\":0,\"models\":2,\"observations\":8,\"pilotStatus\":1,\"pools\":1,\"rejectedLogs\":2,\"rootfields\":2,\"swaps\":1,\"tokenBalances\":1,\"tokens\":1,\"transactions\":25,\"verifierModules\":3,\"verifierReports\":8,\"walletPublicMetadata\":3,\"withdrawals\":1,\"workReceipts\":9},\"schema\":\"flowmemory.control_plane.chain_status.v0\"}", ); rmSync(dir, { recursive: true, force: true }); }); @@ -198,20 +201,22 @@ test("submits local transactions to the file-backed runtime intake path", () => const dir = mkdtempSync(join(tmpdir(), "flowmemory-control-plane-intake-")); try { const state = loadControlPlaneState({ txIntakePath: join(dir, "transactions.ndjson") }); + const chain = dispatchJsonRpc({ jsonrpc: "2.0", id: "chain", method: "chain_status" }, { state }) as RpcSuccessResponse; + const signedEnvelope = buildLocalSignedTransferEnvelope({ + chainId: chain.result.chainId as string, + signer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + nonce: "0", + from: "account:test:alice", + to: "account:test:bob", + amount: "3", + }); const response = dispatchJsonRpc( { jsonrpc: "2.0", id: 1, method: "transaction_submit", params: { - signedEnvelope: { - schema: "flowmemory.test_signed_envelope.v0", - tx: { - schema: "flowmemory.test_transaction.v0", - action: "test", - }, - signature: "0xtest-signature", - }, + signedEnvelope, }, }, { state }, @@ -221,7 +226,7 @@ test("submits local transactions to the file-backed runtime intake path", () => assert.equal(response.result.accepted, true); assert.equal(mempool.result.count, 1); assert.equal(mempool.result.transactions[0].source, "local-file-intake"); - assert.equal(mempool.result.transactions[0].transaction.action, "test"); + assert.equal(mempool.result.transactions[0].transaction.type, "transfer"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -246,8 +251,9 @@ test("rejects unsigned transaction_submit payloads", () => { { state }, ) as RpcErrorResponse; - assert.equal(response.error.code, -32602); - assert.equal(response.error.data.reasonCode, "params.invalid"); + assert.equal(response.error.code, -32041); + assert.equal(response.error.data.reasonCode, "transaction.unsigned"); + assert.equal(response.error.data.errorCode, "UNSIGNED_TRANSACTION"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -549,6 +555,126 @@ test("pilot lifecycle can represent a live Base 8453 evidence bundle", () => { } }); +test("looks up live bridge credit by Base tx hash and spends credited balance", () => { + const relativeDir = `devnet/local/control-plane-live-credit-test-${process.pid}-${Date.now()}`; + const absoluteDir = join(repoRoot(), relativeDir); + const liveStatePath = `${relativeDir}/state.json`; + const handoffPath = `${relativeDir}/bridge-handoff.json`; + const txIntakePath = `${relativeDir}/transactions.ndjson`; + const bridgeObservationIntakePath = `${relativeDir}/bridge-observations.ndjson`; + const accountId = `0x${"a".repeat(64)}`; + const transferTo = `0x${"b".repeat(64)}`; + const txHash = `0x${"2".repeat(64)}`; + const creditId = `0x${"e".repeat(64)}`; + const depositId = `0x${"d".repeat(64)}`; + const observationId = `0x${"c".repeat(64)}`; + + try { + mkdirSync(absoluteDir, { recursive: true }); + writeFileSync(join(absoluteDir, "state.json"), JSON.stringify({ + schema: "flowmemory.local_devnet.state.v0", + chainId: "flowmemory-local-devnet-v0", + blocks: [], + })); + writeFileSync(join(absoluteDir, "bridge-handoff.json"), JSON.stringify({ + schema: "flowmemory.bridge_runtime_handoff.v0", + handoffId: `0x${"1".repeat(64)}`, + generatedAt: "2026-05-14T00:00:00.000Z", + mode: "base-mainnet-canary", + productionReady: false, + localOnly: true, + observations: [{ + schema: "flowmemory.bridge_deposit_observation.v0", + observationId, + replayKey: `0x${"9".repeat(64)}`, + observedAt: "2026-05-14T00:00:00.000Z", + mode: "base-mainnet-canary", + deposit: { + schema: "flowmemory.bridge_deposit.v0", + depositId, + sourceChainId: 8453, + sourceContract: `0x${"1".repeat(40)}`, + txHash, + logIndex: 0, + token: "local-test-unit", + amount: "10", + sender: `0x${"4".repeat(40)}`, + flowchainRecipient: accountId, + nonce: "1", + status: "observed", + }, + }], + credits: [{ + schema: "flowmemory.bridge_credit.v0", + creditId, + observationId, + depositId, + replayKey: `0x${"9".repeat(64)}`, + source: { + chainId: 8453, + contract: `0x${"1".repeat(40)}`, + txHash, + logIndex: 0, + }, + token: "local-test-unit", + amount: "10", + flowchainRecipient: accountId, + status: "applied", + appliedAt: "2026-05-14T00:00:02.500Z", + localOnly: true, + productionReady: false, + }], + withdrawalIntents: [], + releaseEvidence: [], + replayProtection: { + strategy: "source-chain-contract-tx-log-deposit", + replayKeys: [`0x${"9".repeat(64)}`], + duplicateReplayKeys: [], + }, + })); + + const state = loadControlPlaneState({ + localDevnetPath: liveStatePath, + localDevnetLaunchPath: `${relativeDir}/missing-launch-state.json`, + bridgeRuntimeHandoffPath: handoffPath, + bridgeObservationPath: `${relativeDir}/missing-observation.json`, + txIntakePath, + bridgeObservationIntakePath, + }); + const credit = dispatchJsonRpc({ jsonrpc: "2.0", id: 1, method: "bridge_credit_get", params: { txHash } }, { state }) as RpcSuccessResponse; + const status = dispatchJsonRpc({ jsonrpc: "2.0", id: 2, method: "bridge_credit_status", params: { txHash } }, { state }) as RpcSuccessResponse; + const balance = dispatchJsonRpc({ jsonrpc: "2.0", id: 3, method: "balance_get", params: { accountId, tokenId: "local-test-unit" } }, { state }) as RpcSuccessResponse; + const transfer = dispatchJsonRpc({ + jsonrpc: "2.0", + id: 4, + method: "transfer_send", + params: { + from: accountId, + to: transferTo, + tokenId: "local-test-unit", + amount: "4", + }, + }, { state }) as RpcSuccessResponse; + const after = dispatchJsonRpc({ jsonrpc: "2.0", id: 5, method: "bridge_credit_status", params: { accountId } }, { state }) as RpcSuccessResponse; + + assert.equal(credit.result.credit.creditId, creditId); + assert.equal(status.result.readinessLabel, "LIVE PILOT"); + assert.equal(status.result.baseTxHash, txHash); + assert.equal(status.result.creditedAccount, accountId); + assert.equal(status.result.spendableBalance, "10"); + assert.equal(status.result.firstUsableAt, "2026-05-14T00:00:02.500Z"); + assert.equal(status.result.latencyMs, "2500"); + assert.equal(balance.result.amount, "10"); + assert.equal(balance.result.valueBearingPilot, true); + assert.equal(transfer.result.receipt.status, "accepted_local"); + assert.equal(transfer.result.receipt.balanceAfter, "6"); + assert.equal(after.result.spendableBalance, "6"); + assert.equal(after.result.transferActionStatus, "accepted_local"); + } finally { + rmSync(absoluteDir, { recursive: true, force: true }); + } +}); + test("rejects secret-shaped bridge and pilot-adjacent intake material", () => { const dir = mkdtempSync(join(tmpdir(), "flowmemory-control-plane-pilot-secrets-")); try { @@ -596,7 +722,8 @@ test("smoke client queries the complete local lifecycle surface", () => { assert.equal(smoke.schema, "flowmemory.control_plane.smoke.v0"); assert.equal(smoke.ok, true); - assert.equal(smoke.methodCount, 66); + assert.equal(smoke.methodCount, 94); + assert.equal(smoke.expectedErrorCount, 4); assert.ok((smoke.responseSchemas as string[]).includes("flowmemory.control_plane.real_value_pilot_status.v0")); assert.ok((smoke.responseSchemas as string[]).includes("flowmemory.control_plane.raw_json.v0")); rmSync(dir, { recursive: true, force: true }); @@ -647,66 +774,80 @@ test("HTTP server exposes browser-safe health and state endpoints", async () => assert.equal(typeof address, "object"); assert.notEqual(address, null); const port = address?.port; + const fetchJson = async (path: string, init?: RequestInit): Promise => { + const { headers, ...requestInit } = init ?? {}; + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + ...requestInit, + headers: { Origin: "http://127.0.0.1:5173", ...(headers ?? {}) }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); + const body = await response.json() as JsonObject; + assert.deepEqual(scanJsonForSecrets(body as never), []); + return body; + }; - const health = await fetch(`http://127.0.0.1:${port}/health`, { - headers: { Origin: "http://127.0.0.1:5173" }, - }); - assert.equal(health.status, 200); - assert.equal(health.headers.get("access-control-allow-origin"), "*"); - assert.equal((await health.json()).status, "ok"); + const health = await fetchJson("/health"); + assert.equal(health.status, "ok"); - const state = await fetch(`http://127.0.0.1:${port}/state`, { - headers: { Origin: "http://127.0.0.1:5173" }, - }); - assert.equal(state.status, 200); - assert.equal(state.headers.get("access-control-allow-origin"), "*"); - assert.equal((await state.json()).schema, "flowmemory.control_plane.devnet_state.v0"); + const state = await fetchJson("/state"); + assert.equal(state.schema, "flowmemory.control_plane.devnet_state.v0"); - const explorer = await fetch(`http://127.0.0.1:${port}/explorer/summary`, { - headers: { Origin: "http://127.0.0.1:5173" }, - }); - assert.equal(explorer.status, 200); - assert.equal(explorer.headers.get("access-control-allow-origin"), "*"); - assert.equal((await explorer.json()).schema, "flowmemory.control_plane.chain_status.v0"); + const explorer = await fetchJson("/explorer/summary"); + assert.equal(explorer.schema, "flowmemory.control_plane.chain_status.v0"); - const productFlow = await fetch(`http://127.0.0.1:${port}/product-flow/status`, { - headers: { Origin: "http://127.0.0.1:5173" }, - }); - assert.equal(productFlow.status, 200); - assert.equal(productFlow.headers.get("access-control-allow-origin"), "*"); - assert.equal((await productFlow.json()).schema, "flowmemory.control_plane.product_flow_status.v0"); + const productFlow = await fetchJson("/product-flow/status"); + assert.equal(productFlow.schema, "flowmemory.control_plane.product_flow_status.v0"); + + const pilotStatus = await fetchJson("/pilot/status"); + assert.equal(pilotStatus.schema, "flowmemory.control_plane.real_value_pilot_status.v0"); + + const pilotCredits = await fetchJson("/pilot/credits?limit=1"); + assert.equal(pilotCredits.schema, "flowmemory.control_plane.real_value_pilot_credit_list.v0"); + + const pilotWithdrawals = await fetchJson("/pilot/withdrawal-intents?limit=1"); + assert.equal(pilotWithdrawals.schema, "flowmemory.control_plane.real_value_pilot_withdrawal_intent_list.v0"); + + const pilotReleaseEvidence = await fetchJson("/pilot/release-evidence?limit=1"); + assert.equal(pilotReleaseEvidence.schema, "flowmemory.control_plane.real_value_pilot_release_evidence_list.v0"); + + const pilotCap = await fetchJson("/pilot/cap-status"); + assert.equal(pilotCap.schema, "flowmemory.control_plane.real_value_pilot_cap_status.v0"); - const rpc = await fetch(`http://127.0.0.1:${port}/rpc`, { + const pilotPause = await fetchJson("/pilot/pause-status"); + assert.equal(pilotPause.schema, "flowmemory.control_plane.real_value_pilot_pause_status.v0"); + + const pilotRetry = await fetchJson("/pilot/retry-status"); + assert.equal(pilotRetry.schema, "flowmemory.control_plane.real_value_pilot_retry_status.v0"); + + const pilotEmergency = await fetchJson("/pilot/emergency-status"); + assert.equal(pilotEmergency.schema, "flowmemory.control_plane.real_value_pilot_emergency_status.v0"); + + const rpc = await fetchJson("/rpc", { method: "POST", - headers: { "content-type": "application/json", Origin: "http://127.0.0.1:5173" }, + headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "node_status" }), }); - assert.equal(rpc.status, 200); - assert.equal(rpc.headers.get("access-control-allow-origin"), "*"); - assert.equal((await rpc.json()).result.schema, "flowmemory.control_plane.node_status.v0"); + assert.equal((rpc.result as JsonObject).schema, "flowmemory.control_plane.node_status.v0"); - const bridge = await fetch(`http://127.0.0.1:${port}/bridge/observations`, { - headers: { Origin: "http://127.0.0.1:5173" }, - }); - assert.equal(bridge.status, 200); - assert.equal(bridge.headers.get("access-control-allow-origin"), "*"); - assert.equal((await bridge.json()).schema, "flowmemory.control_plane.bridge_observation_list.v0"); + const bridge = await fetchJson("/bridge/observations"); + assert.equal(bridge.schema, "flowmemory.control_plane.bridge_observation_list.v0"); - const pilotDeposits = await fetch(`http://127.0.0.1:${port}/pilot/deposits?limit=1`, { - headers: { Origin: "http://127.0.0.1:5173" }, - }); - assert.equal(pilotDeposits.status, 200); - assert.equal(pilotDeposits.headers.get("access-control-allow-origin"), "*"); - const pilotDepositBody = await pilotDeposits.json(); + const bridgeStatus = await fetchJson("/bridge/status"); + assert.equal(bridgeStatus.schema, "flowmemory.control_plane.bridge_status.v0"); + + const bridgeCredits = await fetchJson("/bridge/credits?limit=1"); + assert.equal(bridgeCredits.schema, "flowmemory.control_plane.bridge_credit_list.v0"); + + const bridgeCreditStatus = await fetchJson("/bridge/credit-status"); + assert.equal(bridgeCreditStatus.schema, "flowmemory.control_plane.bridge_credit_status.v1"); + + const pilotDepositBody = await fetchJson("/pilot/deposits?limit=1"); assert.equal(pilotDepositBody.schema, "flowmemory.control_plane.real_value_pilot_deposit_observation_list.v0"); assert.equal(pilotDepositBody.count, 1); - const badPilotDeposits = await fetch(`http://127.0.0.1:${port}/pilot/deposits?limit=0`, { - headers: { Origin: "http://127.0.0.1:5173" }, - }); - assert.equal(badPilotDeposits.status, 200); - const badPilotDepositBody = await badPilotDeposits.json(); - assert.equal(badPilotDepositBody.error.data.reasonCode, "params.invalid"); + const badPilotDepositBody = await fetchJson("/pilot/deposits?limit=0"); + assert.equal(((badPilotDepositBody.error as JsonObject).data as JsonObject).reasonCode, "params.invalid"); } finally { await new Promise((resolve, reject) => { server.close((error) => {