Skip to content

Commit 7327dee

Browse files
committed
Add runtime-backed FlowChain RPC e2e
1 parent e0577b0 commit 7327dee

6 files changed

Lines changed: 359 additions & 24 deletions

File tree

docs/FLOWCHAIN_CONTROL_PLANE_API.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ devnet/local/intake/transactions.ndjson
5353
devnet/local/intake/bridge-observations.ndjson
5454
```
5555

56+
`transaction_submit` can also be asked to forward a valid local devnet transaction
57+
into the active Rust runtime state with `runtimeSubmit: true` or
58+
`runtimeSubmitMode: "direct"`. That mode is still local-only, but it proves the
59+
RPC can drive the same state file that block production reads.
60+
5661
All JSON-RPC responses and local intake payloads are scanned for private-key, mnemonic, seed phrase, RPC credential, API key, and webhook-shaped material.
5762

5863
## JSON-RPC Envelope
@@ -289,11 +294,16 @@ Params:
289294
},
290295
"signature": "0x..."
291296
},
292-
"submittedBy": "local-operator"
297+
"submittedBy": "local-operator",
298+
"runtimeSubmit": true
293299
}
294300
```
295301

296-
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.
302+
Accepts signed local test transaction envelopes only. Plain `transaction`, `tx`,
303+
or `txs` params are rejected. The method rejects secret-shaped material and
304+
appends an intake row to `devnet/local/intake/transactions.ndjson`. With
305+
`runtimeSubmit` enabled, the contained devnet `tx` is also submitted directly to
306+
the active local Rust runtime state. It does not broadcast to a public chain.
297307

298308
### `mempool_list`
299309

docs/agent-goals/production-l1-live-chain/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# FlowChain Production L1 Live-Chain Goal Pack
1+
# FlowChain Guardrailed Live-Chain Build Goal Pack
22

33
Status: copy-ready `/goal` prompts for agents building FlowChain from the
44
current local/private runtime and capped owner pilot into a complete runnable
@@ -62,4 +62,3 @@ Launcher:
6262
```powershell
6363
powershell -ExecutionPolicy Bypass -File .\infra\scripts\launch-production-l1-live-chain-goals.ps1 -DryRun
6464
```
65-

fixtures/bridge/local-runtime-bridge-handoff.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema": "flowmemory.bridge_runtime_handoff.v0",
3-
"handoffId": "0x29cf06d9ffbfff9538a38f5055122f0c8801df516b70d2d2a2bd0937cd1dea9f",
3+
"handoffId": "0x78dd7a25f55ab00e62fad047f9f7b5e39233b47163e85b3a57b5b1867c735829",
44
"generatedAt": "2026-05-13T00:00:00.000Z",
55
"mode": "mock",
66
"productionReady": false,
@@ -74,7 +74,7 @@
7474
"withdrawalIntents": [
7575
{
7676
"schema": "flowmemory.bridge_withdrawal_intent.v0",
77-
"withdrawalIntentId": "0x6ae5f8a744eb711ef791e5c5b9b6e01b94a84e3fa668ce67484040e8fe564ffa",
77+
"withdrawalIntentId": "0x7183c023e4fdec83362cfcf1d195bc97fad6ad3f3abc4a1af320c3354315b3aa",
7878
"creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6",
7979
"depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269",
8080
"sourceChainId": 84532,
@@ -123,9 +123,9 @@
123123
"releaseEvidences": [
124124
{
125125
"schema": "flowmemory.bridge_release_evidence.v0",
126-
"releaseEvidenceId": "0x486e8abfce6246093c96114e1af34cb3c8c1afd9646766a584b2dee8296c2d97",
126+
"releaseEvidenceId": "0xf0f06391f74c94c8331b684073967e28946adca4e3ecd7a7f2b27eece495fe23",
127127
"generatedAt": "2026-05-13T00:00:00.000Z",
128-
"withdrawalIntentId": "0x6ae5f8a744eb711ef791e5c5b9b6e01b94a84e3fa668ce67484040e8fe564ffa",
128+
"withdrawalIntentId": "0x7183c023e4fdec83362cfcf1d195bc97fad6ad3f3abc4a1af320c3354315b3aa",
129129
"creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6",
130130
"depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269",
131131
"sourceChainId": 84532,
@@ -142,7 +142,7 @@
142142
"recipient": "0x4444444444444444444444444444444444444444",
143143
"token": "0x3333333333333333333333333333333333333333",
144144
"amount": "20000000",
145-
"evidenceHash": "0x871d7552d22b7e2d0d1dfef570a8bcecccd11afef217a71dca1a37aae02ccb3a",
145+
"evidenceHash": "0x7009968cfb7edf56372f45f0f5659238c124551b4a8773b2b86f0acf7643ae3e",
146146
"broadcast": false
147147
},
148148
"operatorNote": "Pilot release evidence only. Review before any separate release-authority transaction; this relayer does not broadcast.",
@@ -188,7 +188,7 @@
188188
{
189189
"phase": "withdrawal_requested",
190190
"status": "requested",
191-
"objectId": "0x6ae5f8a744eb711ef791e5c5b9b6e01b94a84e3fa668ce67484040e8fe564ffa",
191+
"objectId": "0x7183c023e4fdec83362cfcf1d195bc97fad6ad3f3abc4a1af320c3354315b3aa",
192192
"title": "Withdrawal requested",
193193
"summary": "Test-mode local-to-Base withdrawal intent recorded with no broadcast or real release."
194194
}
@@ -250,9 +250,9 @@
250250
},
251251
{
252252
"sectionKey": "transactions",
253-
"id": "0x6ae5f8a744eb711ef791e5c5b9b6e01b94a84e3fa668ce67484040e8fe564ffa",
253+
"id": "0x7183c023e4fdec83362cfcf1d195bc97fad6ad3f3abc4a1af320c3354315b3aa",
254254
"kind": "Bridge withdrawal intent",
255-
"title": "0x6ae5f8a744eb711ef791e5c5b9b6e01b94a84e3fa668ce67484040e8fe564ffa",
255+
"title": "0x7183c023e4fdec83362cfcf1d195bc97fad6ad3f3abc4a1af320c3354315b3aa",
256256
"summary": "Test-mode withdrawal intent recorded; no mainnet or real-funds release is broadcast.",
257257
"status": "pending",
258258
"facts": [
@@ -273,7 +273,7 @@
273273
"value": "test_record_only"
274274
}
275275
],
276-
"rawRef": "0x6ae5f8a744eb711ef791e5c5b9b6e01b94a84e3fa668ce67484040e8fe564ffa"
276+
"rawRef": "0x7183c023e4fdec83362cfcf1d195bc97fad6ad3f3abc4a1af320c3354315b3aa"
277277
}
278278
],
279279
"limitations": [

services/control-plane/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,11 @@ The loader reads local runtime state first, then committed deterministic outputs
120120

121121
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.
122122

123-
`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.
123+
`transaction_submit` accepts signed local test transaction envelopes only and writes them to `devnet/local/intake/transactions.ndjson` by default. When called with `runtimeSubmit: true` or `runtimeSubmitMode: "direct"`, it also forwards the contained devnet transaction into the active Rust runtime state file so `mempool_list`, block production, transaction reads, account reads, and balance reads can see it. `bridge_observation_submit` writes bridge-agent observations to `devnet/local/intake/bridge-observations.ndjson`. These files are local runtime intake, not committed fixtures.
124124

125125
`npm run control-plane:smoke` runs an in-process JSON-RPC client over the complete local lifecycle surface: RPC discovery/readiness, 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.
126126

127-
`npm run flowchain:rpc:e2e` verifies the FlowChain-native JSON-RPC discovery and readiness methods, required method coverage, no-secret report boundary, and writes `devnet/local/rpc-e2e/flowchain-rpc-e2e-report.json`. It intentionally reports public RPC readiness as blocked until the explicit public RPC deployment inputs are configured.
127+
`npm run flowchain:rpc:e2e` verifies the FlowChain-native JSON-RPC discovery and readiness methods, required method coverage, no-secret report boundary, runtime-backed transaction submission, mempool visibility, block/transaction/account/balance/token-balance/provenance reads, and restart continuity. It writes `devnet/local/rpc-e2e/flowchain-rpc-e2e-report.json` and intentionally reports public RPC readiness as blocked until the explicit public RPC deployment inputs are configured.
128128

129129
`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.
130130

services/control-plane/src/methods.ts

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
2-
import { dirname } from "node:path";
1+
import { spawnSync } from "node:child_process";
2+
import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
3+
import { dirname, resolve } from "node:path";
34

45
import { canonicalJson, findSecret, keccak256Hex } from "../../shared/src/index.ts";
56
import { invalidParams, methodNotFound, objectNotFound, secretRejected } from "./errors.ts";
6-
import { loadControlPlaneState, resolveControlPlanePath } from "./fixture-state.ts";
7+
import { loadControlPlaneState, repoRoot, resolveControlPlanePath } from "./fixture-state.ts";
78
import {
89
bridgeLiveReadiness,
910
pilotCapStatus,
@@ -354,6 +355,12 @@ function appendNdjson(path: string, row: JsonObject): void {
354355
appendFileSync(resolved, `${JSON.stringify(row)}\n`);
355356
}
356357

358+
function writeJson(path: string, value: JsonObject): void {
359+
const resolved = resolveControlPlanePath(path);
360+
mkdirSync(dirname(resolved), { recursive: true });
361+
writeFileSync(resolved, `${JSON.stringify(value, null, 2)}\n`);
362+
}
363+
357364
function txIntakeRows(state: LoadedControlPlaneState): JsonObject[] {
358365
const rows = readNdjson(state.paths.txIntakePath);
359366
return rows.length > 0 ? rows : state.txIntake;
@@ -462,6 +469,24 @@ function nodeAccountRows(state: LoadedControlPlaneState): JsonObject[] {
462469
localOnly: true,
463470
});
464471
}
472+
for (const [balanceId, value] of Object.entries(devnetLocalTestUnitBalances(state))) {
473+
const balance = asJsonObject(value) ?? {};
474+
const accountId = stringValue(balance.accountId) ?? balanceId;
475+
if (rows.some((row) => row.accountId === accountId || row.keyReferenceId === accountId)) {
476+
continue;
477+
}
478+
rows.push({
479+
schema: "flowmemory.control_plane.account.v0",
480+
accountId,
481+
accountType: "local_test_unit_balance",
482+
owner: stringValue(balance.owner) ?? null,
483+
balance: stringValue(balance.units) ?? stringValue(balance.amountUnits) ?? "0",
484+
noValue: true,
485+
metadata: balance,
486+
source: "local-devnet:localTestUnitBalances",
487+
localOnly: true,
488+
});
489+
}
465490
if (rows.length === 0) {
466491
rows.push({
467492
schema: "flowmemory.control_plane.account.v0",
@@ -765,7 +790,8 @@ function tokenBalanceRows(state: LoadedControlPlaneState): JsonObject[] {
765790
return {
766791
schema: "flowmemory.control_plane.token_balance.v0",
767792
balanceId,
768-
accountId: stringValue(balance.owner) ?? stringValue(balance.accountId) ?? balanceId,
793+
accountId: stringValue(balance.accountId) ?? balanceId,
794+
owner: stringValue(balance.owner) ?? null,
769795
tokenId: "local-test-unit",
770796
amount: stringValue(balance.units) ?? stringValue(balance.amountUnits) ?? "0",
771797
status: "projected_local_unit",
@@ -1873,13 +1899,114 @@ function signedEnvelopeForSubmit(params: JsonObject): JsonObject {
18731899
return envelope;
18741900
}
18751901

1902+
function transactionFromSignedEnvelope(envelope: JsonObject): JsonObject {
1903+
const transaction = asJsonObject(envelope.tx) ?? asJsonObject(envelope.transaction) ?? asJsonObject(envelope.payload);
1904+
if (transaction === null) {
1905+
throw invalidParams("signed envelope must include tx/transaction/payload");
1906+
}
1907+
return transaction;
1908+
}
1909+
1910+
function runtimeSubmitMode(params: JsonObject): "off" | "direct" {
1911+
const mode = optionalString(params, "runtimeSubmitMode");
1912+
if (mode !== undefined && mode !== "direct" && mode !== "off") {
1913+
throw invalidParams("runtimeSubmitMode must be direct or off", {
1914+
allowed: ["direct", "off"],
1915+
});
1916+
}
1917+
if (mode === "direct") {
1918+
return "direct";
1919+
}
1920+
if (mode === "off") {
1921+
return "off";
1922+
}
1923+
return optionalBoolean(params, "runtimeSubmit") || optionalBoolean(params, "forwardToRuntime")
1924+
? "direct"
1925+
: "off";
1926+
}
1927+
1928+
function safeFileId(value: string): string {
1929+
return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120);
1930+
}
1931+
1932+
function submitEnvelopeToRuntime(
1933+
state: LoadedControlPlaneState,
1934+
signedEnvelope: JsonObject,
1935+
intakeId: string,
1936+
submittedBy: string,
1937+
): JsonObject {
1938+
const tx = transactionFromSignedEnvelope(signedEnvelope);
1939+
const runtimeDir = resolve(dirname(resolveControlPlanePath(state.paths.txIntakePath)), "runtime-submit");
1940+
const fixturePath = resolve(runtimeDir, `${safeFileId(intakeId)}.json`);
1941+
writeJson(fixturePath, {
1942+
schema: "flowmemory.control_plane.runtime_submit_fixture.v0",
1943+
tx,
1944+
});
1945+
1946+
const statePath = resolveControlPlanePath(state.paths.localDevnetPath);
1947+
const nodeDir = resolve(dirname(statePath), "node");
1948+
const args = [
1949+
"run",
1950+
"--manifest-path",
1951+
"crates/flowmemory-devnet/Cargo.toml",
1952+
"--",
1953+
"--state",
1954+
statePath,
1955+
"--node-dir",
1956+
nodeDir,
1957+
"submit-tx",
1958+
"--tx-file",
1959+
fixturePath,
1960+
"--authorized-by",
1961+
submittedBy,
1962+
"--direct",
1963+
];
1964+
const result = spawnSync("cargo", args, {
1965+
cwd: repoRoot(),
1966+
encoding: "utf8",
1967+
windowsHide: true,
1968+
});
1969+
1970+
if (result.error !== undefined) {
1971+
throw invalidParams("runtime submit failed before cargo started", {
1972+
error: result.error.message,
1973+
});
1974+
}
1975+
if (result.status !== 0) {
1976+
throw invalidParams("runtime submit rejected the signed transaction payload", {
1977+
status: result.status,
1978+
stderr: result.stderr.trim().slice(0, 1200),
1979+
});
1980+
}
1981+
1982+
let queued: JsonValue = [];
1983+
try {
1984+
const parsed = JSON.parse(result.stdout) as JsonObject;
1985+
queued = asJsonArray(parsed.queued);
1986+
} catch {
1987+
queued = [];
1988+
}
1989+
1990+
return {
1991+
schema: "flowmemory.control_plane.runtime_submit_result.v0",
1992+
mode: "direct",
1993+
queued,
1994+
statePath,
1995+
nodeDir,
1996+
fixturePath,
1997+
status: "queued_in_runtime_state",
1998+
localOnly: true,
1999+
};
2000+
}
2001+
18762002
function transactionSubmit(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue {
18772003
const state = stateFor(context);
18782004
const objectParams = asObjectParams(params, "transaction_submit");
18792005
const signedEnvelope = signedEnvelopeForSubmit(objectParams);
2006+
const submittedBy = optionalString(objectParams, "submittedBy") ?? "local-control-plane";
18802007
const intakePayload: JsonObject = {
18812008
signedEnvelope,
1882-
submittedBy: optionalString(objectParams, "submittedBy") ?? "local-control-plane",
2009+
submittedBy,
18832010
};
18842011
const finding = findSecret(intakePayload);
18852012
if (finding !== null) {
@@ -1898,14 +2025,19 @@ function transactionSubmit(params: JsonValue | undefined, context: ControlPlaneC
18982025
localOnly: true,
18992026
};
19002027
appendNdjson(state.paths.txIntakePath, row);
2028+
const runtimeMode = runtimeSubmitMode(objectParams);
2029+
const runtimeSubmission = runtimeMode === "direct"
2030+
? submitEnvelopeToRuntime(state, signedEnvelope, intakeId, submittedBy)
2031+
: null;
19012032
return {
19022033
schema: "flowmemory.control_plane.transaction_submit_result.v0",
19032034
accepted: true,
19042035
intakeId,
19052036
txId: row.txId,
19062037
status: row.status,
1907-
forwardedTo: "local-file-intake",
2038+
forwardedTo: runtimeSubmission === null ? "local-file-intake" : "local-runtime-state",
19082039
runtimeIntakePath: state.paths.txIntakePath,
2040+
runtimeSubmission,
19092041
localOnly: true,
19102042
};
19112043
}
@@ -1957,7 +2089,7 @@ function balanceGet(params: JsonValue | undefined, context: ControlPlaneContext)
19572089
return {
19582090
schema: "flowmemory.control_plane.balance.v0",
19592091
accountId: account.accountId,
1960-
amount: "0",
2092+
amount: stringValue(account.balance) ?? "0",
19612093
unit: "no-value-local-credit",
19622094
noValue: true,
19632095
localOnly: true,

0 commit comments

Comments
 (0)