diff --git a/apps/dashboard/public/data/flowchain-bridge-test-deposit.json b/apps/dashboard/public/data/flowchain-bridge-test-deposit.json new file mode 100644 index 00000000..f43813a2 --- /dev/null +++ b/apps/dashboard/public/data/flowchain-bridge-test-deposit.json @@ -0,0 +1,15 @@ +{ + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "sourceChainId": 84532, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" +} diff --git a/apps/dashboard/public/data/flowchain-local-devnet-dashboard-state.json b/apps/dashboard/public/data/flowchain-local-devnet-dashboard-state.json index df33245c..bee21bdf 100644 --- a/apps/dashboard/public/data/flowchain-local-devnet-dashboard-state.json +++ b/apps/dashboard/public/data/flowchain-local-devnet-dashboard-state.json @@ -29,18 +29,20 @@ "uriHint": "fixture://artifact/demo/001" } }, + "balanceTransfers": {}, "baseAnchors": { - "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885": { + "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d": { "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", - "anchorId": "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885", + "anchorId": "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d", "appchainChainId": "flowmemory-local-devnet-v0", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", "blockRangeEnd": 1, "blockRangeStart": 1, "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "finalityStatus": "local-placeholder", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", "memoryCellRoot": "0x1b4e91099dd8d867201bd880437197ae6c031e538341aaa3cd2046e5706a2c25", @@ -48,7 +50,7 @@ "operatorKeyReferenceRoot": "0x8457aa3ed0f4238834a8f3925f25ccca805828d8427c3ef67590a45659b22a40", "previousAnchorId": "0x0000000000000000000000000000000000000000000000000000000000000000", "rootfieldStateRoot": "0xb72a851dca1103410484e3272945bae5e87fc39b8f32f77d2991959b60d3bfbf", - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "verifierModuleRoot": "0xd6ddd8a2d0f5812d64679656c69983a2e0aecd36bd36199d900245658ae4626c", "verifierReportRoot": "0x4facd21e55423e182eba87355482a35daa93f53190fbd3a8d2969f9d55bc5373", "workReceiptRoot": "0x8b3ef5650c9eea2f608ad9c7cb73df3c289fc0ac72ed04f46e6ae4bce0a1f023" @@ -88,7 +90,7 @@ "finalizedBy": "operator:local-demo", "receiptId": "receipt:demo:001", "rootfieldId": "rootfield:demo:alpha", - "stateRoot": "0x2cff83eaf83ea3ae2e9b248ca6ac2b32e23fa3f9ca067c4a9c93e72ef5679d33" + "stateRoot": "0x9a04c063ceb652a89bcdeba147cc36ceeecce9e8a0c6161a5d13b6363a9b5af2" } }, "genesisConfig": { @@ -121,10 +123,11 @@ "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", - "baseAnchorRoot": "0x0f455d919de2d313e88c276b687975249bf3ce53c9cedc27c012a85bcbf0b946", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", + "baseAnchorRoot": "0x50b8b39f15d742afe3efbffc0754fd2956464462de9677e79e00a4736ced8dba", "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "importedObservationRoot": "0x99cb1b939d5a09f800f72e4c5a2b92988571126e1f6f93549f4893b3f7de7880", "importedVerifierReportRoot": "0x6070b1015f000dd509c7b276d2ad68d8a9d188ef1a961c2f573346eb75ea5ad7", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", @@ -190,7 +193,7 @@ } }, "schema": "flowmemory.dashboard_state.local_devnet.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "verifierModules": { "verifier:local-demo": { "active": true, diff --git a/apps/dashboard/public/data/flowchain-local-devnet-state.json b/apps/dashboard/public/data/flowchain-local-devnet-state.json index ccec530d..0f42a2d3 100644 --- a/apps/dashboard/public/data/flowchain-local-devnet-state.json +++ b/apps/dashboard/public/data/flowchain-local-devnet-state.json @@ -19,7 +19,7 @@ "genesisHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9", "nextBlockNumber": 3, "logicalTime": 1778688002, - "parentHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", + "parentHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", "operatorKeyReferences": { "operator-key:local-devnet:alpha": { "schema": "flowmemory.local_devnet.operator_key_reference.v0", @@ -81,6 +81,7 @@ "noValue": true } }, + "balanceTransfers": {}, "modelPassports": { "model:demo:local-alpha": { "modelPassportId": "model:demo:local-alpha", @@ -127,7 +128,7 @@ "finalityStatus": "finalized", "challengeCount": 1, "finalizedAtBlock": 1, - "stateRoot": "0x2cff83eaf83ea3ae2e9b248ca6ac2b32e23fa3f9ca067c4a9c93e72ef5679d33" + "stateRoot": "0x9a04c063ceb652a89bcdeba147cc36ceeecce9e8a0c6161a5d13b6363a9b5af2" } }, "artifactCommitments": { @@ -185,12 +186,12 @@ "importedObservations": {}, "importedVerifierReports": {}, "baseAnchors": { - "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885": { - "anchorId": "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885", + "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d": { + "anchorId": "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d", "appchainChainId": "flowmemory-local-devnet-v0", "blockRangeStart": 1, "blockRangeEnd": 1, - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "workReceiptRoot": "0x8b3ef5650c9eea2f608ad9c7cb73df3c289fc0ac72ed04f46e6ae4bce0a1f023", "verifierReportRoot": "0x4facd21e55423e182eba87355482a35daa93f53190fbd3a8d2969f9d55bc5373", "rootfieldStateRoot": "0xb72a851dca1103410484e3272945bae5e87fc39b8f32f77d2991959b60d3bfbf", @@ -199,10 +200,11 @@ "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", "modelPassportRoot": "0x326aa6b0b372d29d24d747fe0879adfd7aaea206373b24ae2ab77d56357e9529", "memoryCellRoot": "0x1b4e91099dd8d867201bd880437197ae6c031e538341aaa3cd2046e5706a2c25", "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "verifierModuleRoot": "0xd6ddd8a2d0f5812d64679656c69983a2e0aecd36bd36199d900245658ae4626c", "previousAnchorId": "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -309,13 +311,13 @@ "error": null } ], - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", - "blockHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56" + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", + "blockHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c" }, { "schema": "flowmemory.local_devnet.block.v0", "blockNumber": 2, - "parentHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "parentHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "logicalTime": 1778688001, "txIds": [ "0x8f719c880f17b5d4fb6d9efd54ac276d0dd8050d11c2c7870c36a79b66bc49d7" @@ -327,8 +329,8 @@ "error": null } ], - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", - "blockHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82" + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", + "blockHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b" } ], "pendingTxs": [] diff --git a/apps/dashboard/public/data/flowmemory-dashboard-v0.json b/apps/dashboard/public/data/flowmemory-dashboard-v0.json index fc642893..07cb6a94 100644 --- a/apps/dashboard/public/data/flowmemory-dashboard-v0.json +++ b/apps/dashboard/public/data/flowmemory-dashboard-v0.json @@ -1993,11 +1993,11 @@ ], "devnetBlocks": [ { - "id": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "id": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "blockNumber": 1, - "blockHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "blockHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "parentHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9", - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "receiptsRoot": "0x2f98caf4b28b2209cdf1f9beb1c23f8732c538657cc7a1d8855878b5400efabd", "timestamp": "2026-05-13T16:00:00.000Z", "observationCount": 8, @@ -2015,11 +2015,11 @@ } }, { - "id": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", + "id": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", "blockNumber": 2, - "blockHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", - "parentHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "blockHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", + "parentHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "receiptsRoot": "0xa0407b9a8a55106d549e0f19b92fceaa7f7a25697e94ebf8a1fa74af7b9168f4", "timestamp": "2026-05-13T16:00:01.000Z", "observationCount": 8, diff --git a/apps/dashboard/scripts/sync-fixtures.mjs b/apps/dashboard/scripts/sync-fixtures.mjs index 5a73153c..ccf709c3 100644 --- a/apps/dashboard/scripts/sync-fixtures.mjs +++ b/apps/dashboard/scripts/sync-fixtures.mjs @@ -26,6 +26,11 @@ const fixtureCopies = [ source: resolve(repoRoot, "fixtures/launch-core/generated/devnet/dashboard-state.json"), destination: resolve(destinationDir, "flowchain-local-devnet-dashboard-state.json"), }, + { + label: "FlowChain bridge test deposit", + source: resolve(repoRoot, "fixtures/bridge/base-sepolia-mock-deposit.json"), + destination: resolve(destinationDir, "flowchain-bridge-test-deposit.json"), + }, ]; mkdirSync(destinationDir, { recursive: true }); diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 1b97c60e..750ab52e 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -115,7 +115,7 @@ export default function App() { return ( - } /> + setVersion((current) => current + 1)} />} /> } /> } /> } /> diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 11a39e69..b33457b8 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -289,6 +289,13 @@ small { min-width: min(520px, 100%); } +.workbench-header-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + .filter-row { display: grid; grid-template-columns: minmax(240px, 1fr) 150px; diff --git a/apps/dashboard/src/views/WorkbenchView.tsx b/apps/dashboard/src/views/WorkbenchView.tsx index 5bacb0c3..4be659b4 100644 --- a/apps/dashboard/src/views/WorkbenchView.tsx +++ b/apps/dashboard/src/views/WorkbenchView.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Activity, Database, Network, PlayCircle, Search, Server, Terminal } from "lucide-react"; +import { Activity, Database, Network, PlayCircle, RefreshCw, Search, Server, Terminal } from "lucide-react"; import { EmptyState } from "../components/EmptyState"; import { HashValue } from "../components/HashValue"; import { ProvenanceLine } from "../components/ProvenanceLine"; @@ -10,6 +10,12 @@ import { WORKBENCH_SECTIONS, type WorkbenchRecord, type WorkbenchSectionKey, typ const DEFAULT_SECTION: WorkbenchSectionKey = "nodeStatus"; +interface WorkbenchViewProps { + data: DashboardData; + workbench: WorkbenchSnapshot; + onRefresh?: () => void; +} + function displayValue(value: string) { if (value.startsWith("0x") && value.length > 18) { return ; @@ -35,7 +41,7 @@ function missingStateDetail(activeDefinition: (typeof WORKBENCH_SECTIONS)[number return `${activeDefinition.missingService} did not provide records for ${activeDefinition.expectedEndpoint}. Run ${activeDefinition.missingCommand} locally, then refresh this dashboard.`; } -export function WorkbenchView({ data, workbench }: { data: DashboardData; workbench: WorkbenchSnapshot }) { +export function WorkbenchView({ data, workbench, onRefresh }: WorkbenchViewProps) { const [activeSection, setActiveSection] = useState(DEFAULT_SECTION); const [query, setQuery] = useState(""); const [actionResult, setActionResult] = useState(null); @@ -69,10 +75,18 @@ export function WorkbenchView({ data, workbench }: { data: DashboardData; workbe title="Local explorer workbench" detail="Usable browser surface for inspecting the private/local L1 testnet shape. It probes the local control-plane API when available and otherwise renders deterministic committed fixtures; value-bearing wallet flows are not included." action={ - +
+ + {onRefresh ? ( + + ) : null} +
} /> diff --git a/contracts/FlowChainSettlementSpine.sol b/contracts/FlowChainSettlementSpine.sol new file mode 100644 index 00000000..b423e6e2 --- /dev/null +++ b/contracts/FlowChainSettlementSpine.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title FlowChainSettlementSpine +/// @notice Compact local/test settlement event spine for FlowChain object commitments. +/// @dev This records commitment metadata only. Raw objects, verifier logic, and +/// runtime state transitions remain off-chain or in the private/local runtime. +contract FlowChainSettlementSpine { + struct ObjectCommitment { + address submitter; + bytes32 objectType; + bytes32 rootfieldId; + bytes32 commitment; + bytes32 parentObjectId; + uint64 sequence; + uint64 committedAt; + bool exists; + } + + bytes32 public constant BRIDGE_DEPOSIT_OBJECT = keccak256("flowchain.object.bridge-deposit.v0"); + bytes32 public constant MEMORY_OBJECT = keccak256("flowchain.object.memory.v0"); + bytes32 public constant FINALITY_OBJECT = keccak256("flowchain.object.finality.v0"); + + address public owner; + uint64 public nextSequence = 1; + + mapping(address submitter => bool authorized) public authorizedSubmitters; + mapping(bytes32 objectId => ObjectCommitment commitment) private _commitments; + + error NotOwner(address caller); + error SubmitterNotAuthorized(address submitter); + error ZeroOwner(); + error ZeroSubmitter(); + error ZeroObjectType(); + error ZeroObjectId(); + error ZeroRootfieldId(); + error ZeroCommitment(); + error ObjectAlreadyCommitted(bytes32 objectId); + error ObjectNotCommitted(bytes32 objectId); + error TimestampOverflow(uint256 timestamp); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event SubmitterAuthorizationSet(address indexed submitter, bool authorized); + event FlowChainObjectCommitted( + bytes32 indexed objectId, + bytes32 indexed rootfieldId, + bytes32 indexed objectType, + address submitter, + bytes32 commitment, + bytes32 parentObjectId, + uint64 sequence, + uint64 committedAt, + string evidenceURI + ); + + modifier onlyOwner() { + if (msg.sender != owner) { + revert NotOwner(msg.sender); + } + _; + } + + modifier onlyAuthorizedSubmitter() { + if (!authorizedSubmitters[msg.sender]) { + revert SubmitterNotAuthorized(msg.sender); + } + _; + } + + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert ZeroOwner(); + } + owner = initialOwner; + authorizedSubmitters[initialOwner] = true; + emit OwnershipTransferred(address(0), initialOwner); + emit SubmitterAuthorizationSet(initialOwner, true); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) { + revert ZeroOwner(); + } + address previousOwner = owner; + owner = newOwner; + emit OwnershipTransferred(previousOwner, newOwner); + } + + function setSubmitterAuthorization(address submitter, bool authorized) external onlyOwner { + if (submitter == address(0)) { + revert ZeroSubmitter(); + } + authorizedSubmitters[submitter] = authorized; + emit SubmitterAuthorizationSet(submitter, authorized); + } + + function commitObject( + bytes32 objectType, + bytes32 objectId, + bytes32 rootfieldId, + bytes32 commitment, + bytes32 parentObjectId, + string calldata evidenceURI + ) external onlyAuthorizedSubmitter returns (uint64 sequence) { + if (objectType == bytes32(0)) { + revert ZeroObjectType(); + } + if (objectId == bytes32(0)) { + revert ZeroObjectId(); + } + if (rootfieldId == bytes32(0)) { + revert ZeroRootfieldId(); + } + if (commitment == bytes32(0)) { + revert ZeroCommitment(); + } + if (_commitments[objectId].exists) { + revert ObjectAlreadyCommitted(objectId); + } + + sequence = nextSequence++; + uint64 committedAt = _blockTimestamp(); + _commitments[objectId] = ObjectCommitment({ + submitter: msg.sender, + objectType: objectType, + rootfieldId: rootfieldId, + commitment: commitment, + parentObjectId: parentObjectId, + sequence: sequence, + committedAt: committedAt, + exists: true + }); + + emit FlowChainObjectCommitted({ + objectId: objectId, + rootfieldId: rootfieldId, + objectType: objectType, + submitter: msg.sender, + commitment: commitment, + parentObjectId: parentObjectId, + sequence: sequence, + committedAt: committedAt, + evidenceURI: evidenceURI + }); + } + + function getObjectCommitment(bytes32 objectId) external view returns (ObjectCommitment memory commitment) { + commitment = _commitments[objectId]; + if (!commitment.exists) { + revert ObjectNotCommitted(objectId); + } + } + + function isObjectCommitted(bytes32 objectId) external view returns (bool) { + return _commitments[objectId].exists; + } + + function _blockTimestamp() private view returns (uint64) { + if (block.timestamp > type(uint64).max) { + revert TimestampOverflow(block.timestamp); + } + return uint64(block.timestamp); + } +} diff --git a/contracts/bridge/BaseBridgeLockbox.sol b/contracts/bridge/BaseBridgeLockbox.sol index a3ceefce..a6b0c4b4 100644 --- a/contracts/bridge/BaseBridgeLockbox.sol +++ b/contracts/bridge/BaseBridgeLockbox.sol @@ -18,29 +18,55 @@ contract BaseBridgeLockbox { uint256 totalLocked; } + struct DepositRecord { + address sender; + address token; + uint256 amount; + uint256 released; + bytes32 flowchainRecipient; + uint256 nonce; + bytes32 metadataHash; + bool exists; + } + address public constant NATIVE_TOKEN = address(0); + bytes32 public constant BRIDGE_DEPOSIT_SCHEMA_ID = keccak256("flowmemory.bridge.deposit.v0"); + bytes32 public constant BRIDGE_RELEASE_SCHEMA_ID = keccak256("flowmemory.bridge.release.v0"); address public owner; + address public releaseAuthority; bool public paused; uint256 public nextNonce = 1; mapping(address token => TokenConfig config) public tokenConfigs; mapping(bytes32 depositId => bool seen) public deposits; + mapping(bytes32 depositId => DepositRecord record) public depositRecords; mapping(bytes32 releaseId => bool seen) public releases; + bool private _entered; + error NotOwner(address caller); + error NotReleaseAuthority(address caller); error Paused(); + error ReentrantCall(); error ZeroOwner(); + error ZeroReleaseAuthority(); error ZeroRecipient(); error ZeroToken(); error ZeroAmount(); + error ZeroEvidenceHash(); error TokenNotAllowed(address token); error PerDepositCapExceeded(address token, uint256 amount, uint256 cap); error TotalCapExceeded(address token, uint256 nextTotal, uint256 cap); error TransferFailed(); + error DepositAlreadyRecorded(bytes32 depositId); + error DepositNotRecorded(bytes32 depositId); + error ReleaseTokenMismatch(bytes32 depositId, address expectedToken, address actualToken); + error ReleaseAmountExceeded(bytes32 depositId, uint256 requested, uint256 available); error ReleaseAlreadyProcessed(bytes32 releaseId); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event ReleaseAuthoritySet(address indexed previousAuthority, address indexed newAuthority); event PausedSet(bool paused); event TokenConfigured(address indexed token, bool allowed, uint256 perDepositCap, uint256 totalCap); event BridgeDeposit( @@ -69,6 +95,13 @@ contract BaseBridgeLockbox { _; } + modifier onlyReleaseAuthority() { + if (msg.sender != releaseAuthority) { + revert NotReleaseAuthority(msg.sender); + } + _; + } + modifier whenNotPaused() { if (paused) { revert Paused(); @@ -76,12 +109,26 @@ contract BaseBridgeLockbox { _; } - constructor(address initialOwner) { + modifier nonReentrant() { + if (_entered) { + revert ReentrantCall(); + } + _entered = true; + _; + _entered = false; + } + + constructor(address initialOwner, address initialReleaseAuthority) { if (initialOwner == address(0)) { revert ZeroOwner(); } + if (initialReleaseAuthority == address(0)) { + revert ZeroReleaseAuthority(); + } owner = initialOwner; + releaseAuthority = initialReleaseAuthority; emit OwnershipTransferred(address(0), initialOwner); + emit ReleaseAuthoritySet(address(0), initialReleaseAuthority); } receive() external payable { @@ -97,25 +144,32 @@ contract BaseBridgeLockbox { emit OwnershipTransferred(previousOwner, newOwner); } + function setReleaseAuthority(address newAuthority) external onlyOwner { + if (newAuthority == address(0)) { + revert ZeroReleaseAuthority(); + } + address previousAuthority = releaseAuthority; + releaseAuthority = newAuthority; + emit ReleaseAuthoritySet(previousAuthority, newAuthority); + } + function setPaused(bool value) external onlyOwner { paused = value; emit PausedSet(value); } function configureToken(address token, bool allowed, uint256 perDepositCap, uint256 totalCap) external onlyOwner { - if (token != NATIVE_TOKEN && token == address(0)) { - revert ZeroToken(); - } + TokenConfig storage config = tokenConfigs[token]; if (allowed && perDepositCap == 0) { revert ZeroAmount(); } - if (allowed && totalCap != 0 && totalCap < tokenConfigs[token].totalLocked) { - revert TotalCapExceeded(token, tokenConfigs[token].totalLocked, totalCap); + if (allowed && totalCap != 0 && totalCap < config.totalLocked) { + revert TotalCapExceeded(token, config.totalLocked, totalCap); } - tokenConfigs[token].allowed = allowed; - tokenConfigs[token].perDepositCap = perDepositCap; - tokenConfigs[token].totalCap = totalCap; + config.allowed = allowed; + config.perDepositCap = perDepositCap; + config.totalCap = totalCap; emit TokenConfigured(token, allowed, perDepositCap, totalCap); } @@ -123,6 +177,7 @@ contract BaseBridgeLockbox { external payable whenNotPaused + nonReentrant returns (bytes32 depositId) { depositId = _lock(NATIVE_TOKEN, msg.value, msg.sender, flowchainRecipient, metadataHash); @@ -131,6 +186,7 @@ contract BaseBridgeLockbox { function lockERC20(address token, uint256 amount, bytes32 flowchainRecipient, bytes32 metadataHash) external whenNotPaused + nonReentrant returns (bytes32 depositId) { if (token == NATIVE_TOKEN) { @@ -144,7 +200,8 @@ contract BaseBridgeLockbox { function releaseNative(bytes32 depositId, address payable recipient, uint256 amount, bytes32 evidenceHash) external - onlyOwner + onlyReleaseAuthority + nonReentrant returns (bytes32 releaseId) { releaseId = _recordRelease(depositId, recipient, NATIVE_TOKEN, amount, evidenceHash); @@ -156,7 +213,8 @@ contract BaseBridgeLockbox { function releaseERC20(bytes32 depositId, address recipient, address token, uint256 amount, bytes32 evidenceHash) external - onlyOwner + onlyReleaseAuthority + nonReentrant returns (bytes32 releaseId) { if (token == NATIVE_TOKEN) { @@ -168,6 +226,18 @@ contract BaseBridgeLockbox { } } + function remainingDepositAmount(bytes32 depositId) external view returns (uint256) { + DepositRecord storage record = depositRecords[depositId]; + if (!record.exists) { + return 0; + } + return record.amount - record.released; + } + + function getDepositRecord(bytes32 depositId) external view returns (DepositRecord memory) { + return depositRecords[depositId]; + } + function _lock(address token, uint256 amount, address sender, bytes32 flowchainRecipient, bytes32 metadataHash) private returns (bytes32 depositId) @@ -193,8 +263,34 @@ contract BaseBridgeLockbox { } uint256 nonce = nextNonce++; - depositId = keccak256(abi.encode(block.chainid, address(this), sender, token, amount, flowchainRecipient, nonce)); + depositId = keccak256( + abi.encode( + BRIDGE_DEPOSIT_SCHEMA_ID, + block.chainid, + address(this), + sender, + token, + amount, + flowchainRecipient, + nonce, + metadataHash + ) + ); + if (deposits[depositId]) { + revert DepositAlreadyRecorded(depositId); + } + deposits[depositId] = true; + depositRecords[depositId] = DepositRecord({ + sender: sender, + token: token, + amount: amount, + released: 0, + flowchainRecipient: flowchainRecipient, + nonce: nonce, + metadataHash: metadataHash, + exists: true + }); config.totalLocked = nextTotal; emit BridgeDeposit({ @@ -209,32 +305,54 @@ contract BaseBridgeLockbox { }); } - function _recordRelease( - bytes32 depositId, - address recipient, - address token, - uint256 amount, - bytes32 evidenceHash - ) private returns (bytes32 releaseId) { + function _recordRelease(bytes32 depositId, address recipient, address token, uint256 amount, bytes32 evidenceHash) + private + returns (bytes32 releaseId) + { if (recipient == address(0)) { revert ZeroRecipient(); } if (amount == 0) { revert ZeroAmount(); } + if (evidenceHash == bytes32(0)) { + revert ZeroEvidenceHash(); + } - releaseId = keccak256(abi.encode(block.chainid, address(this), depositId, recipient, token, amount, evidenceHash)); + DepositRecord storage record = depositRecords[depositId]; + if (!record.exists) { + revert DepositNotRecorded(depositId); + } + if (record.token != token) { + revert ReleaseTokenMismatch(depositId, record.token, token); + } + + releaseId = keccak256( + abi.encode( + BRIDGE_RELEASE_SCHEMA_ID, + block.chainid, + address(this), + depositId, + recipient, + token, + amount, + evidenceHash + ) + ); if (releases[releaseId]) { revert ReleaseAlreadyProcessed(releaseId); } + + uint256 available = record.amount - record.released; + if (amount > available) { + revert ReleaseAmountExceeded(depositId, amount, available); + } + releases[releaseId] = true; + record.released += amount; TokenConfig storage config = tokenConfigs[token]; - if (config.totalLocked >= amount) { - config.totalLocked -= amount; - } else { - config.totalLocked = 0; - } + config.totalLocked -= amount; emit BridgeRelease({ releaseId: releaseId, diff --git a/crates/flowmemory-devnet/src/cli.rs b/crates/flowmemory-devnet/src/cli.rs index e64d07f8..3599bc11 100644 --- a/crates/flowmemory-devnet/src/cli.rs +++ b/crates/flowmemory-devnet/src/cli.rs @@ -1,19 +1,23 @@ use crate::hash::{hash_json, normalize_value}; use crate::model::{ - FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, ImportedVerifierReport, Transaction, - build_block, demo_transactions, genesis_state, queue_transaction, state_map_roots, state_root, + FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, ImportedVerifierReport, LocalAuthorization, + Transaction, build_block, demo_transactions, envelope_tx, genesis_state, + queue_authorized_transaction, queue_transaction, state_map_roots, state_root, }; use crate::storage::{default_state_path, load_or_genesis, load_state, reset_state, save_state}; use anyhow::{Context, Result, anyhow}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; #[derive(Debug)] pub struct Cli { state: PathBuf, + node_dir: PathBuf, command: Command, } @@ -22,6 +26,30 @@ pub enum Command { Init, ResetLocal, Start { blocks: u64 }, + Node { + node_id: String, + block_ms: u64, + max_blocks: Option, + peer_config: Option, + }, + NodeStop, + NodeStatus, + Tick { + node_id: String, + peer_config: Option, + }, + SubmitTx { + tx_file: PathBuf, + authorized_by: Option, + direct: bool, + }, + Faucet { + account_id: String, + amount: u64, + reason: String, + authorized_by: Option, + direct: bool, + }, SubmitFixture { fixture: PathBuf }, InspectState { summary: bool }, ExportFixtures { out_dir: PathBuf }, @@ -38,6 +66,7 @@ pub fn run_cli() -> Result<()> { fn parse_args(args: Vec) -> Result { let mut state = default_state_path(); + let mut node_dir = default_node_dir(); let mut index = 0; let mut positional = Vec::new(); @@ -50,6 +79,13 @@ fn parse_args(args: Vec) -> Result { .ok_or_else(|| anyhow!("--state requires a path"))?; state = PathBuf::from(value); } + "--node-dir" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| anyhow!("--node-dir requires a path"))?; + node_dir = PathBuf::from(value); + } "--help" | "-h" => { print_help(); std::process::exit(0); @@ -70,6 +106,39 @@ fn parse_args(args: Vec) -> Result { "start" | "run" => Command::Start { blocks: option_u64(&positional[1..], "--blocks")?.unwrap_or(1), }, + "node" => Command::Node { + node_id: option_value_optional(&positional[1..], "--node-id") + .unwrap_or_else(|| "node:local:alpha".to_string()), + block_ms: option_u64(&positional[1..], "--block-ms")?.unwrap_or(1_000), + max_blocks: option_u64(&positional[1..], "--max-blocks")?, + peer_config: option_value_optional(&positional[1..], "--peer-config") + .map(PathBuf::from), + }, + "node-stop" => Command::NodeStop, + "node-status" => Command::NodeStatus, + "tick" => Command::Tick { + node_id: option_value_optional(&positional[1..], "--node-id") + .unwrap_or_else(|| "node:local:alpha".to_string()), + peer_config: option_value_optional(&positional[1..], "--peer-config") + .map(PathBuf::from), + }, + "submit-tx" => { + let tx_file = option_value(&positional[1..], "--tx-file")?; + Command::SubmitTx { + tx_file: PathBuf::from(tx_file), + authorized_by: option_value_optional(&positional[1..], "--authorized-by"), + direct: positional.iter().any(|arg| arg == "--direct"), + } + } + "faucet" => Command::Faucet { + account_id: option_value(&positional[1..], "--account")?, + amount: option_u64(&positional[1..], "--amount")? + .ok_or_else(|| anyhow!("--amount is required"))?, + reason: option_value_optional(&positional[1..], "--reason") + .unwrap_or_else(|| "local-private-testnet-faucet".to_string()), + authorized_by: option_value_optional(&positional[1..], "--authorized-by"), + direct: positional.iter().any(|arg| arg == "--direct"), + }, "submit-fixture" => { let fixture = option_value(&positional[1..], "--fixture")?; Command::SubmitFixture { @@ -105,7 +174,15 @@ fn parse_args(args: Vec) -> Result { unknown => return Err(anyhow!("unknown command '{unknown}'")), }; - Ok(Cli { state, command }) + Ok(Cli { + state, + node_dir, + command, + }) +} + +fn default_node_dir() -> PathBuf { + PathBuf::from("devnet/local/node") } fn option_value(args: &[String], name: &str) -> Result { @@ -118,6 +195,11 @@ fn option_value(args: &[String], name: &str) -> Result { .ok_or_else(|| anyhow!("{name} requires a value")) } +fn option_value_optional(args: &[String], name: &str) -> Option { + let index = args.iter().position(|arg| arg == name)?; + args.get(index + 1).cloned() +} + fn option_u64(args: &[String], name: &str) -> Result> { let Some(index) = args.iter().position(|arg| arg == name) else { return Ok(None); @@ -133,7 +215,7 @@ fn option_u64(args: &[String], name: &str) -> Result> { fn print_help() { println!( - "flowmemory-devnet --state \n\nCommands:\n init\n reset-local\n start|run [--blocks ]\n run-block\n submit-fixture --fixture \n inspect|inspect-state [--summary]\n export|export-fixtures [--out-dir ]\n export-state [--out ]\n import-state --from \n demo [--out-dir ]\n smoke [--out-dir ]\n" + "flowmemory-devnet --state --node-dir \n\nCommands:\n init\n reset-local\n node [--node-id ] [--block-ms ] [--max-blocks ] [--peer-config ]\n node-stop\n node-status\n tick [--node-id ] [--peer-config ]\n submit-tx --tx-file [--authorized-by ] [--direct]\n faucet --account --amount [--reason ] [--authorized-by ] [--direct]\n start|run [--blocks ]\n run-block\n submit-fixture --fixture \n inspect|inspect-state [--summary]\n export|export-fixtures [--out-dir ]\n export-state [--out ]\n import-state --from \n demo [--out-dir ]\n smoke [--out-dir ]\n" ); } @@ -150,6 +232,135 @@ fn run(cli: Cli) -> Result<()> { write_runtime_boundary_files(&cli.state, &state)?; print_json(&StateSummary::from_state(&state))?; } + Command::Node { + node_id, + block_ms, + max_blocks, + peer_config, + } => { + run_node(NodeRunOptions { + state_path: cli.state, + node_dir: cli.node_dir, + node_id, + block_ms, + max_blocks, + peer_config, + })?; + } + Command::NodeStop => { + request_node_stop(&cli.node_dir)?; + let state = load_or_genesis(&cli.state)?; + let stop_path = stop_file(&cli.node_dir); + write_node_status( + &cli.node_dir, + &NodeStatus::from_state( + "stopping", + "local stop requested", + "node:local:unknown", + 0, + &cli.state, + &cli.node_dir, + &state, + 0, + 0, + None, + ), + )?; + print_json(&NodeStopSummary { + schema: "flowmemory.local_devnet.node_stop.v0".to_string(), + node_dir: cli.node_dir, + stop_file: stop_path, + requested: true, + })?; + } + Command::NodeStatus => { + let state = load_or_genesis(&cli.state)?; + let persisted_status = read_node_status(&cli.node_dir)?; + print_json(&NodeStatusSummary::from_state( + cli.state, + cli.node_dir, + &state, + persisted_status, + ))?; + } + Command::Tick { + node_id, + peer_config, + } => { + let mut state = load_or_genesis(&cli.state)?; + let peers = load_peer_config(peer_config.as_deref())?; + let sync_event = sync_from_peers(&mut state, &peers)?; + let ingested = drain_inbox(&mut state, &cli.node_dir)?; + let produced = build_block(&mut state); + save_state(&cli.state, &state)?; + write_runtime_boundary_files(&cli.state, &state)?; + let status = NodeStatus::from_state( + "ticked", + "manual tick completed", + &node_id, + std::process::id(), + &cli.state, + &cli.node_dir, + &state, + ingested.queued, + ingested.rejected, + sync_event, + ); + write_node_identity(&cli.node_dir, &node_id, &cli.state, peer_config.as_deref())?; + write_node_status(&cli.node_dir, &status)?; + print_json(&NodeTickSummary::from_block(status, produced.block_hash))?; + } + Command::SubmitTx { + tx_file, + authorized_by, + direct, + } => { + let txs = transactions_from_fixture(&tx_file)?; + let queued = if direct { + queue_txs_direct(&cli.state, txs, authorized_by)? + } else { + write_txs_to_inbox(&cli.node_dir, txs, authorized_by)? + }; + print_json(&QueuedTransactions { queued })?; + } + Command::Faucet { + account_id, + amount, + reason, + authorized_by, + direct, + } => { + let faucet_record_id = crate::hash::hash_json( + "flowmemory.local_devnet.faucet_record_id.v0", + &serde_json::json!({ + "accountId": &account_id, + "amount": amount, + "reason": &reason + }), + ); + let owner = authorized_by + .clone() + .unwrap_or_else(|| "local-test-operator".to_string()); + let txs = vec![ + Transaction::CreateLocalTestUnitBalance { + account_id: account_id.clone(), + owner: owner.clone(), + }, + Transaction::FaucetLocalTestUnits { + faucet_record_id, + account_id, + recipient: owner, + amount_units: amount, + reason, + }, + ]; + let queued = if direct { + queue_txs_direct(&cli.state, txs, authorized_by)? + } else { + write_txs_to_inbox(&cli.node_dir, txs, authorized_by)? + }; + print_json(&QueuedTransactions { queued })?; + } Command::Start { blocks } => { let mut state = load_or_genesis(&cli.state)?; let produced = build_blocks(&mut state, blocks)?; @@ -234,6 +445,451 @@ fn build_blocks( Ok(produced) } +#[derive(Debug)] +struct NodeRunOptions { + state_path: PathBuf, + node_dir: PathBuf, + node_id: String, + block_ms: u64, + max_blocks: Option, + peer_config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PeerConfig { + #[serde(default = "peer_config_schema")] + schema: String, + #[serde(default)] + node_id: Option, + #[serde(default)] + peers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StaticPeer { + node_id: String, + state_path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PeerSyncEvent { + peer_id: String, + peer_state_path: PathBuf, + adopted_block_height: usize, + adopted_state_root: String, +} + +#[derive(Debug, Default)] +struct InboxIngestSummary { + queued: usize, + rejected: usize, +} + +fn peer_config_schema() -> String { + "flowmemory.local_devnet.static_peers.v0".to_string() +} + +fn run_node(options: NodeRunOptions) -> Result<()> { + if options.block_ms == 0 { + return Err(anyhow!("--block-ms must be greater than zero")); + } + + fs::create_dir_all(inbox_dir(&options.node_dir))?; + fs::create_dir_all(processed_dir(&options.node_dir))?; + fs::create_dir_all(rejected_dir(&options.node_dir))?; + let stop_path = stop_file(&options.node_dir); + if stop_path.exists() { + fs::remove_file(&stop_path) + .with_context(|| format!("failed to remove stale stop file {}", stop_path.display()))?; + } + + let peers = load_peer_config(options.peer_config.as_deref())?; + write_node_identity( + &options.node_dir, + &options.node_id, + &options.state_path, + options.peer_config.as_deref(), + )?; + + let mut state = load_or_genesis(&options.state_path)?; + save_state(&options.state_path, &state)?; + write_runtime_boundary_files(&options.state_path, &state)?; + + let mut produced = 0_u64; + loop { + if stop_path.exists() { + let status = NodeStatus::from_state( + "stopped", + "stop file observed", + &options.node_id, + std::process::id(), + &options.state_path, + &options.node_dir, + &state, + 0, + 0, + None, + ); + write_node_status(&options.node_dir, &status)?; + println!("{}", serde_json::to_string(&status)?); + break; + } + + let sync_event = sync_from_peers(&mut state, &peers)?; + let ingested = drain_inbox(&mut state, &options.node_dir)?; + let block = build_block(&mut state); + produced += 1; + save_state(&options.state_path, &state)?; + write_runtime_boundary_files(&options.state_path, &state)?; + + let status = NodeStatus::from_state( + "running", + "block produced", + &options.node_id, + std::process::id(), + &options.state_path, + &options.node_dir, + &state, + ingested.queued, + ingested.rejected, + sync_event, + ); + write_node_status(&options.node_dir, &status)?; + + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "schema": "flowmemory.local_devnet.node_log.v0", + "nodeId": options.node_id, + "event": "blockProduced", + "blockNumber": block.block_number, + "blockHash": block.block_hash, + "txs": block.tx_ids.len(), + "stateRoot": block.state_root + }))? + ); + + if options + .max_blocks + .is_some_and(|max_blocks| produced >= max_blocks) + { + let status = NodeStatus::from_state( + "stopped", + "max blocks reached", + &options.node_id, + std::process::id(), + &options.state_path, + &options.node_dir, + &state, + 0, + 0, + None, + ); + write_node_status(&options.node_dir, &status)?; + println!("{}", serde_json::to_string(&status)?); + break; + } + + thread::sleep(Duration::from_millis(options.block_ms)); + } + + Ok(()) +} + +fn request_node_stop(node_dir: &Path) -> Result<()> { + fs::create_dir_all(node_dir) + .with_context(|| format!("failed to create node directory {}", node_dir.display()))?; + fs::write(stop_file(node_dir), b"stop\n") + .with_context(|| format!("failed to write node stop file in {}", node_dir.display())) +} + +fn queue_txs_direct( + state_path: &Path, + txs: Vec, + authorized_by: Option, +) -> Result> { + let mut state = load_or_genesis(state_path)?; + let mut queued = Vec::new(); + for tx in txs { + let tx_id = match authorized_by.clone() { + Some(signer) => queue_authorized_transaction(&mut state, tx, signer), + None => queue_transaction(&mut state, tx), + }; + queued.push(tx_id); + } + save_state(state_path, &state)?; + Ok(queued) +} + +fn write_txs_to_inbox( + node_dir: &Path, + txs: Vec, + authorized_by: Option, +) -> Result> { + let inbox = inbox_dir(node_dir); + fs::create_dir_all(&inbox) + .with_context(|| format!("failed to create inbox directory {}", inbox.display()))?; + + let mut queued = Vec::new(); + for tx in txs { + let envelope = local_authorized_envelope(tx, authorized_by.clone()); + let tx_id = envelope.tx_id.clone(); + let path = inbox.join(format!("{}.json", file_safe_id(&tx_id))); + write_json( + path, + &serde_json::json!({ + "schema": "flowmemory.local_devnet.inbox_tx.v0", + "tx": envelope.tx, + "authorization": envelope.authorization + }), + )?; + queued.push(tx_id); + } + Ok(queued) +} + +fn drain_inbox( + state: &mut crate::model::ChainState, + node_dir: &Path, +) -> Result { + let inbox = inbox_dir(node_dir); + if !inbox.exists() { + return Ok(InboxIngestSummary::default()); + } + + fs::create_dir_all(processed_dir(node_dir))?; + fs::create_dir_all(rejected_dir(node_dir))?; + let mut files = fs::read_dir(&inbox)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + }) + .collect::>(); + files.sort(); + + let mut summary = InboxIngestSummary::default(); + for path in files { + match transactions_from_inbox_file(&path) { + Ok(txs) => { + for (tx, authorization) in txs { + let mut envelope = envelope_tx(tx); + envelope.authorization = authorization; + state.pending_txs.push(envelope); + summary.queued += 1; + } + move_inbox_file(&path, &processed_dir(node_dir))?; + } + Err(error) => { + let error_path = rejected_dir(node_dir).join(format!( + "{}.error.json", + path.file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("rejected") + )); + write_json( + error_path, + &serde_json::json!({ + "schema": "flowmemory.local_devnet.rejected_inbox_tx.v0", + "source": path, + "error": error.to_string() + }), + )?; + move_inbox_file(&path, &rejected_dir(node_dir))?; + summary.rejected += 1; + } + } + } + + Ok(summary) +} + +fn move_inbox_file(path: &Path, target_dir: &Path) -> Result<()> { + fs::create_dir_all(target_dir)?; + let file_name = path + .file_name() + .ok_or_else(|| anyhow!("inbox path has no file name: {}", path.display()))?; + let target = target_dir.join(file_name); + if target.exists() { + fs::remove_file(&target)?; + } + fs::rename(path, target)?; + Ok(()) +} + +fn local_authorized_envelope( + tx: Transaction, + authorized_by: Option, +) -> crate::model::TxEnvelope { + let mut envelope = envelope_tx(tx); + if let Some(signer) = authorized_by { + envelope.authorization = Some(LocalAuthorization { + mode: "local-authorized".to_string(), + signer, + digest: envelope.tx_id.clone(), + }); + } + envelope +} + +fn load_peer_config(path: Option<&Path>) -> Result> { + let Some(path) = path else { + return Ok(Vec::new()); + }; + let body = fs::read_to_string(path) + .with_context(|| format!("failed to read peer config {}", path.display()))?; + let config: PeerConfig = serde_json::from_str(body.trim_start_matches('\u{feff}')) + .with_context(|| format!("failed to parse peer config {}", path.display()))?; + Ok(config.peers) +} + +fn sync_from_peers( + state: &mut crate::model::ChainState, + peers: &[StaticPeer], +) -> Result> { + let mut adopted = None; + for peer in peers { + if !peer.state_path.exists() { + continue; + } + let peer_state = load_state(&peer.state_path)?; + if peer_state.chain_id != state.chain_id { + continue; + } + if should_adopt_peer_state(state, &peer_state) { + let adopted_state_root = state_root(&peer_state); + let adopted_block_height = peer_state.blocks.len(); + *state = peer_state; + adopted = Some(PeerSyncEvent { + peer_id: peer.node_id.clone(), + peer_state_path: peer.state_path.clone(), + adopted_block_height, + adopted_state_root, + }); + } + } + Ok(adopted) +} + +fn should_adopt_peer_state( + local: &crate::model::ChainState, + peer: &crate::model::ChainState, +) -> bool { + let local_height = local.blocks.len(); + let peer_height = peer.blocks.len(); + if peer_height > local_height { + return true; + } + if peer_height == local_height && peer_height > 0 { + return state_root(peer) < state_root(local); + } + false +} + +fn write_node_identity( + node_dir: &Path, + node_id: &str, + state_path: &Path, + peer_config: Option<&Path>, +) -> Result<()> { + fs::create_dir_all(node_dir)?; + write_json( + node_dir.join("node-identity.json"), + &serde_json::json!({ + "schema": "flowmemory.local_devnet.node_identity.v0", + "nodeId": node_id, + "mode": "local-file-private-testnet", + "statePath": state_path, + "peerConfig": peer_config, + "localOnly": true, + "lanMode": "not exposed; static local-file peers only" + }), + ) +} + +fn write_node_status(node_dir: &Path, status: &NodeStatus) -> Result<()> { + fs::create_dir_all(node_dir)?; + write_json(node_dir.join("status.json"), status) +} + +fn read_node_status(node_dir: &Path) -> Result> { + let path = node_dir.join("status.json"); + if !path.exists() { + return Ok(None); + } + let body = fs::read_to_string(&path) + .with_context(|| format!("failed to read node status {}", path.display()))?; + serde_json::from_str(&body) + .map(Some) + .with_context(|| format!("failed to parse node status {}", path.display())) +} + +fn inbox_dir(node_dir: &Path) -> PathBuf { + node_dir.join("inbox") +} + +fn processed_dir(node_dir: &Path) -> PathBuf { + node_dir.join("processed") +} + +fn rejected_dir(node_dir: &Path) -> PathBuf { + node_dir.join("rejected") +} + +fn stop_file(node_dir: &Path) -> PathBuf { + node_dir.join("stop") +} + +fn file_safe_id(id: &str) -> String { + id.chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' => ch, + _ => '-', + }) + .collect() +} + +fn transactions_from_inbox_file( + path: &Path, +) -> Result)>> { + let body = fs::read_to_string(path) + .with_context(|| format!("failed to read inbox transaction {}", path.display()))?; + let value: Value = serde_json::from_str(body.trim_start_matches('\u{feff}')) + .with_context(|| format!("failed to parse inbox transaction {}", path.display()))?; + let authorization = authorization_from_value(value.get("authorization"))?; + + if value.get("txs").is_some() { + let txs: Vec = serde_json::from_value(value["txs"].clone()) + .with_context(|| format!("failed to parse txs in {}", path.display()))?; + return Ok(txs + .into_iter() + .map(|tx| (tx, authorization.clone())) + .collect()); + } + + if value.get("tx").is_some() { + let tx = serde_json::from_value(value["tx"].clone()) + .with_context(|| format!("failed to parse tx in {}", path.display()))?; + return Ok(vec![(tx, authorization)]); + } + + transactions_from_fixture(path).map(|txs| txs.into_iter().map(|tx| (tx, None)).collect()) +} + +fn authorization_from_value(value: Option<&Value>) -> Result> { + match value { + Some(Value::Null) | None => Ok(None), + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .context("failed to parse local authorization"), + } +} + struct DemoRun { state: crate::model::ChainState, first_block_hash: String, @@ -388,6 +1044,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "agentAccounts": state.agent_accounts, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "modelPassports": state.model_passports, "memoryCells": state.memory_cells, "challenges": state.challenges, @@ -408,6 +1065,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "agentAccounts": state.agent_accounts, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "memoryCells": state.memory_cells, "challenges": state.challenges, "finalityReceipts": state.finality_receipts, @@ -423,6 +1081,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "operatorKeyReferences": state.operator_key_references, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, @@ -449,6 +1108,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "agentAccounts": state.agent_accounts, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "modelPassports": state.model_passports, "memoryCells": state.memory_cells, "challenges": state.challenges, @@ -521,6 +1181,128 @@ struct QueuedTransactions { queued: Vec, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeStatus { + schema: String, + status: String, + note: String, + node_id: String, + pid: u32, + state_path: PathBuf, + node_dir: PathBuf, + block_height: usize, + next_block_number: u64, + latest_block_hash: String, + state_root: String, + pending_txs: usize, + local_test_unit_balances: usize, + faucet_records: usize, + balance_transfers: usize, + static_peer_sync: Option, + last_ingested_txs: usize, + last_rejected_inbox_files: usize, + lan_mode: String, +} + +impl NodeStatus { + fn from_state( + status: &str, + note: &str, + node_id: &str, + pid: u32, + state_path: &Path, + node_dir: &Path, + state: &crate::model::ChainState, + last_ingested_txs: usize, + last_rejected_inbox_files: usize, + static_peer_sync: Option, + ) -> Self { + Self { + schema: "flowmemory.local_devnet.node_status.v0".to_string(), + status: status.to_string(), + note: note.to_string(), + node_id: node_id.to_string(), + pid, + state_path: state_path.to_path_buf(), + node_dir: node_dir.to_path_buf(), + block_height: state.blocks.len(), + next_block_number: state.next_block_number, + latest_block_hash: state + .blocks + .last() + .map(|block| block.block_hash.clone()) + .unwrap_or_else(|| state.parent_hash.clone()), + state_root: state_root(state), + pending_txs: state.pending_txs.len(), + local_test_unit_balances: state.local_test_unit_balances.len(), + faucet_records: state.faucet_records.len(), + balance_transfers: state.balance_transfers.len(), + static_peer_sync, + last_ingested_txs, + last_rejected_inbox_files, + lan_mode: "not exposed; static local-file peers only".to_string(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeStatusSummary { + schema: String, + state_path: PathBuf, + node_dir: PathBuf, + stop_requested: bool, + state: StateSummary, + persisted_status: Option, +} + +impl NodeStatusSummary { + fn from_state( + state_path: PathBuf, + node_dir: PathBuf, + state: &crate::model::ChainState, + persisted_status: Option, + ) -> Self { + let stop_requested = stop_file(&node_dir).exists(); + Self { + schema: "flowmemory.local_devnet.node_status_summary.v0".to_string(), + state_path, + node_dir, + stop_requested, + state: StateSummary::from_state(state), + persisted_status, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeStopSummary { + schema: String, + node_dir: PathBuf, + stop_file: PathBuf, + requested: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeTickSummary { + schema: String, + block_hash: String, + status: NodeStatus, +} + +impl NodeTickSummary { + fn from_block(status: NodeStatus, block_hash: String) -> Self { + Self { + schema: "flowmemory.local_devnet.node_tick.v0".to_string(), + block_hash, + status, + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct StateSummary { @@ -536,8 +1318,10 @@ struct StateSummary { blocks: usize, rootfields: usize, agent_accounts: usize, + local_balances: usize, local_test_unit_balances: usize, faucet_records: usize, + balance_transfers: usize, model_passports: usize, memory_cells: usize, challenges: usize, @@ -567,8 +1351,10 @@ impl StateSummary { blocks: state.blocks.len(), rootfields: state.rootfields.len(), agent_accounts: state.agent_accounts.len(), + local_balances: state.local_test_unit_balances.len(), local_test_unit_balances: state.local_test_unit_balances.len(), faucet_records: state.faucet_records.len(), + balance_transfers: state.balance_transfers.len(), model_passports: state.model_passports.len(), memory_cells: state.memory_cells.len(), challenges: state.challenges.len(), diff --git a/crates/flowmemory-devnet/src/lib.rs b/crates/flowmemory-devnet/src/lib.rs index 652040cc..8ff2f9a5 100644 --- a/crates/flowmemory-devnet/src/lib.rs +++ b/crates/flowmemory-devnet/src/lib.rs @@ -6,10 +6,10 @@ pub mod storage; pub use cli::run_cli; pub use hash::{canonical_json, keccak_hex}; pub use model::{ - AgentAccount, ArtifactAvailabilityProof, BaseAnchorPlaceholder, Block, BlockReceipt, + AgentAccount, ArtifactAvailabilityProof, BalanceTransfer, BaseAnchorPlaceholder, Block, BlockReceipt, ChainState, Challenge, DevnetConfig, DevnetError, FaucetRecord, FinalityReceipt, - ImportedFlowPulseObservation, ImportedVerifierReport, LocalTestUnitBalance, MemoryCell, + ImportedFlowPulseObservation, ImportedVerifierReport, LocalAuthorization, LocalTestUnitBalance, MemoryCell, ModelPassport, OperatorKeyReference, StateMapRoots, Transaction, TxEnvelope, VerifierModule, apply_transaction, build_block, default_config, default_operator_key_references, genesis_state, - state_map_roots, state_root, + queue_authorized_transaction, state_map_roots, state_root, }; diff --git a/crates/flowmemory-devnet/src/model.rs b/crates/flowmemory-devnet/src/model.rs index 446150a2..790635d7 100644 --- a/crates/flowmemory-devnet/src/model.rs +++ b/crates/flowmemory-devnet/src/model.rs @@ -27,10 +27,14 @@ pub enum DevnetError { LocalTestUnitBalanceMissing(String), #[error("local test-unit balance overflow: {0}")] LocalTestUnitBalanceOverflow(String), + #[error("insufficient local test-unit balance: {0}")] + LocalTestUnitBalanceInsufficient(String), #[error("faucet record already exists: {0}")] FaucetRecordAlreadyExists(String), #[error("faucet amount must be greater than zero: {0}")] FaucetAmountMustBePositive(String), + #[error("balance transfer already exists: {0}")] + BalanceTransferAlreadyExists(String), #[error("model passport already exists: {0}")] ModelPassportAlreadyExists(String), #[error("model passport does not exist: {0}")] @@ -114,6 +118,8 @@ pub struct ChainState { #[serde(default)] pub faucet_records: BTreeMap, #[serde(default)] + pub balance_transfers: BTreeMap, + #[serde(default)] pub model_passports: BTreeMap, #[serde(default)] pub memory_cells: BTreeMap, @@ -213,6 +219,18 @@ pub struct FaucetRecord { pub no_value: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BalanceTransfer { + pub transfer_id: String, + pub from_account_id: String, + pub to_account_id: String, + pub amount_units: u64, + pub memo: String, + pub transferred_at_block: u64, + pub no_value: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ModelPassport { @@ -371,6 +389,8 @@ pub struct BaseAnchorPlaceholder { #[serde(default)] pub faucet_record_root: String, #[serde(default)] + pub balance_transfer_root: String, + #[serde(default)] pub model_passport_root: String, #[serde(default)] pub memory_cell_root: String, @@ -416,6 +436,13 @@ pub enum Transaction { amount_units: u64, reason: String, }, + TransferLocalTestUnits { + transfer_id: String, + from_account_id: String, + to_account_id: String, + amount_units: u64, + memo: String, + }, RegisterModelPassport { model_passport_id: String, issuer: String, @@ -507,6 +534,16 @@ pub enum Transaction { pub struct TxEnvelope { pub tx_id: String, pub tx: Transaction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalAuthorization { + pub mode: String, + pub signer: String, + pub digest: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -528,6 +565,8 @@ pub struct BlockReceipt { pub tx_id: String, pub status: String, pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization: Option, } #[derive(Debug, Serialize)] @@ -542,6 +581,7 @@ struct StateCommitmentView<'a> { agent_accounts: &'a BTreeMap, local_test_unit_balances: &'a BTreeMap, faucet_records: &'a BTreeMap, + balance_transfers: &'a BTreeMap, model_passports: &'a BTreeMap, memory_cells: &'a BTreeMap, challenges: &'a BTreeMap, @@ -571,6 +611,7 @@ pub struct StateMapRoots { pub agent_account_root: String, pub local_test_unit_balance_root: String, pub faucet_record_root: String, + pub balance_transfer_root: String, pub model_passport_root: String, pub memory_cell_root: String, pub challenge_root: String, @@ -639,6 +680,7 @@ pub fn genesis_state() -> ChainState { agent_accounts: BTreeMap::new(), local_test_unit_balances: BTreeMap::new(), faucet_records: BTreeMap::new(), + balance_transfers: BTreeMap::new(), model_passports: BTreeMap::new(), memory_cells: BTreeMap::new(), challenges: BTreeMap::new(), @@ -658,7 +700,11 @@ pub fn genesis_state() -> ChainState { pub fn envelope_tx(tx: Transaction) -> TxEnvelope { let tx_id = hash_json(TX_SCHEMA, &tx); - TxEnvelope { tx_id, tx } + TxEnvelope { + tx_id, + tx, + authorization: None, + } } pub fn queue_transaction(state: &mut ChainState, tx: Transaction) -> String { @@ -668,6 +714,22 @@ pub fn queue_transaction(state: &mut ChainState, tx: Transaction) -> String { tx_id } +pub fn queue_authorized_transaction( + state: &mut ChainState, + tx: Transaction, + signer: String, +) -> String { + let mut envelope = envelope_tx(tx); + envelope.authorization = Some(LocalAuthorization { + mode: "local-authorized".to_string(), + signer, + digest: envelope.tx_id.clone(), + }); + let tx_id = envelope.tx_id.clone(); + state.pending_txs.push(envelope); + tx_id +} + pub fn state_root(state: &ChainState) -> String { let view = StateCommitmentView { schema: STATE_SCHEMA, @@ -679,6 +741,7 @@ pub fn state_root(state: &ChainState) -> String { agent_accounts: &state.agent_accounts, local_test_unit_balances: &state.local_test_unit_balances, faucet_records: &state.faucet_records, + balance_transfers: &state.balance_transfers, model_passports: &state.model_passports, memory_cells: &state.memory_cells, challenges: &state.challenges, @@ -721,6 +784,10 @@ pub fn state_map_roots(state: &ChainState) -> StateMapRoots { "flowmemory.local_devnet.faucet_records.v0", &state.faucet_records, ), + balance_transfer_root: map_root( + "flowmemory.local_devnet.balance_transfers.v0", + &state.balance_transfers, + ), model_passport_root: map_root( "flowmemory.local_devnet.model_passports.v0", &state.model_passports, @@ -776,6 +843,7 @@ pub fn build_block(state: &mut ChainState) -> Block { for envelope in txs { tx_ids.push(envelope.tx_id.clone()); + let authorization = envelope.authorization.clone(); let result = apply_transaction(state, &envelope.tx); receipts.push(BlockReceipt { tx_id: envelope.tx_id, @@ -786,6 +854,7 @@ pub fn build_block(state: &mut ChainState) -> Block { } .to_string(), error: result.err().map(|error| error.to_string()), + authorization, }); } @@ -928,6 +997,66 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), }, ); } + Transaction::TransferLocalTestUnits { + transfer_id, + from_account_id, + to_account_id, + amount_units, + memo, + } => { + if state.balance_transfers.contains_key(transfer_id) { + return Err(DevnetError::BalanceTransferAlreadyExists( + transfer_id.clone(), + )); + } + if *amount_units == 0 { + return Err(DevnetError::FaucetAmountMustBePositive( + transfer_id.clone(), + )); + } + + let from_balance = state + .local_test_unit_balances + .get(from_account_id) + .ok_or_else(|| DevnetError::LocalTestUnitBalanceMissing(from_account_id.clone()))?; + if from_balance.units < *amount_units { + return Err(DevnetError::LocalTestUnitBalanceInsufficient( + from_account_id.clone(), + )); + } + + { + let from_balance = state + .local_test_unit_balances + .get_mut(from_account_id) + .expect("source local test-unit balance was checked above"); + from_balance.units -= *amount_units; + from_balance.updated_at_block = state.next_block_number; + } + + let to_balance = state + .local_test_unit_balances + .get_mut(to_account_id) + .ok_or_else(|| DevnetError::LocalTestUnitBalanceMissing(to_account_id.clone()))?; + to_balance.units = to_balance + .units + .checked_add(*amount_units) + .ok_or_else(|| DevnetError::LocalTestUnitBalanceOverflow(to_account_id.clone()))?; + to_balance.updated_at_block = state.next_block_number; + + state.balance_transfers.insert( + transfer_id.clone(), + BalanceTransfer { + transfer_id: transfer_id.clone(), + from_account_id: from_account_id.clone(), + to_account_id: to_account_id.clone(), + amount_units: *amount_units, + memo: memo.clone(), + transferred_at_block: state.next_block_number, + no_value: true, + }, + ); + } Transaction::RegisterModelPassport { model_passport_id, issuer, @@ -1350,6 +1479,7 @@ pub fn anchor_from_state( agent_account_root: &'a str, local_test_unit_balance_root: &'a str, faucet_record_root: &'a str, + balance_transfer_root: &'a str, model_passport_root: &'a str, memory_cell_root: &'a str, challenge_root: &'a str, @@ -1376,6 +1506,7 @@ pub fn anchor_from_state( agent_account_root: &roots.agent_account_root, local_test_unit_balance_root: &roots.local_test_unit_balance_root, faucet_record_root: &roots.faucet_record_root, + balance_transfer_root: &roots.balance_transfer_root, model_passport_root: &roots.model_passport_root, memory_cell_root: &roots.memory_cell_root, challenge_root: &roots.challenge_root, @@ -1401,6 +1532,7 @@ pub fn anchor_from_state( agent_account_root: roots.agent_account_root, local_test_unit_balance_root: roots.local_test_unit_balance_root, faucet_record_root: roots.faucet_record_root, + balance_transfer_root: roots.balance_transfer_root, model_passport_root: roots.model_passport_root, memory_cell_root: roots.memory_cell_root, challenge_root: roots.challenge_root, diff --git a/crates/flowmemory-devnet/tests/devnet_tests.rs b/crates/flowmemory-devnet/tests/devnet_tests.rs index 2dbf9061..99e89910 100644 --- a/crates/flowmemory-devnet/tests/devnet_tests.rs +++ b/crates/flowmemory-devnet/tests/devnet_tests.rs @@ -265,6 +265,70 @@ fn every_core_transaction_type_can_be_applied() { assert_eq!(state.base_anchors.len(), 1); } +#[test] +fn local_faucet_and_transfer_update_test_unit_ledger() { + let mut state = genesis_state(); + apply_transaction( + &mut state, + &Transaction::CreateLocalTestUnitBalance { + account_id: "local-account:alice".to_string(), + owner: "operator:alice".to_string(), + }, + ) + .unwrap(); + apply_transaction( + &mut state, + &Transaction::CreateLocalTestUnitBalance { + account_id: "local-account:bob".to_string(), + owner: "operator:bob".to_string(), + }, + ) + .unwrap(); + apply_transaction( + &mut state, + &Transaction::FaucetLocalTestUnits { + faucet_record_id: "faucet:unit:001".to_string(), + account_id: "local-account:alice".to_string(), + recipient: "operator:alice".to_string(), + amount_units: 50, + reason: "unit-test".to_string(), + }, + ) + .unwrap(); + apply_transaction( + &mut state, + &Transaction::TransferLocalTestUnits { + transfer_id: "transfer:unit:001".to_string(), + from_account_id: "local-account:alice".to_string(), + to_account_id: "local-account:bob".to_string(), + amount_units: 20, + memo: "unit-test-transfer".to_string(), + }, + ) + .unwrap(); + + assert_eq!(state.local_test_unit_balances["local-account:alice"].units, 30); + assert_eq!(state.local_test_unit_balances["local-account:bob"].units, 20); + assert_eq!(state.faucet_records.len(), 1); + assert_eq!(state.balance_transfers.len(), 1); + + assert_eq!( + apply_transaction( + &mut state, + &Transaction::TransferLocalTestUnits { + transfer_id: "transfer:unit:002".to_string(), + from_account_id: "local-account:bob".to_string(), + to_account_id: "local-account:alice".to_string(), + amount_units: 30, + memo: "too-much".to_string(), + }, + ), + Err(DevnetError::LocalTestUnitBalanceInsufficient( + "local-account:bob".to_string() + )) + ); +} + #[test] fn duplicate_ids_are_rejected_for_new_objects() { let mut state = genesis_state(); @@ -779,12 +843,186 @@ fn cli_export_import_state_round_trip_is_deterministic() { std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } +#[test] +fn cli_node_runs_ten_blocks_and_includes_authorized_inbox_tx() { + let temp = temp_dir("cli-node"); + let state = temp.join("state.json"); + let node_dir = temp.join("node"); + + let faucet = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "faucet", + "--account", + "local-account:cli-node", + "--amount", + "9", + "--reason", + "cli-node-test", + "--authorized-by", + "local-test-operator", + ]) + .status() + .expect("submit faucet"); + assert!(faucet.success()); + + let node = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "node", + "--node-id", + "node:test:cli", + "--block-ms", + "1", + "--max-blocks", + "10", + ]) + .status() + .expect("run node"); + assert!(node.success()); + + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "node-status", + ]) + .output() + .expect("node status"); + assert!(output.status.success()); + let summary: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("status summary json"); + assert_eq!(summary["state"]["blocks"], 10); + assert_eq!(summary["state"]["localBalances"], 1); + assert_eq!(summary["state"]["faucetRecords"], 1); + let state_body = std::fs::read_to_string(&state).expect("state body"); + let state_json: serde_json::Value = serde_json::from_str(&state_body).expect("state json"); + assert_eq!( + state_json["blocks"][0]["receipts"][0]["authorization"]["signer"], + "local-test-operator" + ); + assert!(node_dir.join("node-identity.json").exists()); + assert!(node_dir.join("status.json").exists()); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_static_peer_sync_reconciles_two_local_node_states() { + let temp = temp_dir("cli-peer-sync"); + let state_a = temp.join("state-a.json"); + let state_b = temp.join("state-b.json"); + let node_a = temp.join("node-a"); + let node_b = temp.join("node-b"); + let peer_b = temp.join("node-b-peers.json"); + + let faucet = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state_a.to_str().expect("state a"), + "--node-dir", + node_a.to_str().expect("node a"), + "faucet", + "--account", + "local-account:peer-sync", + "--amount", + "11", + "--reason", + "peer-sync-test", + "--authorized-by", + "local-test-operator", + ]) + .status() + .expect("submit faucet to node a"); + assert!(faucet.success()); + + let node_a_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state_a.to_str().expect("state a"), + "--node-dir", + node_a.to_str().expect("node a"), + "node", + "--node-id", + "node:test:a", + "--block-ms", + "1", + "--max-blocks", + "2", + ]) + .status() + .expect("run node a"); + assert!(node_a_status.success()); + + std::fs::write( + &peer_b, + format!( + "{{\"schema\":\"flowmemory.local_devnet.static_peers.v0\",\"nodeId\":\"node:test:b\",\"peers\":[{{\"nodeId\":\"node:test:a\",\"statePath\":\"{}\"}}]}}\n", + state_a.to_string_lossy().replace('\\', "\\\\") + ), + ) + .expect("write peer config"); + + let node_b_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state_b.to_str().expect("state b"), + "--node-dir", + node_b.to_str().expect("node b"), + "node", + "--node-id", + "node:test:b", + "--block-ms", + "1", + "--max-blocks", + "1", + "--peer-config", + peer_b.to_str().expect("peer config"), + ]) + .status() + .expect("run node b"); + assert!(node_b_status.success()); + + let summary_a = inspect_summary(&state_a, &node_a); + let summary_b = inspect_summary(&state_b, &node_b); + assert_eq!( + summary_a["state"]["stateRoot"], + summary_b["state"]["stateRoot"] + ); + assert_eq!(summary_b["state"]["localBalances"], 1); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + #[test] fn zero_hash_constant_is_hex_32_bytes() { assert_eq!(ZERO_HASH.len(), 66); assert!(ZERO_HASH.starts_with("0x")); } +fn inspect_summary(state: &std::path::Path, node_dir: &std::path::Path) -> serde_json::Value { + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "node-status", + ]) + .output() + .expect("inspect node status"); + assert!(output.status.success()); + serde_json::from_slice(&output.stdout).expect("status json") +} + fn temp_dir(name: &str) -> std::path::PathBuf { let temp = std::env::temp_dir().join(format!( "flowmemory-devnet-test-{}-{name}", diff --git a/crypto/.gitignore b/crypto/.gitignore index 2f24c57c..4e322039 100644 --- a/crypto/.gitignore +++ b/crypto/.gitignore @@ -1,3 +1,6 @@ node_modules/ coverage/ .nyc_output/ +.wallet/ +*.vault.local.json +*.wallet.local.json diff --git a/crypto/fixtures/local-alpha-objects.json b/crypto/fixtures/local-alpha-objects.json index 8080f423..a20f9c72 100644 --- a/crypto/fixtures/local-alpha-objects.json +++ b/crypto/fixtures/local-alpha-objects.json @@ -387,6 +387,9 @@ "sourceContract": "0x1111111111111111111111111111111111111111", "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", "logIndex": 0, + "sourceBlockNumber": "5000", + "sourceBlockHash": "0x7777777777777777777777777777777777777777777777777777777777777777", + "transactionIndex": 0, "token": "0x3333333333333333333333333333333333333333", "amount": "20000000", "sender": "0x4444444444444444444444444444444444444444", @@ -402,6 +405,9 @@ "sourceContract": "0x1111111111111111111111111111111111111111", "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", "logIndex": 0, + "sourceBlockNumber": "5000", + "sourceBlockHash": "0x7777777777777777777777777777777777777777777777777777777777777777", + "transactionIndex": 0, "token": "0x3333333333333333333333333333333333333333", "amount": "20000000", "sender": "0x4444444444444444444444444444444444444444", @@ -1535,9 +1541,9 @@ "schemaPath": "../../schemas/flowmemory/local-transaction-envelope.schema.json", "function": "localTransactionEnvelopeHash", "expected": { - "envelopeId": "0xb8de3add81640848961aa8e0ba73c5c2ff3e30aed480dbba8e70d1345dba6210", - "signingDigest": "0x0c702b64fef1434b9de56fbc01e47db9ef6f3565bc1a58babb2dbdd72d65ca14", - "payloadHash": "0x485a6896d8ee1c67440b6dddf723f0378c4f51d7448e487effe5f337656f0908" + "envelopeId": "0x11a8286288f46a7164e627d66a09f4e63f0d8c48bce2cf00df7b9fd9388aaf8e", + "signingDigest": "0x6d02b95313e31b9e13f0f223bde4a78ce215cc18d0ebbdd55568d2d58b6ec78a", + "payloadHash": "0x8e9dc6a1c3249e51eb8da32a47b122ef362fb59928e98877d432e1c3b899c48e" }, "input": { "chainId": "31337", @@ -1546,14 +1552,14 @@ "signerKeyId": "0x2409a6f84adce8b773bb8f47e18b4179df21e1957816c464efa481118f25d40a", "signerRole": 1, "nonce": "1", - "payloadHash": "0x485a6896d8ee1c67440b6dddf723f0378c4f51d7448e487effe5f337656f0908", + "payloadHash": "0x8e9dc6a1c3249e51eb8da32a47b122ef362fb59928e98877d432e1c3b899c48e", "objectId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", "objectTypeHash": "0x97bcbbe7a3f24fdb7cab668fc751ca8757b59b3fe8ed71674fdaca140a22d408", "issuedAtUnixMs": "1778702400000" }, "envelope": { "schema": "flowchain.local_transaction_envelope.v0", - "envelopeId": "0xb8de3add81640848961aa8e0ba73c5c2ff3e30aed480dbba8e70d1345dba6210", + "envelopeId": "0x11a8286288f46a7164e627d66a09f4e63f0d8c48bce2cf00df7b9fd9388aaf8e", "domain": "flowchain.local-alpha.v0.local-transaction-envelope:chain:31337", "domainSeparator": "0xff516fcab184623fe60f12b4d1e0430a57e6d18685d1356887ada50f5f20b36c", "chainId": "31337", @@ -1567,10 +1573,10 @@ "objectType": "bridge_deposit", "objectTypeHash": "0x97bcbbe7a3f24fdb7cab668fc751ca8757b59b3fe8ed71674fdaca140a22d408", "objectId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", - "payloadHash": "0x485a6896d8ee1c67440b6dddf723f0378c4f51d7448e487effe5f337656f0908", + "payloadHash": "0x8e9dc6a1c3249e51eb8da32a47b122ef362fb59928e98877d432e1c3b899c48e", "issuedAtUnixMs": "1778702400000", - "signingDigest": "0x0c702b64fef1434b9de56fbc01e47db9ef6f3565bc1a58babb2dbdd72d65ca14", - "signature": "0x51d06d784e2c0fb703a9c1e8ad7186eb777176bdf79bfddc4284020015a1c72d0059bf0b962bf5cdaa4b7e007e6a6b770551f24699ac938303be5203b3b24631" + "signingDigest": "0x6d02b95313e31b9e13f0f223bde4a78ce215cc18d0ebbdd55568d2d58b6ec78a", + "signature": "0xe5b909398cd6e4f09f897c4588f94df7ea8c35aaf4680ec5ee56184b12a381371055850a7e0cf37e71cce901bcdb15bd3db09d85aa6b3fb47923e77ea54d3fa3" } } ], diff --git a/crypto/fixtures/local-transaction-vectors.json b/crypto/fixtures/local-transaction-vectors.json new file mode 100644 index 00000000..ce67b6ad --- /dev/null +++ b/crypto/fixtures/local-transaction-vectors.json @@ -0,0 +1,291 @@ +{ + "schema": "flowmemory.crypto.local-transaction-vectors.v0", + "chainId": "31337", + "publicWalletMetadata": { + "schema": "flowchain.local_wallet_public_metadata.v0", + "vaultId": "0x999558b9e2477f6420ab4b9ad3008c4dbd276aaac611ec5575def8c9a98fc384", + "createdAtUnixMs": "1778702400000", + "updatedAtUnixMs": "1778702400000", + "accounts": [ + { + "accountId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerKeyId": "0x011c875680a5f90e1513ec2c05e22c9dab1d83e22d554cb2c61e14199da746a1", + "signerRole": "operator", + "signerRoleCode": 1, + "publicKey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "label": "fixture-operator", + "status": "active", + "createdAtUnixMs": "1778702400000", + "nextNonce": "3" + } + ] + }, + "positive": [ + { + "name": "transaction.register-agent.operator-signature", + "payload": { + "schema": "flowmemory.local_devnet.tx_payload.v0", + "objectType": "agent_account", + "object": { + "schema": "flowchain.agent_account.v0", + "agentId": "0xe4982e2682c9dd11caf102d2e0c9567ffad56850f1c69086b3996b77495fbb61", + "namespaceId": "0x4c2eae268c3f97a510b66597873c6c08ae9e427a7c03cd383df6acfeda9bbf37", + "owner": "0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1", + "policyRoot": "0x20121b5daa2be4de0d9dbcae292d1d3466a4e0a8b6cc3149ce46c30c352a1f0e", + "toolPermissionsRoot": "0x39079bde86c6743c81fd927a14012331688374b55118484529ce257317327120", + "modelAllowlistRoot": "0x00a463537f12e3419d4bf3655d28d93f3e189b9a89a3ad40c4819bbc4eb2acc9", + "memoryNamespaceRoot": "0x574eee506454973fb82863a3225ea78696b96907a17c360ad2c2b5234dfa2d83", + "spendingLimitPerEpoch": "0", + "status": "active", + "nonce": "0x37e916697be966b19c0cd7aa6073e78ffb7ee5cf27a4392383f9cfe5fa5025bb" + }, + "tx": { + "type": "RegisterAgent", + "agentId": "0xe4982e2682c9dd11caf102d2e0c9567ffad56850f1c69086b3996b77495fbb61", + "controller": "operator:local-wallet-vector", + "modelPassportId": null, + "metadataHash": "0x0dee06bd221366280f62239c3df48eea9414e3bc1c9fc86796d5a546ba26fc7a" + } + }, + "input": { + "chainId": "31337", + "nonce": "1", + "signerId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerKeyId": "0x011c875680a5f90e1513ec2c05e22c9dab1d83e22d554cb2c61e14199da746a1", + "signerRole": 1, + "payloadHash": "0x3659032f06fcb8573d47c109237dbba39fce5809c041123327767d3af17bb2b2", + "issuedAtUnixMs": "1778702400000", + "expiresAtUnixMs": "1810238400000", + "domainSeparator": "0x12aff8820bbfec739f09eeac1e8a497ef87a9930ef07b5a09ff8e3a67a2b5677" + }, + "expected": { + "payloadHash": "0x3659032f06fcb8573d47c109237dbba39fce5809c041123327767d3af17bb2b2", + "envelopeId": "0xfba94617ac6fbae608393c67570280d7123b27dabb0c1f31427808ad955a7c46", + "signingDigest": "0x9ff2d8b2153b38d76ecd81334ab06bd16f31f004be03d5b9e29042dd09cd296f" + }, + "envelope": { + "schema": "flowchain.local_transaction_envelope.v0", + "envelopeId": "0xfba94617ac6fbae608393c67570280d7123b27dabb0c1f31427808ad955a7c46", + "domain": "flowchain.local.v0.transaction-envelope", + "domainSeparator": "0x12aff8820bbfec739f09eeac1e8a497ef87a9930ef07b5a09ff8e3a67a2b5677", + "chainId": "31337", + "nonce": "1", + "payloadHash": "0x3659032f06fcb8573d47c109237dbba39fce5809c041123327767d3af17bb2b2", + "payload": { + "schema": "flowmemory.local_devnet.tx_payload.v0", + "objectType": "agent_account", + "object": { + "schema": "flowchain.agent_account.v0", + "agentId": "0xe4982e2682c9dd11caf102d2e0c9567ffad56850f1c69086b3996b77495fbb61", + "namespaceId": "0x4c2eae268c3f97a510b66597873c6c08ae9e427a7c03cd383df6acfeda9bbf37", + "owner": "0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1", + "policyRoot": "0x20121b5daa2be4de0d9dbcae292d1d3466a4e0a8b6cc3149ce46c30c352a1f0e", + "toolPermissionsRoot": "0x39079bde86c6743c81fd927a14012331688374b55118484529ce257317327120", + "modelAllowlistRoot": "0x00a463537f12e3419d4bf3655d28d93f3e189b9a89a3ad40c4819bbc4eb2acc9", + "memoryNamespaceRoot": "0x574eee506454973fb82863a3225ea78696b96907a17c360ad2c2b5234dfa2d83", + "spendingLimitPerEpoch": "0", + "status": "active", + "nonce": "0x37e916697be966b19c0cd7aa6073e78ffb7ee5cf27a4392383f9cfe5fa5025bb" + }, + "tx": { + "type": "RegisterAgent", + "agentId": "0xe4982e2682c9dd11caf102d2e0c9567ffad56850f1c69086b3996b77495fbb61", + "controller": "operator:local-wallet-vector", + "modelPassportId": null, + "metadataHash": "0x0dee06bd221366280f62239c3df48eea9414e3bc1c9fc86796d5a546ba26fc7a" + } + }, + "signer": { + "accountId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerKeyId": "0x011c875680a5f90e1513ec2c05e22c9dab1d83e22d554cb2c61e14199da746a1", + "signerRole": "operator", + "signerRoleCode": 1, + "publicKey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + "issuedAtUnixMs": "1778702400000", + "expiresAtUnixMs": "1810238400000", + "signingDigest": "0x9ff2d8b2153b38d76ecd81334ab06bd16f31f004be03d5b9e29042dd09cd296f", + "signature": "0xcc2ce2a8edb0190940ae3c9c4878de831ecba19aad57c031c721737877cf226038096b32d5554221c14d5c0d44d72fdf0dfa529d64b350b90b1d1d8a32663b24" + } + }, + { + "name": "transaction.bridge-deposit.operator-signature", + "payload": { + "schema": "flowmemory.local_devnet.tx_payload.v0", + "objectType": "bridge_deposit", + "object": { + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "sourceChainId": 84532, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" + }, + "tx": { + "type": "ImportBridgeDeposit", + "depositId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "sourceChainId": 84532, + "amount": "20000000", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555" + } + }, + "input": { + "chainId": "31337", + "nonce": "2", + "signerId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerKeyId": "0x011c875680a5f90e1513ec2c05e22c9dab1d83e22d554cb2c61e14199da746a1", + "signerRole": 1, + "payloadHash": "0x2d2a18d5508b23b17d25a2f7522a0b5fc57eb762e25ebb2b9d7a2faa947158a4", + "issuedAtUnixMs": "1778702400000", + "expiresAtUnixMs": "1810238400000", + "domainSeparator": "0x12aff8820bbfec739f09eeac1e8a497ef87a9930ef07b5a09ff8e3a67a2b5677" + }, + "expected": { + "payloadHash": "0x2d2a18d5508b23b17d25a2f7522a0b5fc57eb762e25ebb2b9d7a2faa947158a4", + "envelopeId": "0xd6195c2f24da099f9c0fe8e40d80e4824ed045f40b18c4b6b0c42c67eb9de856", + "signingDigest": "0xd6379c1be2d89c5f17e6c6a3a033fc717aab692e39197a29ab5a64e4a132a839" + }, + "envelope": { + "schema": "flowchain.local_transaction_envelope.v0", + "envelopeId": "0xd6195c2f24da099f9c0fe8e40d80e4824ed045f40b18c4b6b0c42c67eb9de856", + "domain": "flowchain.local.v0.transaction-envelope", + "domainSeparator": "0x12aff8820bbfec739f09eeac1e8a497ef87a9930ef07b5a09ff8e3a67a2b5677", + "chainId": "31337", + "nonce": "2", + "payloadHash": "0x2d2a18d5508b23b17d25a2f7522a0b5fc57eb762e25ebb2b9d7a2faa947158a4", + "payload": { + "schema": "flowmemory.local_devnet.tx_payload.v0", + "objectType": "bridge_deposit", + "object": { + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "sourceChainId": 84532, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" + }, + "tx": { + "type": "ImportBridgeDeposit", + "depositId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "sourceChainId": 84532, + "amount": "20000000", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555" + } + }, + "signer": { + "accountId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerKeyId": "0x011c875680a5f90e1513ec2c05e22c9dab1d83e22d554cb2c61e14199da746a1", + "signerRole": "operator", + "signerRoleCode": 1, + "publicKey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + "issuedAtUnixMs": "1778702400000", + "expiresAtUnixMs": "1810238400000", + "signingDigest": "0xd6379c1be2d89c5f17e6c6a3a033fc717aab692e39197a29ab5a64e4a132a839", + "signature": "0x336c2428f7841d8b76c5fbf37f07d7f8c0e6632debf2247d52cba8cbc1fc45d137eac8cc746c6edbc1539abf0786f9149a38badd4b381be5c1741d8e35aa93e8" + } + } + ], + "negative": [ + { + "name": "transaction.wrong-chain-id", + "baseEnvelope": "transaction.register-agent.operator-signature", + "mutation": { + "envelope": { + "chainId": "31338" + } + }, + "expectErrors": [ + "wrong-chain-id" + ] + }, + { + "name": "transaction.wrong-domain", + "baseEnvelope": "transaction.register-agent.operator-signature", + "mutation": { + "envelope": { + "domain": "flowchain.local.v0.wrong-transaction-envelope", + "domainSeparator": "0xff84e3bb0214d3812d1bf8521ab9d5b836c7746794e226af2a56a2efd0b65ee6" + } + }, + "expectErrors": [ + "wrong-domain" + ] + }, + { + "name": "transaction.wrong-signer", + "baseEnvelope": "transaction.register-agent.operator-signature", + "mutation": { + "signer": { + "signerId": "0xc08e9afda6b71e85507d1e84d73dd290a79d9319620ecfd784d1cd00d7c24500" + } + }, + "expectErrors": [ + "wrong-signer" + ] + }, + { + "name": "transaction.replayed-nonce", + "baseEnvelope": "transaction.register-agent.operator-signature", + "mutation": { + "contextReplay": true + }, + "expectErrors": [ + "replay" + ] + }, + { + "name": "transaction.malformed-root-agent-policy", + "baseEnvelope": "transaction.register-agent.operator-signature", + "mutation": { + "payloadObject": { + "policyRoot": "0x1234" + } + }, + "expectErrors": [ + "malformed-root" + ] + }, + { + "name": "transaction.malformed-bridge-deposit-zero-amount", + "baseEnvelope": "transaction.bridge-deposit.operator-signature", + "mutation": { + "payloadObject": { + "amount": "0" + } + }, + "expectErrors": [ + "malformed-bridge-deposit" + ] + }, + { + "name": "transaction.changed-object-type", + "baseEnvelope": "transaction.register-agent.operator-signature", + "mutation": { + "payload": { + "objectType": "model_passport" + } + }, + "expectErrors": [ + "changed-object-type" + ] + } + ] +} diff --git a/crypto/src/validate-local-transaction-fixtures.js b/crypto/src/validate-local-transaction-fixtures.js new file mode 100644 index 00000000..b87e6ac6 --- /dev/null +++ b/crypto/src/validate-local-transaction-fixtures.js @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +import { + localTransactionReplayKey, + validateLocalTransactionEnvelope +} from "./transactions.js"; + +const defaultFixturePath = resolve(import.meta.dirname, "..", "fixtures", "local-transaction-vectors.json"); + +export function validateLocalTransactionFixtures(fixturePath = defaultFixturePath) { + const fixture = readJson(fixturePath); + assert.equal(fixture.schema, "flowmemory.crypto.local-transaction-vectors.v0"); + + const fixtureDir = resolve(fixturePath, ".."); + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + const transactionEnvelopeSchema = ajv.compile( + readJson(resolve(fixtureDir, "../../schemas/flowmemory/local-transaction-envelope.schema.json")) + ); + const walletPublicSchema = ajv.compile( + readJson(resolve(fixtureDir, "../../schemas/flowmemory/local-wallet-public-metadata.schema.json")) + ); + + assert.equal(walletPublicSchema(fixture.publicWalletMetadata), true, ajv.errorsText(walletPublicSchema.errors)); + + let positive = 0; + for (const vector of fixture.positive) { + assert.equal(transactionEnvelopeSchema(vector.envelope), true, ajv.errorsText(transactionEnvelopeSchema.errors)); + const result = validateLocalTransactionEnvelope({ + envelope: vector.envelope, + context: { expectedChainId: fixture.chainId } + }); + assert.deepEqual(result, { valid: true, errors: [] }, vector.name); + positive += 1; + } + + let negative = 0; + const positives = new Map(fixture.positive.map((entry) => [entry.name, entry.envelope])); + for (const vector of fixture.negative) { + const envelope = mutateEnvelope(positives.get(vector.baseEnvelope), vector.mutation); + const context = { expectedChainId: fixture.chainId }; + if (vector.mutation?.contextReplay) { + context.seenNonces = new Set([localTransactionReplayKey(envelope)]); + } + const result = validateLocalTransactionEnvelope({ envelope, context }); + assert.equal(result.valid, false, vector.name); + for (const expectedError of vector.expectErrors) { + assert.ok( + result.errors.includes(expectedError), + `${vector.name} expected ${expectedError}, got ${result.errors.join(", ")}` + ); + } + negative += 1; + } + + return { positive, negative }; +} + +function mutateEnvelope(baseEnvelope, mutation = {}) { + assert.ok(baseEnvelope, "unknown base envelope"); + const envelope = structuredClone(baseEnvelope); + + if (mutation.envelope) { + Object.assign(envelope, mutation.envelope); + } + if (mutation.signer) { + Object.assign(envelope.signer, mutation.signer); + } + if (mutation.payload) { + envelope.payload = { + ...envelope.payload, + ...mutation.payload + }; + } + if (mutation.payloadObject) { + envelope.payload.object = { + ...envelope.payload.object, + ...mutation.payloadObject + }; + } + if (mutation.deleteSignerFields) { + for (const field of mutation.deleteSignerFields) { + delete envelope.signer[field]; + } + } + + return envelope; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +if (fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const result = validateLocalTransactionFixtures(process.argv[2]); + console.log(`FLOWCHAIN_LOCAL_TRANSACTION_VECTORS_OK positive=${result.positive} negative=${result.negative}`); +} diff --git a/crypto/test/crypto.test.js b/crypto/test/crypto.test.js index db43f38f..f48ef058 100644 --- a/crypto/test/crypto.test.js +++ b/crypto/test/crypto.test.js @@ -107,18 +107,23 @@ const localAlphaValidators = Object.freeze({ function assertSchemaDocument(schemaPath, document) { const schema = JSON.parse(readFileSync(resolve(root, "fixtures", schemaPath), "utf8")); - assert.equal(document.schema, schema.properties.schema.const); + const schemaVariant = Array.isArray(schema.oneOf) + ? schema.oneOf.find((variant) => variant.properties?.schema?.const === document.schema) + : schema; + + assert.ok(schemaVariant, `${document.schema} schema variant not found in ${schemaPath}`); + assert.equal(document.schema, schemaVariant.properties.schema.const); assert.deepEqual( Object.keys(document).sort(), - Object.keys(schema.properties).sort(), + Object.keys(schemaVariant.properties).sort(), `${document.schema} should not drift from its schema properties` ); - for (const key of schema.required) { + for (const key of schemaVariant.required) { assert.ok(Object.hasOwn(document, key), `${document.schema} missing ${key}`); } - for (const [key, definition] of Object.entries(schema.properties)) { + for (const [key, definition] of Object.entries(schemaVariant.properties)) { const value = document[key]; const resolved = definition.$ref ? schema.$defs[definition.$ref.replace("#/$defs/", "")] diff --git a/docs/DAILY_HQ_RUNBOOK.md b/docs/DAILY_HQ_RUNBOOK.md index 2fa628e2..370a4cae 100644 --- a/docs/DAILY_HQ_RUNBOOK.md +++ b/docs/DAILY_HQ_RUNBOOK.md @@ -35,6 +35,8 @@ Morning: - Confirm GitHub open PRs and issues still match `docs/CURRENT_STATE.md` and `docs/ISSUE_BACKLOG.md`. +- Confirm `docs/FLOWCHAIN_HQ_INTEGRATION_STATUS.md` matches GitHub milestone + #7, issues #99-#108, and open PRs. - Check all sibling worktrees for dirty changes before assigning agents. - Verify no two active agents are editing the same folder family or source-of-truth doc without coordination. @@ -42,11 +44,14 @@ Morning: missing to in flight or implemented after merges. - Verify root command aliases in `package.json` still match the scripts under `infra/scripts/flowchain-*.ps1`. +- Verify `npm run flowchain:full-smoke -- -AllowIncomplete -SkipMergedSmoke` + still reports missing subsystem commands with owning issue numbers until + #108 is ready to pass. - Confirm the next assigned work extends the existing devnet, control-plane, crypto, dashboard, contracts, hardware, or research surface instead of adding a replacement system. -- Keep production L1, tokenomics, public validator, production bridge, audited - cryptography, production hook, and production hardware claims blocked. +- Keep public-chain launch, tokenomics, public validator, value-bearing bridge, + audited-cryptography, production hook, and production hardware claims blocked. Evening: @@ -61,6 +66,8 @@ Evening: day, and name the first failing step. - Save or cite `devnet/local/smoke/flowchain-smoke-report.json` when full smoke runs locally. +- Save or cite `devnet/local/smoke/flowchain-full-smoke-report.json` when the + full-L1 wrapper is run, even if it is expected to report blockers. - Require `git diff --check` in each PR summary and area tests where the touched area has tests. diff --git a/docs/DASHBOARD_MVP.md b/docs/DASHBOARD_MVP.md index 6b4d52fc..fbb67b94 100644 --- a/docs/DASHBOARD_MVP.md +++ b/docs/DASHBOARD_MVP.md @@ -1,15 +1,18 @@ # Dashboard MVP -FlowMemory Dashboard V0 is a local React/Vite operator app under `apps/dashboard/`. It visualizes fixture data for the first app-facing explorer surface without introducing production APIs, wallet flows, token data, or live network claims. +FlowMemory Dashboard V0 is a local React/Vite operator app under `apps/dashboard/`. It visualizes fixture data for the first app-facing explorer surface and acts as the local FlowChain workbench when the control-plane API is running. It does not introduce production wallet flows, token data, or live network claims. ## Scope The MVP covers local inspection of: +- Local control-plane health and state from `http://127.0.0.1:8787/health`, `/state`, and `/rpc` +- Node status, peers, mempool, accounts, balances, faucet events, public wallet references, and setup status - FlowPulse observations from indexer-style receipt/log data - Rootfield registry state - Work lanes and work receipts -- Verifier reports +- Verifier modules and verifier reports +- Transactions, memory cells, challenges, finality rows, and bridge test-lane records when exported - Devnet blocks and state roots - Hardware node heartbeats - Alerts and incidents @@ -27,6 +30,9 @@ Runtime copy loaded by Vite: ```text apps/dashboard/public/data/flowmemory-dashboard-v0.json +apps/dashboard/public/data/flowchain-local-devnet-state.json +apps/dashboard/public/data/flowchain-local-devnet-dashboard-state.json +apps/dashboard/public/data/flowchain-bridge-test-deposit.json ``` Copy command: @@ -58,12 +64,26 @@ fixtures/dashboard/generated/hardware-heartbeats.json The app should keep treating those files as local/fixture data until a separate API decision defines authentication, caching, freshness, and failure semantics. +When `npm run control-plane:serve` is running, the workbench probes: + +```text +GET http://127.0.0.1:8787/health +GET http://127.0.0.1:8787/state +POST http://127.0.0.1:8787/rpc +``` + +The JSON-RPC batch reads live local objects with the documented read-only control-plane methods. If `/rpc` is not available but `/health` or `/state` responds, the UI keeps the API status visible and falls back to deterministic public fixtures for missing object tables. + +The first screen includes action cards for refresh, local faucet request, sample transaction, and bridge test-deposit inspection. Refresh is available when the control-plane API responds. The submit/inspect actions stay disabled unless the API advertises a matching local-only method; the dashboard does not invent write methods and does not handle signing keys. + ## Non-Goals - No backend service required for V0 - No wallet connect +- No private-key handling in the browser - No token price, TVL, rewards, staking, or market data - No production monitoring claims +- No production bridge or real-funds claim - No secrets or RPC credentials - No contract, service, or hardware behavior changes diff --git a/docs/DECISIONS/flowchain-local-wallet-envelope-v0.md b/docs/DECISIONS/flowchain-local-wallet-envelope-v0.md new file mode 100644 index 00000000..c20c9c90 --- /dev/null +++ b/docs/DECISIONS/flowchain-local-wallet-envelope-v0.md @@ -0,0 +1,63 @@ +# FlowChain Local Wallet And Transaction Envelope V0 + +Status: accepted for private/local testnet package. + +Date: 2026-05-13 + +## Decision + +FlowChain private/local testnet transactions use a crypto-package envelope named +`flowchain.local_transaction_envelope.v0`. + +The envelope signs: + +- `domain = flowchain.local.v0.transaction-envelope` +- `chainId` +- `nonce` +- public signer id and signer key id +- signer role +- canonical JSON `payloadHash` +- issuance and expiry timestamps +- secp256k1 signature over the local EIP-712 style digest + +The envelope keeps the devnet transaction object at `payload.tx`. Consumers +validate the envelope first, then pass `payload.tx` to the existing +`crates/flowmemory-devnet` transaction path. This avoids a second devnet or +second object model. + +Local test keys live in an encrypted vault managed by the existing `crypto/` +package. The vault uses scrypt plus AES-256-GCM. Public account metadata is +exported separately as `flowchain.local_wallet_public_metadata.v0`. + +## Rationale + +The private/local testnet needs real signing and signer display without making a +production wallet claim. Binding domain, chain id, nonce, signer metadata, and +payload hash gives the devnet and control-plane agents enough information to +reject wrong-network, wrong-domain, wrong-signer, and replayed local +transactions. + +Preserving `payload.tx` keeps the handoff compatible with the current Rust +devnet transaction model. + +## Boundaries + +- No production custody claim. +- No tokenomics or production bridge readiness claim. +- No private key in committed fixtures or public metadata exports. +- No production key recovery, hardware wallet integration, or audited wallet + claim. +- Bridge objects are local/private testnet accounting commitments only. + +## Evidence + +The crypto package now includes positive and negative vectors for local +transaction envelopes and wallet public metadata. The required commands are: + +```powershell +npm test --prefix crypto +npm run validate:vectors --prefix crypto +npm run wallet:create --prefix crypto +npm run wallet:sign --prefix crypto +npm run wallet:verify --prefix crypto +``` diff --git a/docs/EASY_SECOND_COMPUTER_SETUP.md b/docs/EASY_SECOND_COMPUTER_SETUP.md index 15372b20..5fe780f2 100644 --- a/docs/EASY_SECOND_COMPUTER_SETUP.md +++ b/docs/EASY_SECOND_COMPUTER_SETUP.md @@ -46,6 +46,16 @@ initializes local state, runs the deterministic local chain demo, runs the smoke path, exports a local bundle, runs the bridge mock, and opens the control plane and dashboard in separate PowerShell windows. +The current installer uses the merged-surface smoke path. The full local L1 +acceptance wrapper is: + +```powershell +npm run flowchain:full-smoke -- -AllowIncomplete +``` + +It reports the remaining subsystem blockers until issues #99 through #108 are +finished. + ## Already Cloned Setup If the repo is already cloned: diff --git a/docs/FLOWCHAIN_HQ_INTEGRATION_STATUS.md b/docs/FLOWCHAIN_HQ_INTEGRATION_STATUS.md new file mode 100644 index 00000000..3b18d629 --- /dev/null +++ b/docs/FLOWCHAIN_HQ_INTEGRATION_STATUS.md @@ -0,0 +1,157 @@ +# FlowChain HQ Integration Status + +Status: live HQ issue, PR, branch, ownership, merge-order, and smoke map for +the FlowChain private/local L1 testnet package. + +Last synced: 2026-05-13. + +GitHub is the source of truth. This file is the local HQ view after checking +open GitHub PRs/issues, `status-report.ps1`, and the local package command map. + +## GitHub Source Of Truth + +Milestone: + +- #7 `FlowChain Private/Local L1 Testnet Package` + +Full-L1 workstream issues: + +| Area | Issue | Primary branch/worktree | Ownership | +| --- | --- | --- | --- | +| Chain/runtime | #99 | `agent/full-l1-runtime` in `E:\FlowMemory\flowmemory-chain` | `crates/flowmemory-devnet/`, `devnet/`, runtime wrappers | +| Crypto/wallet | #100 | `agent/full-l1-crypto-wallet` in `E:\FlowMemory\flowmemory-crypto` | `crypto/`, `schemas/flowmemory/`, wallet/envelope vectors | +| Control plane/indexer | #101 | `agent/full-l1-control-plane` in `E:\FlowMemory\flowmemory-indexer` | `services/`, live API, no-secret API checks | +| Dashboard/workbench | #102 | `agent/full-l1-workbench` in `E:\FlowMemory\flowmemory-dashboard` | `apps/dashboard/`, local workbench views | +| Contracts/settlement | #103 | `agent/full-l1-contracts` in `E:\FlowMemory\flowmemory-contracts` | `contracts/`, `tests/`, bridge/settlement events | +| Bridge/test credit | #104 | `agent/full-l1-bridge` in `E:\FlowMemory\flowmemory-bridge-full` | `services/bridge-relayer/`, `contracts/bridge/`, bridge schemas/fixtures | +| Hardware signals | #105 | `agent/full-l1-hardware` in `E:\FlowMemory\flowmemory-hardware` | `hardware/`, `fixtures/hardware/`, optional signal fixtures | +| Research decisions | #106 | `agent/full-l1-research-consensus` in `E:\FlowMemory\flowmemory-research` | `research/`, `docs/DECISIONS/` | +| HQ integration | #107 | `agent/full-l1-hq-integration` in `E:\FlowMemory\flowmemory-review` | docs, runbooks, issue/PR map, smoke evidence | +| Full smoke gate | #108 | `agent/full-l1-hq-integration` plus subsystem branches | `infra/scripts/flowchain-full-smoke.ps1`, root package command | + +Other relevant issues: + +- #78 remains open for the real Uniswap v4 hook path beyond the adapter + scaffold. It is not on the critical path for the private/local L1 runtime. +- #76, #77, and #79 are closed. Do not keep treating them as active canary + follow-up issues. + +## Open PRs + +| PR | Branch | Status | Changed ownership | Review notes | Merge order | +| --- | --- | --- | --- | --- | --- | +| #114 `[codex] Extend local FlowChain control-plane API` | `agent/full-l1-control-plane` | Draft, CI passing | `services/control-plane/`, `docs/FLOWCHAIN_CONTROL_PLANE_API.md`, `docs/INDEXER_VERIFIER_MVP.md`, `package.json` | Matches #101 ownership. Adds live/local-file preference, submit/intake surfaces, and no-secret scan. Review should focus on whether transaction/bridge intake contracts match #99/#104 and whether package command additions conflict with #111/#113. | After #111, before #112 if dashboard depends on the expanded methods. | +| #113 `[codex] Build bridge relayer credit handoff smoke` | `agent/full-l1-bridge` | Draft, CI passing | `services/bridge-relayer/`, bridge schemas/fixtures/docs, bridge scripts, `package.json` | Matches #104 ownership. Adds bridge local-credit smoke and full-smoke wiring on its branch. Review should coordinate schema/event assumptions with #110 and runtime handoff expectations with #99/#101. | After #110 event schema is accepted, and after or alongside #114 if API intake is required. | +| #112 `[codex] Expand FlowChain workbench live console` | `agent/full-l1-workbench` | Draft, CI passing | `apps/dashboard/`, `docs/DASHBOARD_MVP.md` | Matches #102 ownership and extends the existing dashboard rather than adding a second app. PR notes say `flowchain:full-smoke` is not present on its branch, so it needs rebase after #111 before final review. Review should focus on API-gated actions, no browser private-key handling, and avoiding production/mainnet or real-funds UX claims. | After #101/#114 API surfaces are stable enough, or merge earlier only if all live API assumptions are fixture-safe and #111 is rebased in. | +| #110 `[codex] harden bridge lockbox settlement spine` | `agent/full-l1-contracts` | Draft, CI passing | `contracts/`, `tests/`, `script/`, `docs/bridge/` | Matches #103 ownership and does not touch services/apps/crates/crypto/hardware. Needs PR body or comment to link #103. Review should focus on duplicate bridge object semantics with #104 and whether `FlowChainSettlementSpine` stays optional settlement/event support rather than a second runtime. | Candidate early merge after #107 if #103 linkage is added and bridge relayer #104 accepts the event schema. | +| #71 `[codex] add terminal goal dispatcher` | `hq/terminal-dispatch` | Draft | `infra/scripts/send-goal-to-agent.ps1` | The merged launcher from #98 already starts full-L1 agents. Before merge, confirm this dispatcher still has distinct value and document how it relates to `launch-full-l1-agents.ps1`; avoid two competing dispatch paths. | After #107 refresh, or close if superseded by #98. | +| #73 `[codex] add L1 research inventory` | `hq/l1-research-inventory` | Draft | `docs/CURRENT_STATE.md`, `docs/L1_RESEARCH_INVENTORY.md`, `docs/ROADMAP.md` | Docs-only and no product implementation. Needs rebase/refresh against current `main` and the new #106 research decision issue before merge because it touches source-of-truth state docs. | Before or alongside #106 if refreshed; otherwise after #106 decisions. | + +Recently merged: + +- #98 `Add full L1 agent goal launcher` is merged into `main` at + `83d33f0`. It added `docs/agent-goals/full-l1/` and + `infra/scripts/launch-full-l1-agents.ps1`. + +## Local Worktrees + +Current `status-report.ps1` output showed these active branches: + +| Worktree | Branch | PR | Dirty state | Notes | +| --- | --- | --- | --- | --- | +| `E:\FlowMemory\flowmemory-main` | `release/windows-beginner-installer` | none listed | clean | Main checkout is not on `main`; use for status only unless assigned. | +| `E:\FlowMemory\flowchain-release` | `hq/full-l1-master-goals` | #98 merged | clean | Historical launcher branch; main contains its files. | +| `E:\FlowMemory\flowmemory-chain` | `agent/full-l1-runtime` | none yet | clean | Owns #99. | +| `E:\FlowMemory\flowmemory-crypto` | `agent/full-l1-crypto-wallet` | none yet | clean | Owns #100. | +| `E:\FlowMemory\flowmemory-indexer` | `agent/full-l1-control-plane` | #114 draft | dirty at latest status-report snapshot | Owns #101. | +| `E:\FlowMemory\flowmemory-dashboard` | `agent/full-l1-workbench` | #112 draft | dirty at latest status-report snapshot | Owns #102. | +| `E:\FlowMemory\flowmemory-contracts` | `agent/full-l1-contracts` | #110 draft | clean at last status-report snapshot; PR now open | Owns #103 and can support #104 contract pieces. | +| `E:\FlowMemory\flowmemory-bridge-full` | `agent/full-l1-bridge` | #113 draft | dirty at latest status-report snapshot | Owns #104. | +| `E:\FlowMemory\flowmemory-hardware` | `agent/full-l1-hardware` | none yet | clean | Owns #105. | +| `E:\FlowMemory\flowmemory-research` | `agent/full-l1-research-consensus` | none yet | clean | Owns #106. | +| `E:\FlowMemory\flowmemory-review` | `agent/full-l1-hq-integration` | this PR | clean before HQ edits | Owns #107 and #108 wrapper contract. | +| `E:\FlowMemory\flowmemory-bridge` | `agent/flowchain-base-bridge-poc` | none listed | dirty/untracked | Separate bridge POC worktree. Do not reuse for unrelated work; reconcile before assigning. | + +## Integration Matrix + +| Area | Implemented now | Running now | Remaining before full chain | Next prompt | +| --- | --- | --- | --- | --- | +| Chain | Deterministic no-value Rust devnet, demo, export/import, bounded wrappers | `flowchain:init`, `flowchain:start`, `flowchain:demo`, `flowchain:smoke` merged-surface path | Long-running node, signed tx intake, local balance/faucet records, multi-process smoke, native object lifecycle | `docs/agent-goals/full-l1/chain-runtime.md` | +| Crypto | Keccak V0 helpers, vectors, receipt/report/root helpers | `npm test --prefix crypto`, `npm run validate:vectors --prefix crypto` | Wallet/vault, signed envelopes, object IDs for full native lifecycle and bridge objects, negative vectors | `docs/agent-goals/full-l1/crypto-wallet.md` | +| Control plane | Local fixture-backed API, `/health`, `/state`, `/rpc`, smoke command | `npm run control-plane:serve`, `npm run control-plane:smoke` | Live node adapters, transaction submission, full lifecycle queries, bridge intake, no-secret response scans | `docs/agent-goals/full-l1/control-plane-indexer.md` | +| Dashboard | Existing Vite dashboard/workbench and fixture-backed views | `npm run workbench:dev`, dashboard build/test | Live API-backed local chain console, transaction/account/bridge/hardware views, second-computer status states | `docs/agent-goals/full-l1/dashboard-workbench.md` | +| Contracts | FlowPulse/registry surfaces and bridge POC foundation | `forge test`, `npm run contracts:hardening` | Harden BaseBridgeLockbox, settlement/event spine tests, dry-run deploy scripts, bridge event docs | `docs/agent-goals/full-l1/contracts-settlement.md` | +| Bridge | Test-only bridge POC docs, relayer package, mock command | `npm run bridge:mock`, `npm run bridge:test` | Base Sepolia observation command, BridgeCredit local runtime handoff, withdrawal intent, local-credit smoke | `docs/agent-goals/full-l1/bridge-relayer.md` | +| Hardware | FlowRouter POC simulator and seed fixture | Python simulator validation through current smoke | Operator signal fixtures for heartbeat, alerts, receipt relay, verifier digest, bridge alert, NFC metadata; optional API/workbench ingestion | `docs/agent-goals/full-l1/hardware-signals.md` | +| Research | Local-alpha and deployment-gate decisions exist | Docs only | Concrete consensus, transaction/state, storage, wallet, and bridge decision records for implementation | `docs/agent-goals/full-l1/research-consensus.md` | + +## Merge Order + +1. HQ #107 documentation, issue map, and temporary full-smoke wrapper. +2. Research #106 if builder agents need decisions before changing protocol + behavior. +3. Chain #99 and crypto #100 in parallel if their envelope/object contracts are + coordinated. +4. Control-plane #101 after the chain handoff shape and crypto object IDs are + stable enough to query. +5. Contracts #103 and bridge #104 in parallel only after bridge event/object + vocabulary is stable; bridge local-credit smoke depends on runtime/control + plane intake. +6. Dashboard #102 after the control-plane API methods settle. +7. Hardware #105 after control-plane/dashboard labels for optional signals are + stable. +8. Ops #108 finalizes `flowchain:full-smoke` after subsystem smoke commands + exist and produce evidence. + +If two PRs touch the same source-of-truth doc, merge the HQ/process PR first, +then rebase the subsystem PR and update only its evidence rows. + +## Full Smoke Status + +`npm run flowchain:full-smoke` now exists as the HQ wrapper contract. Until +issues #99 through #105 land their subsystem commands, it: + +- runs the current merged-surface `flowchain:smoke` unless skipped; +- writes `devnet/local/smoke/flowchain-full-smoke-report.json`; +- reports missing command coverage with owning issue numbers; +- exits nonzero by default while the full private/local L1 lifecycle is + incomplete; +- can be run with `-AllowIncomplete` only to validate the temporary wrapper. + +Latest local evidence from this HQ pass: + +| Command | Result | Evidence | +| --- | --- | --- | +| `npm run flowchain:smoke` | Passed after installing dashboard and crypto package dependencies | `devnet/local/smoke/flowchain-smoke-report.json` reported deterministic replay `true`, state root `0x75373cc47666ed9bcad605ce0f5d0aeb1bc8100a1087840d755205aef8a6bb50`, service/crypto/launch/devnet/dashboard/hardware/no-secret checks passed. | +| `npm run flowchain:full-smoke -- -SkipMergedSmoke -AllowIncomplete` | Passed as temporary blocker-report mode | `devnet/local/smoke/flowchain-full-smoke-report.json` reported `fullAcceptance: false` and missing command coverage for #99, #100, #104, and #105. | +| `git diff --check` | Passed | Only line-ending warnings were emitted by Git on this Windows checkout; no whitespace errors were reported. | + +Required final promotion for #108: + +- start or verify a long-running local node; +- create/unlock local test wallet; +- submit signed transactions and include them in blocks; +- query the control plane for every lifecycle object; +- build or verify the workbench; +- run bridge mock/local-credit smoke; +- validate optional hardware signal fixtures; +- export/import and compare deterministic roots; +- write a non-secret smoke report. + +## Follow-Up Prompts + +When an agent finishes early, assign the next smallest prompt in this order: + +1. Chain: add the smallest transaction intake and one-node smoke that can feed + control-plane state. +2. Crypto: ship object IDs and envelope validation before wallet UX polish. +3. Control plane: add no-secret response scanning as soon as live adapters are + present. +4. Dashboard: add down/offline command guidance before adding submit actions. +5. Contracts: stabilize bridge event tests before Base Sepolia scripts. +6. Bridge: keep mock observation and local handoff green before any Base + Sepolia read. +7. Hardware: deliver deterministic fixture shape before optional dashboard + polish. +8. Research: land decision records that unblock currently active builder PRs + first. diff --git a/docs/FLOWCHAIN_OPERATOR_CHECKLIST.md b/docs/FLOWCHAIN_OPERATOR_CHECKLIST.md index 5593523b..2a250c57 100644 --- a/docs/FLOWCHAIN_OPERATOR_CHECKLIST.md +++ b/docs/FLOWCHAIN_OPERATOR_CHECKLIST.md @@ -87,4 +87,3 @@ End each HQ/Ops handoff with: - Tests or checks run. - Current second-computer next command. - Risks, assumptions, and follow-ups. - diff --git a/docs/FLOWCHAIN_TROUBLESHOOTING.md b/docs/FLOWCHAIN_TROUBLESHOOTING.md index cfea5377..a42884f5 100644 --- a/docs/FLOWCHAIN_TROUBLESHOOTING.md +++ b/docs/FLOWCHAIN_TROUBLESHOOTING.md @@ -59,6 +59,7 @@ or workbench commands. | Crypto dependency error during strict prereq | Crypto deps are separate from root npm workspaces. | Run `npm install --prefix crypto`. | | Workbench does not open | Vite dev server did not start or the port changed. | Run `npm run workbench:dev` again and use the URL printed by Vite. | | Smoke fails during hardware fixture check | Python is missing or not on PATH. | Install Python 3 or run `npm run flowchain:smoke -- -SkipHardware` for a scoped local smoke. | +| `flowchain:full-smoke` fails with missing command coverage | The full local/private L1 package is not complete yet. | Read `devnet/local/smoke/flowchain-full-smoke-report.json` and the owning issues #99-#108. Use `npm run flowchain:full-smoke -- -SkipMergedSmoke -AllowIncomplete` only to validate the temporary report wrapper. | | Cargo output looks like a different worktree | A shared `CARGO_TARGET_DIR` is reusing stale binaries. | Use the root wrapper scripts; they pin cargo output to `crates/flowmemory-devnet/target` for this checkout. | | Existing state blocks init | `devnet/local/state.json` already exists. | Run `npm run flowchain:demo`, or force reset with `powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-init.ps1 -Force`. | | Import refuses to overwrite state | Import protects existing local state by default. | Run `npm run flowchain:import -- --BundlePath -Force`. | @@ -92,6 +93,17 @@ The current smoke report also lists blocked lifecycle coverage. Those blocked rows are expected until the chain, crypto, control-plane, and workbench workstreams land the remaining native private/local testnet surfaces. +The full-L1 wrapper report is: + +```powershell +Get-Content -Raw devnet/local/smoke/flowchain-full-smoke-report.json +``` + +Until the full workstreams land, it is expected to list missing command +coverage for long-running node, wallet/signing, live control plane, live +workbench, bridge local credit, optional hardware ingestion, and deterministic +full replay. Treat that as the integration checklist, not as passing evidence. + ## Secret Hygiene Do not commit: diff --git a/docs/INDEXER_VERIFIER_MVP.md b/docs/INDEXER_VERIFIER_MVP.md index 7b887c3e..8dee9f48 100644 --- a/docs/INDEXER_VERIFIER_MVP.md +++ b/docs/INDEXER_VERIFIER_MVP.md @@ -270,7 +270,7 @@ Those are future protocol decisions, not part of this local package. ## Local Control Plane -`services/control-plane` exposes the fixture-backed FlowChain / FlowMemory JSON-RPC 2.0 read API documented in `docs/FLOWCHAIN_CONTROL_PLANE_API.md`. +`services/control-plane` exposes the FlowChain / FlowMemory JSON-RPC 2.0 API documented in `docs/FLOWCHAIN_CONTROL_PLANE_API.md`. It now reads ignored `devnet/local/` runtime state and handoff files first, then falls back to committed deterministic fixtures. Run: @@ -281,7 +281,7 @@ npm run control-plane:smoke npm run control-plane:serve ``` -The control-plane reads committed launch-core, indexer, verifier, artifact, transaction fixture, and local devnet handoff files first. If the generated launch-core fixture is missing, it rebuilds an in-memory view from deterministic indexer/verifier fixtures. It exposes read methods for health, chain status, blocks, transactions, rootfields, agents, models, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, provenance, and raw JSON. It does not fetch production RPC data, store secrets, or make production API claims. +The control-plane exposes methods for health, chain status, node status, peers, mempool, blocks, transactions, transaction submission, accounts, balances, faucet status, wallet public metadata, rootfields, agents, agent accounts, models, model passports, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, bridge observations/deposits/credits/withdrawals, provenance, and raw JSON. `transaction_submit` forwards local test transactions to the existing Rust devnet `submit-fixture` intake path. `bridge_observation_submit` stores bridge-agent observations under `services/bridge-relayer/out/`. The smoke client queries every lifecycle object and runs no-secret response scanning. The package does not fetch production RPC data, store secrets, or make production API claims. ## Open Questions diff --git a/docs/ISSUE_BACKLOG.md b/docs/ISSUE_BACKLOG.md index 5644a44e..1625833a 100644 --- a/docs/ISSUE_BACKLOG.md +++ b/docs/ISSUE_BACKLOG.md @@ -36,8 +36,8 @@ state; update this index after issue or milestone changes. Primary milestone: make the FlowChain private/local L1 testnet package for second-computer validation runnable from a clean Windows machine. -These rows are proposed next-wave issue groupings unless a GitHub issue number -is named. They should become GitHub issues before implementation work starts. +GitHub milestone #7 now tracks the full-L1 workstream. The live HQ issue/PR +and ownership matrix is `docs/FLOWCHAIN_HQ_INTEGRATION_STATUS.md`. Remaining gaps for this milestone: @@ -50,8 +50,10 @@ Remaining gaps for this milestone: and raw JSON. - Workbench views for the same private/local testnet entities. - No-secret checks for control-plane responses. +- Test bridge observation and local-credit smoke path. - Full deterministic second-computer smoke evidence for native private/local - objects after chain, crypto, control-plane, and dashboard work lands. + objects after chain, crypto, control-plane, dashboard, bridge, and hardware + work lands. Implemented HQ/Ops packaging layer: @@ -61,6 +63,8 @@ Implemented HQ/Ops packaging layer: - `infra/scripts/flowchain-stop.ps1` - `infra/scripts/flowchain-demo.ps1` - `infra/scripts/flowchain-smoke.ps1` +- `infra/scripts/flowchain-full-smoke.ps1` as the temporary full-L1 + blocker-report wrapper. - `infra/scripts/flowchain-export.ps1` - `infra/scripts/flowchain-import.ps1` - `infra/scripts/flowchain-workbench.ps1` @@ -74,75 +78,73 @@ Dependency order: vectors for the same object set. 3. Control plane reads the existing devnet, launch-core, and verifier outputs without creating a second API model. -4. Dashboard extends the existing app to consume control-plane or deterministic +4. Contracts and bridge stabilize the test-only settlement/bridge event path + without production bridge claims. +5. Dashboard extends the existing app to consume control-plane or deterministic fixture output. -5. Hardware contributes optional advisory signal fixtures only after object and +6. Hardware contributes optional advisory signal fixtures only after object and API labels are stable. -6. Packaging keeps root command aliases and Windows scripts aligned as command +7. Packaging keeps root command aliases and Windows scripts aligned as command semantics evolve. ### Chain / Devnet | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[chain] Extend devnet into private FlowChain testnet runtime` | Proposed | Chain - `flowmemory-chain` | Current `crates/flowmemory-devnet/`, crypto object IDs | Extend existing Rust devnet only; add genesis/config, deterministic object lifecycle, export/import, and full smoke path. | -| Proposed `[chain] Add deterministic private testnet smoke fixture` | Proposed | Chain - `flowmemory-chain` | Runtime extension | Must prove agent, model, receipt, artifact, verifier report, memory, challenge, finality, export, and replay. | -| Proposed `[chain] Document LAN/private-node boundaries` | Proposed | Chain - `flowmemory-chain` | Runtime start behavior | LAN is optional; no public validator or production consensus claim. | +| #99 `[chain] Make FlowChain private/local L1 runtime runnable` | Open | Chain - `flowmemory-chain` on `agent/full-l1-runtime` | Current Rust devnet, #100 envelope/object IDs | Long-running node, transaction intake, local test-unit ledger, restart persistence, export/import, one-node and multi-process smoke. | ### Control Plane / Indexer | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[indexer] Expose private FlowChain testnet control plane` | Proposed | Indexer - `flowmemory-indexer` | Chain handoff, crypto object IDs | Extend `services/control-plane/` and existing indexer/verifier outputs; no second API. | -| Proposed `[indexer] Add control-plane full-smoke client` | Proposed | Indexer - `flowmemory-indexer` | Control-plane methods | Query health, chain, blocks, txs, agents, models, receipts, artifacts, verifier reports, challenges, finality, memory, provenance, raw JSON. | -| Proposed `[indexer/security] Add no-secret response checks` | Proposed | Indexer - `flowmemory-indexer` | Control-plane API | Tests must prevent keys, RPC URLs, API keys, seed phrases, and private locators from appearing in responses. | +| #101 `[indexer] Expose full FlowChain local control plane and full-smoke client` | Open; draft PR #114 | Indexer - `flowmemory-indexer` on `agent/full-l1-control-plane` | #99 chain handoff, #100 object IDs, #104 bridge objects | Live-node adapters, full lifecycle queries, transaction submission, bridge observation intake, `control-plane:smoke`, and no-secret response scans. | ### Crypto / RD | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[crypto] Define private testnet object envelopes and vectors` | Proposed | Crypto - `flowmemory-crypto` | Existing `crypto/`, `schemas/flowmemory/` | AgentAccount, ModelPassport, MemoryCell, ArtifactAvailabilityProof, VerifierModule, Challenge, FinalityReceipt, provenance response. | -| Proposed `[crypto] Define local signer and envelope policy` | Proposed | Crypto - `flowmemory-crypto` | Object IDs | Local operators, agents, verifiers, hardware signal issuers; no production wallet or audited-crypto claim. | -| Proposed `[crypto] Add negative vector coverage` | Proposed | Crypto - `flowmemory-crypto` | Envelope policy | Replay, wrong domain, missing signer, zero hash, malformed object, duplicate IDs. | +| #100 `[crypto] Add wallet, signed envelopes, and object IDs for full local L1` | Open | Crypto - `flowmemory-crypto` on `agent/full-l1-crypto-wallet` | Existing `crypto/`, schemas, #99 runtime object model | Canonical envelopes, wallet/vault, object IDs, bridge objects, positive/negative vectors, and no-secret exports. | ### Dashboard / Workbench | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[dashboard] Build local FlowChain testnet workbench` | Proposed | Dashboard - `flowmemory-dashboard` | Control-plane API or deterministic fixture fallback | Extend existing dashboard; show node health, blocks, transactions, agents, models, receipts, memory cells, artifacts, reports, challenges, finality, provenance, raw JSON. | -| Proposed `[dashboard] Add local setup/status panel` | Proposed | Dashboard - `flowmemory-dashboard` | Packaging command names | Show expected local commands and service states; no marketing landing page. | -| #76 `[dashboard] Add canary deployment artifact ingestion and live/canary mode` | Open | Dashboard - `flowmemory-dashboard` | Guarded canary reader output | Canary mode must stay separate from private/local testnet and production claims. | +| #102 `[dashboard] Extend workbench for live FlowChain local L1 state` | Open; draft PR #112 | Dashboard - `flowmemory-dashboard` on `agent/full-l1-workbench` | #101 API, #99 runtime handoff | Live workbench over node, tx, account, object lifecycle, bridge, hardware, provenance, and raw JSON state. | +| #76 `[dashboard] Add canary deployment artifact ingestion and live/canary mode` | Closed | Dashboard - `flowmemory-dashboard` | Guarded canary reader output | Canary mode is historical follow-up evidence, not active private/local testnet work. | + +### Bridge + +| Issue | State | Agent/worktree | Dependencies | Notes | +| --- | --- | --- | --- | --- | +| #104 `[bridge] Build test bridge observation and local credit path` | Open; draft PR #113 | Bridge - `flowmemory-bridge-full` on `agent/full-l1-bridge` | #100 bridge IDs, #101 intake, #103 lockbox/events | Mock/Base Sepolia observation, BridgeObservation/BridgeCredit objects, local-credit smoke, withdrawal intent, explicit real-funds guardrails. | ### Hardware | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[hardware] Add optional private testnet operator signal fixtures` | Proposed | Hardware - `flowmemory-hardware` | Crypto/control-plane labels | Heartbeat, receipt relay, verifier digest relay, offline alert/challenge input, NFC cartridge metadata. | -| Proposed `[hardware] Validate simulator projection for workbench ingestion` | Proposed | Hardware - `flowmemory-hardware` | Hardware fixture schema | Hardware remains optional and advisory; no manufacturing, RF, broadband, validator, or trustlessness claim. | +| #105 `[hardware] Add optional operator-signal fixtures for FlowChain local L1` | Open | Hardware - `flowmemory-hardware` on `agent/full-l1-hardware` | #101/#102 labels, #100 optional signer vocabulary | Heartbeat, receipt relay, verifier digest relay, offline/challenge alert, bridge alert, NFC metadata, deterministic simulator fixtures. | ### Contracts | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[contracts] Align settlement spine with private testnet objects` | Proposed | Contracts - `flowmemory-contracts` | Object model and FlowPulse semantics | Contracts remain optional event/settlement spine; do not move private runtime into Solidity. | +| #103 `[contracts] Harden settlement and bridge event spine for FlowChain local L1` | Open; draft PR #110 | Contracts - `flowmemory-contracts` on `agent/full-l1-contracts` | #100 object/bridge vocabulary, #104 relayer handoff | Harden BaseBridgeLockbox, settlement/event tests, dry-run scripts, stable bridge event schema. | | #78 `[contracts] Build real Uniswap v4 hook path beyond HookAdapter scaffold` | Open | Contracts - `flowmemory-contracts` | Hook boundary and deployment decisions | Must remain outside private testnet core and must not claim production hook readiness. | -| #79 `[contracts/security] Define ownership and operator policy for deployed V0 surfaces` | Open | Contracts - `flowmemory-contracts` | Access-control review | Policy only until implementation is scoped. | +| #79 `[contracts/security] Define ownership and operator policy for deployed V0 surfaces` | Closed | Contracts - `flowmemory-contracts` | Access-control review | Historical canary follow-up; keep future policy changes scoped in new issues. | ### Research | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[research] Gate advanced FlowChain L1 research for private testnet` | Proposed | Research - `flowmemory-research` | Existing research docs and decisions | Local testnet, public devnet, and public L1 gates; Process-Witness, SEAL, encrypted compute, bridge/security blocked until reviewed. | -| Proposed `[research] Define dependency atom placeholder boundary` | Proposed | Research - `flowmemory-research` | Crypto object vocabulary | Placeholder or dependency-root vocabulary only; no proof claim. | +| #106 `[research] Lock FlowChain local consensus, storage, wallet, and bridge decisions` | Open | Research - `flowmemory-research` on `agent/full-l1-research-consensus` | Existing research docs and decisions | Concrete V0 local consensus, transaction/state, storage, wallet, bridge, and full-L1 acceptance gates. | ### Review / HQ / Packaging | Issue | State | Agent/worktree | Dependencies | Notes | | --- | --- | --- | --- | --- | -| Proposed `[hq] Define FlowChain private testnet acceptance plan` | Proposed | Review/HQ - `flowmemory-review` | Dispatch target | Docs-only acceptance, setup, integration map, roadmap, backlog. | -| Proposed `[ops] Maintain Windows private testnet run scripts` | Implemented locally; keep open until merged | Review/HQ or packaging owner | Command names from chain/control-plane/dashboard | Prereq check, init, bounded start/stop, demo, smoke, export/import, and workbench scripts now exist; update when subsystem commands change. | -| Proposed `[hq] Create second-computer validation checklist` | Implemented locally; keep open until merged | Review/HQ - `flowmemory-review` | Smoke command and workbench | `docs/FLOWCHAIN_SECOND_COMPUTER_SETUP.md`, `docs/FLOWCHAIN_TROUBLESHOOTING.md`, and `docs/FLOWCHAIN_OPERATOR_CHECKLIST.md` record commands, outputs, limitations, and follow-ups. | -| #77 `[ops/security] Automate source verification for V0 canary contracts` | Open | Review/HQ or Contracts/Ops | Canary deployment docs | Canary follow-up only; not private testnet production readiness. | +| #107 `[hq] Maintain full-L1 integration matrix, merge order, and smoke evidence` | Open | Review/HQ - `flowmemory-review` on `agent/full-l1-hq-integration` | GitHub milestone #7, active worktrees | Live issue/PR matrix, branch ownership, merge order, second-computer setup, and follow-up prompts. | +| #108 `[ops] Implement final flowchain:full-smoke gate for full local L1 acceptance` | Open | Review/HQ wrapper plus subsystem owners | #99-#105 subsystem commands | Temporary wrapper exists; final gate must initialize, run, query, bridge-smoke, export/import, and compare deterministic roots. | +| #77 `[ops/security] Automate source verification for V0 canary contracts` | Closed | Review/HQ or Contracts/Ops | Canary deployment docs | Historical canary follow-up only; not private testnet production readiness. | ## V0 Repo OS diff --git a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md index 1d86f6f2..69961d13 100644 --- a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md +++ b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md @@ -9,17 +9,29 @@ public bridge, and not approved for broad mainnet use. ## What Exists - `contracts/bridge/BaseBridgeLockbox.sol`: non-upgradeable lockbox with owner, - pause, allowlisted tokens, per-deposit caps, total caps, deposit events, and - owner-only release helpers. + explicit test release authority, pause, allowlisted tokens, per-deposit caps, + total caps, deposit records, replay guards, deposit events, and release hooks. +- `contracts/FlowChainSettlementSpine.sol`: compact local/test event spine for + bridge and FlowChain object commitments. - `tests/bridge/BaseBridgeLockbox.t.sol`: Foundry coverage for token allowlisting, ERC-20 deposits, native deposits, caps, pause behavior, ownership, release, and replay protection. -- `services/bridge-relayer/`: fixture-first observer that converts explicit - bridge deposit records into FlowChain bridge observation JSON. +- `tests/FlowChainSettlementSpine.t.sol`: Foundry coverage for authorized object + commitments and stable settlement event shape. +- `services/bridge-relayer/`: fixture-first and RPC-range observer that + converts explicit bridge deposit records into FlowChain bridge observation, + credit, withdrawal-intent, and runtime handoff JSON. - `fixtures/bridge/base-sepolia-mock-deposit.json`: deterministic test deposit. -- `schemas/flowmemory/bridge-deposit.schema.json` and - `schemas/flowmemory/bridge-observation.schema.json`: bridge object contracts. +- `fixtures/bridge/local-runtime-bridge-handoff.json`: deterministic local + bridge handoff consumed by the runtime/control-plane until direct intake is + enabled. +- `schemas/flowmemory/bridge-*.schema.json`: bridge deposit, observation, + credit, withdrawal-intent, and runtime handoff contracts. +- `infra/scripts/bridge-base-sepolia-observe.ps1`: env-friendly Base Sepolia + observation wrapper that requires no private key. - `infra/scripts/bridge-base-sepolia-smoke.ps1`: guarded Base Sepolia smoke. +- `infra/scripts/bridge-local-anvil-observe.ps1`: local Anvil observation + wrapper for chain id `31337`. - `infra/scripts/bridge-base-mainnet-canary-read.ps1`: disabled-by-default Base mainnet canary read wrapper. @@ -28,22 +40,34 @@ public bridge, and not approved for broad mainnet use. ```text Base Sepolia user/test wallet -> BaseBridgeLockbox.lockERC20 or lockNative - -> BridgeDeposit event + -> BridgeDeposit event and DepositRecord state -> bridge-relayer explicit reader/mock observer - -> FlowChain bridge deposit observation fixture - -> local control plane / workbench / devnet handoff + -> BridgeObservation with replay key + -> BridgeCredit pending/applied local object + -> optional FlowChainSettlementSpine.commitObject bridge-deposit commitment + -> local runtime/control-plane/workbench handoff ``` The POC does not mint production assets on FlowChain. Local acceptance is a fixture/control-plane event until the private/local runtime explicitly consumes bridge deposit objects. +The handoff includes a workbench-ready timeline: + +```text +deposit observed -> credit pending -> credit applied -> withdrawal requested +``` + +Until live bridge intake is enabled, `fixtures/bridge/local-runtime-bridge-handoff.json` +is the exact file for the runtime/control-plane to consume. + ## Risk Model - Base mainnet uses real funds. Mainnet canary reads require `--acknowledge-real-funds` and `--max-usd 25` or lower. -- The lockbox owner can pause and release funds. That is a test operator model, - not a decentralized bridge model. +- The lockbox owner can configure tokens, caps, pause state, and the explicit + release authority. Only the release authority can call release hooks. That is + a test operator model, not a decentralized bridge model. - The relayer reads explicit chains, contracts, and block ranges. It must not broad-scan Base mainnet. - No secrets, RPC keys, private keys, or seed phrases should be committed. @@ -56,12 +80,16 @@ bridge deposit objects. npm install npm run bridge:mock npm run bridge:test +npm run bridge:local-credit:smoke ``` Expected output: ```text services/bridge-relayer/out/bridge-observation.json +services/bridge-relayer/out/bridge-credit.json +services/bridge-relayer/out/bridge-runtime-handoff.json +fixtures/bridge/local-runtime-bridge-handoff.json ``` ## Base Sepolia Smoke @@ -77,7 +105,133 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-se ``` The script checks Base Sepolia chain id `84532`, requires an explicit lockbox, -requires an explicit block range, and writes a local observation output. +requires an explicit block range, and writes local observation output. + +The root package also exposes an env-var smoke path that does not require a +private key: + +```powershell +$env:BASE_SEPOLIA_RPC_URL="" +$env:BASE_BRIDGE_LOCKBOX_ADDRESS="" +$env:BASE_BRIDGE_FROM_BLOCK="" +$env:BASE_BRIDGE_TO_BLOCK="" +npm run bridge:sepolia:observe +``` + +This command reads only `BridgeDeposit` logs from the explicit lockbox and +range, then writes observation, credit, and handoff JSON under +`services/bridge-relayer/out/`. + +## Local Anvil Observation + +Local Anvil is supported as a mock Base event lane with chain id `31337`. +Deploy `BaseBridgeLockbox`, emit one or more deposits, then run: + +```powershell +$env:ANVIL_BRIDGE_LOCKBOX_ADDRESS="" +$env:ANVIL_BRIDGE_FROM_BLOCK="" +$env:ANVIL_BRIDGE_TO_BLOCK="" +npm run bridge:anvil:observe +``` + +Use `-RpcUrl` or `ANVIL_RPC_URL` if the Anvil endpoint is not +`http://127.0.0.1:8545`. + +## Foundry Deploy Script + +The contract-side bridge spine has a dry-run-by-default Foundry script: + +```powershell +$env:FLOWCHAIN_BRIDGE_OWNER = "0x..." +$env:FLOWCHAIN_BRIDGE_RELEASE_AUTHORITY = "0x..." +$env:FLOWCHAIN_SETTLEMENT_SUBMITTER = "0x..." +$env:FLOWCHAIN_BRIDGE_ALLOW_NATIVE = "true" +$env:FLOWCHAIN_BRIDGE_NATIVE_PER_DEPOSIT_CAP = "100000000000000000" +$env:FLOWCHAIN_BRIDGE_NATIVE_TOTAL_CAP = "1000000000000000000" +$env:FLOWCHAIN_BRIDGE_ALLOW_ERC20 = "false" +$env:FLOWCHAIN_BRIDGE_ERC20_TOKEN = "0x0000000000000000000000000000000000000000" +$env:FLOWCHAIN_BRIDGE_ERC20_PER_DEPOSIT_CAP = "0" +$env:FLOWCHAIN_BRIDGE_ERC20_TOTAL_CAP = "0" + +forge script script/DeployBridgeSpine.s.sol:DeployBridgeSpine ` + --rpc-url http://127.0.0.1:8545 +``` + +For Base Sepolia dry-run, use `--rpc-url $env:BASE_SEPOLIA_RPC_URL`. Add +`--broadcast` only after the environment values are explicit and the owner key +is intentionally supplied to Foundry. Do not commit RPC URLs or private keys. + +## Contract Event Schema + +`BridgeDeposit` is the relayer-facing deposit event: + +```solidity +event BridgeDeposit( + bytes32 indexed depositId, + uint256 indexed sourceChainId, + address indexed sender, + address token, + uint256 amount, + bytes32 flowchainRecipient, + uint256 nonce, + bytes32 metadataHash +); +``` + +`depositId` is: + +```text +keccak256(abi.encode( + BRIDGE_DEPOSIT_SCHEMA_ID, + block.chainid, + lockboxAddress, + sender, + token, + amount, + flowchainRecipient, + nonce, + metadataHash +)) +``` + +`BridgeRelease` is a test-only release event: + +```solidity +event BridgeRelease( + bytes32 indexed releaseId, + bytes32 indexed depositId, + address indexed recipient, + address token, + uint256 amount, + bytes32 evidenceHash +); +``` + +Release hooks require the configured release authority, a recorded deposit, +matching token, nonzero evidence hash, and available unreleased deposit amount. +They do not mint anything and do not prove FlowChain finality. + +`FlowChainSettlementSpine` can record the local/private runtime's accepted +object commitments without implementing the runtime in Solidity: + +```solidity +event FlowChainObjectCommitted( + bytes32 indexed objectId, + bytes32 indexed rootfieldId, + bytes32 indexed objectType, + address submitter, + bytes32 commitment, + bytes32 parentObjectId, + uint64 sequence, + uint64 committedAt, + string evidenceURI +); +``` + +Bridge agents should use `BRIDGE_DEPOSIT_OBJECT` as `objectType` when committing +a FlowChain bridge-deposit object derived from a `BridgeDeposit`. Indexers still +derive `txHash`, `logIndex`, and block metadata from receipts and logs; those +fields are not emitted by the contracts. ## Base Mainnet Canary Read @@ -94,14 +248,19 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-ma ``` The script checks Base mainnet chain id `8453` and refuses a canary above -`25` USD. +`25` USD. It is read-only and prints the chain, lockbox, block range, max USD +guardrail, and broadcast status before it reads logs. ## Commands ```powershell forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol +forge test --match-path tests/FlowChainSettlementSpine.t.sol npm run bridge:test npm run bridge:mock +npm run bridge:sepolia:observe +npm run bridge:local-credit:smoke +npm run flowchain:full-smoke git diff --check ``` diff --git a/fixtures/bridge/local-runtime-bridge-handoff.json b/fixtures/bridge/local-runtime-bridge-handoff.json new file mode 100644 index 00000000..29d56b9d --- /dev/null +++ b/fixtures/bridge/local-runtime-bridge-handoff.json @@ -0,0 +1,213 @@ +{ + "schema": "flowmemory.bridge_runtime_handoff.v0", + "handoffId": "0xb8f818f1c45a864a7134b298b993952edda161824a4120c7716ce950fe63a2ca", + "generatedAt": "2026-05-13T00:00:00.000Z", + "mode": "mock", + "productionReady": false, + "localOnly": true, + "observations": [ + { + "schema": "flowmemory.bridge_deposit_observation.v0", + "observationId": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "replayKey": "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09", + "observedAt": "2026-05-13T00:00:00.000Z", + "mode": "mock", + "productionReady": false, + "deposit": { + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "sourceChainId": 84532, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" + }, + "guardrails": { + "explicitChainId": true, + "explicitContract": true, + "explicitBlockRange": false, + "noSecrets": true + } + } + ], + "credits": [ + { + "schema": "flowmemory.bridge_credit.v0", + "creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "observationId": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "replayKey": "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09", + "source": { + "chainId": 84532, + "contract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0 + }, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "status": "applied", + "appliedAt": "2026-05-13T00:00:00.000Z", + "localOnly": true, + "productionReady": false + } + ], + "withdrawalIntents": [ + { + "schema": "flowmemory.bridge_withdrawal_intent.v0", + "withdrawalIntentId": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "sourceChainId": 84532, + "destinationChainId": 84532, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "flowchainAccount": "0x5555555555555555555555555555555555555555555555555555555555555555", + "baseRecipient": "0x4444444444444444444444444444444444444444", + "status": "requested", + "requestedAt": "2026-05-13T00:00:00.000Z", + "testMode": true, + "broadcast": false, + "releasePolicy": "test_record_only", + "productionReady": false + } + ], + "replayProtection": { + "strategy": "source-chain-contract-tx-log-deposit", + "replayKeys": [ + "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09" + ], + "duplicateReplayKeys": [] + }, + "runtimeIntake": { + "status": "handoff_file", + "consumer": "flowchain-runtime-agent", + "expectedPath": "fixtures/bridge/local-runtime-bridge-handoff.json", + "note": "Runtime/control-plane bridge intake is not merged in this scope. Consume this file as the deterministic bridge credit handoff." + }, + "workbenchTimeline": [ + { + "phase": "deposit_observed", + "status": "observed", + "objectId": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "title": "Deposit observed", + "summary": "Observed lockbox deposit 0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269 on chain 84532." + }, + { + "phase": "credit_pending", + "status": "pending", + "objectId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "title": "Credit pending", + "summary": "20000000 test units queued for 0x5555555555555555555555555555555555555555555555555555555555555555." + }, + { + "phase": "credit_applied", + "status": "applied", + "objectId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "title": "Credit applied", + "summary": "20000000 test units applied in local bridge smoke state." + }, + { + "phase": "withdrawal_requested", + "status": "requested", + "objectId": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "title": "Withdrawal requested", + "summary": "Test-mode local-to-Base withdrawal intent recorded with no broadcast or real release." + } + ], + "workbenchRecords": [ + { + "sectionKey": "transactions", + "id": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "kind": "Bridge deposit observation", + "title": "0x2222222222222222222222222222222222222222222222222222222222222222", + "summary": "Deposit 0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269 observed from mock.", + "status": "observed", + "facts": [ + { + "label": "chain", + "value": "84532" + }, + { + "label": "lockbox", + "value": "0x1111111111111111111111111111111111111111" + }, + { + "label": "log index", + "value": "0" + }, + { + "label": "amount", + "value": "20000000" + } + ], + "rawRef": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c" + }, + { + "sectionKey": "receipts", + "id": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "kind": "Bridge credit", + "title": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "summary": "Credit applied for deposit 0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269.", + "status": "verified", + "facts": [ + { + "label": "recipient", + "value": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + { + "label": "amount", + "value": "20000000" + }, + { + "label": "token", + "value": "0x3333333333333333333333333333333333333333" + }, + { + "label": "replay key", + "value": "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09" + } + ], + "rawRef": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6" + }, + { + "sectionKey": "transactions", + "id": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "kind": "Bridge withdrawal intent", + "title": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "summary": "Test-mode withdrawal intent recorded; no mainnet or real-funds release is broadcast.", + "status": "pending", + "facts": [ + { + "label": "base recipient", + "value": "0x4444444444444444444444444444444444444444" + }, + { + "label": "amount", + "value": "20000000" + }, + { + "label": "broadcast", + "value": "false" + }, + { + "label": "policy", + "value": "test_record_only" + } + ], + "rawRef": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751" + } + ], + "limitations": [ + "Bridge objects are for mock, local Anvil, and Base Sepolia test validation by default.", + "No production bridge readiness, audited security, or trustless finality is claimed.", + "Withdrawal intents are test-mode records only and do not broadcast releases.", + "RPC URLs and private keys are never written to bridge artifacts." + ] +} diff --git a/fixtures/dashboard/flowmemory-dashboard-v0.json b/fixtures/dashboard/flowmemory-dashboard-v0.json index fc642893..07cb6a94 100644 --- a/fixtures/dashboard/flowmemory-dashboard-v0.json +++ b/fixtures/dashboard/flowmemory-dashboard-v0.json @@ -1993,11 +1993,11 @@ ], "devnetBlocks": [ { - "id": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "id": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "blockNumber": 1, - "blockHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "blockHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "parentHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9", - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "receiptsRoot": "0x2f98caf4b28b2209cdf1f9beb1c23f8732c538657cc7a1d8855878b5400efabd", "timestamp": "2026-05-13T16:00:00.000Z", "observationCount": 8, @@ -2015,11 +2015,11 @@ } }, { - "id": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", + "id": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", "blockNumber": 2, - "blockHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", - "parentHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "blockHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", + "parentHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "receiptsRoot": "0xa0407b9a8a55106d549e0f19b92fceaa7f7a25697e94ebf8a1fa74af7b9168f4", "timestamp": "2026-05-13T16:00:01.000Z", "observationCount": 8, diff --git a/fixtures/hardware/README.md b/fixtures/hardware/README.md index 930c8bf2..1122e748 100644 --- a/fixtures/hardware/README.md +++ b/fixtures/hardware/README.md @@ -7,21 +7,32 @@ This folder contains local-alpha hardware projections that can be consumed by da ## Fixtures - `flowrouter_local_alpha_seed42.json`: deterministic FlowRouter-to-FlowChain operator signal projection generated from `hardware/fixtures/flowrouter_sample_seed42.json`. +- `flowrouter_control_plane_handoff_seed42.json`: read-only optional hardware handoff shaped for local control-plane ingestion. +- `flowrouter_negative_validation_seed42.json`: deterministic report proving malformed hardware/operator handoff cases are rejected. ## Shape The fixture is a `flowmemory.hardware_operator_signals.local_alpha.v0` document. It includes: -- `signalEnvelopes`: one envelope for heartbeat, receipt relay, verifier digest relay, offline alert/challenge input, and NFC memory cartridge metadata. -- `hardwareSignals`: direct workbench/control-plane signal records for the same five envelopes. -- `hardwareNodes`, `workReceipts`, `verifierReports`, `artifactCommitments`, `memoryCells`, `challenges`, `finalityReceipts`, and `alerts`: control-plane-friendly local fixture collections. +- `signalEnvelopes`: envelopes for operator metadata, heartbeat, receipt relay, verifier digest relay, offline alert/challenge input, bridge alert, and NFC memory cartridge metadata. +- `hardwareSignals`: direct workbench/control-plane signal records for the same envelopes. +- `operatorMetadata`, `hardwareNodes`, `workReceipts`, `verifierReports`, `bridgeAlerts`, `artifactCommitments`, `memoryCells`, `challenges`, `finalityReceipts`, and `alerts`: control-plane-friendly local fixture collections. - `workbenchRecords`: ready-to-render records grouped by workbench section keys, including `hardwareSignals`. - `boundary`: explicit local-only, advisory, optional-hardware limitations. +The handoff fixture is a `flowmemory.hardware_control_plane_handoff.local_alpha.v0` document. It mirrors the stable control-plane state keys under `collections`, declares read-only merge id fields, and carries an optional full-smoke row: + +```powershell +python hardware/simulator/flowrouter_sim.py --smoke +``` + ## Validation ```powershell +python hardware/simulator/flowrouter_sim.py --smoke --seed 42 python hardware/simulator/flowrouter_sim.py --validate-operator-file fixtures/hardware/flowrouter_local_alpha_seed42.json +python hardware/simulator/flowrouter_sim.py --validate-handoff-file fixtures/hardware/flowrouter_control_plane_handoff_seed42.json +python hardware/simulator/flowrouter_sim.py --validate-negative-report-file fixtures/hardware/flowrouter_negative_validation_seed42.json ``` These fixtures are local-only and advisory. They do not prove hardware trustlessness, production field deployment, or receipt/verifier finality. diff --git a/fixtures/hardware/flowrouter_control_plane_handoff_seed42.json b/fixtures/hardware/flowrouter_control_plane_handoff_seed42.json new file mode 100644 index 00000000..94df362d --- /dev/null +++ b/fixtures/hardware/flowrouter_control_plane_handoff_seed42.json @@ -0,0 +1,1739 @@ +{ + "boundary": { + "advisory": true, + "claimLimitations": [ + "Hardware-originated references are hints until reconciled by normal indexer, receipt, and verifier paths.", + "LoRa and Meshtastic packets carry compact control signals, not artifacts, model data, media, or raw memory.", + "NFC cartridge metadata is an untrusted pointer until checked against expected commitments.", + "Emergency offline signals are operator alerts or challenge inputs only; they do not execute remote actions.", + "Bridge alerts are operator review hints and must not block local chain progress." + ], + "hardwareRequiredForPrivateTestnet": false, + "localOnly": true, + "normalNetworkReconciliationRequired": true + }, + "chainId": "flowmemory-local-alpha", + "collections": { + "alerts": [ + { + "id": "hw-alert-f187cbc304cb", + "incidentId": "hw-alert-f187cbc304cb", + "linkedObjectIds": [ + "fr-e1e7878a2aa8", + "receipt:hardware:1e92f586a767", + "challenge:hardware:5f54ef32073d" + ], + "localOnly": true, + "openedAt": "2026-05-13T17:01:30Z", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "recommendedAction": "check-upstream-and-power", + "severity": "warning", + "sourcePacketType": "emergency_offline_signal", + "status": "unresolved", + "summary": "Upstream unavailable; LAN dashboard and local cache still reachable.", + "title": "UPSTREAM_LOSS" + }, + { + "id": "hw-alert-008de71ad207", + "incidentId": "hw-alert-008de71ad207", + "linkedObjectIds": [ + "bridge-alert:hardware:8662b11ba0d4" + ], + "localOnly": true, + "openedAt": "2026-05-13T17:01:35Z", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "recommendedAction": "review-bridge-observer-and-do-not-block-chain", + "severity": "warning", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "title": "LOCKBOX_OBSERVER_LAG" + } + ], + "artifactCommitments": [ + { + "artifactId": "artifact:hardware:dffc8c3e46ff", + "availabilityStatus": "metadata-only", + "cartridgeId": "cart-0cfd3cfcb210", + "commitment": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "containsSecrets": false, + "expiresAt": "2026-06-13T17:00:00Z", + "label": "field-test-cache-alpha", + "localOnly": true, + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed", + "trustLevel": "untrusted-pointer", + "uriHint": "flowmemory://cache/4549dba629bc" + } + ], + "bridgeAlerts": [ + { + "alertCode": "LOCKBOX_OBSERVER_LAG", + "blockHint": 1200024, + "bridgeAlertId": "bridge-alert:hardware:8662b11ba0d4", + "bridgeId": "bridge-3489980d98a3", + "doesNotBlockLocalChain": true, + "eventDigest": "0xaa5bbd37b946a917cd73aa5394466e71c463cf28e5a1091c474139e23cfe236f", + "localOnly": true, + "loraEligible": true, + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "recommendedAction": "review-bridge-observer-and-do-not-block-chain", + "resolutionState": "operator-review-required", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "severity": "warning", + "sourceChain": "flowchain-local-alpha", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "subjectId": "lockbox:dfce05f88cae", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "targetChain": "base-sepolia-sim" + } + ], + "challenges": [ + { + "challengeId": "challenge:hardware:5f54ef32073d", + "doesNotExecuteRemoteAction": true, + "localOnly": true, + "openedAt": "2026-05-13T17:01:30Z", + "openedBy": "hardware-node:fr-e1e7878a2aa8", + "reason": "offline-alert-candidate", + "receiptId": "receipt:hardware:1e92f586a767", + "recommendedAction": "check-upstream-and-power", + "reportId": "report:hardware:bd367723d169", + "sourcePacketType": "emergency_offline_signal", + "status": "pending", + "summary": "Upstream unavailable; LAN dashboard and local cache still reachable.", + "targetId": "receipt:hardware:1e92f586a767", + "ttlSeconds": 900 + } + ], + "finalityReceipts": [ + { + "finalityReceiptId": "finality:hardware:e28633303a6b", + "finalityStatus": "local-pending", + "localOnly": true, + "objectId": "receipt:hardware:1e92f586a767", + "receiptId": "receipt:hardware:1e92f586a767", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "settlement": "local-fixture", + "sourcePacketType": "compact_receipt_relay", + "status": "pending" + } + ], + "hardwareNodes": [ + { + "cacheState": "healthy", + "callsign": "FlowRouter local-alpha fixture", + "firmware": "flowrouter.poc.v0", + "flowcoreState": "online", + "id": "fr-e1e7878a2aa8", + "lastHeartbeatAt": "2026-05-13T17:00:10Z", + "linkedWorkLaneId": "CHECKPOINT_STORAGE", + "localOnly": true, + "locationHint": "local lab fixture", + "nodeId": "fr-e1e7878a2aa8", + "powerState": "mains", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "role": "router", + "sidecarState": "ready", + "sourcePacketType": "heartbeat", + "status": "verified", + "transport": "local-wifi+meshtastic-sidecar-sim", + "warnings": [] + } + ], + "hardwareSignals": [ + { + "envelopeId": "hw-env-adac8a11bc47", + "id": "hw-sig-583c993be535", + "linkedObjectIds": [ + "operator-metadata:hardware:874d3583a3b5" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-adac8a11bc47", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "operatorMetadata", + "objectId": "operator-metadata:hardware:874d3583a3b5" + } + ], + "observedAt": "2026-05-13T17:00:00Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketId": "device_manifest:42", + "sourcePacketType": "device_manifest", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:00:00Z", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketType": "device_manifest", + "status": "observed", + "summary": "Local operator metadata for optional hardware fixture ingestion.", + "transport": "local-simulator" + }, + { + "envelopeId": "hw-env-69eab6533abb", + "id": "hw-sig-cd45a2ab8b1d", + "linkedObjectIds": [ + "fr-e1e7878a2aa8" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-69eab6533abb", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "hardwareNodes", + "objectId": "fr-e1e7878a2aa8" + } + ], + "observedAt": "2026-05-13T17:00:10Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-cd45a2ab8b1d", + "signalType": "heartbeat", + "sourcePacketId": "heartbeat:1042", + "sourcePacketType": "heartbeat", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:00:10Z", + "signalId": "hw-sig-cd45a2ab8b1d", + "signalType": "heartbeat", + "sourcePacketType": "heartbeat", + "status": "observed", + "summary": "FlowRouter heartbeat and coarse node state.", + "transport": "local-simulator" + }, + { + "envelopeId": "hw-env-88c288e8803c", + "id": "hw-sig-5dd1dbdec22d", + "linkedObjectIds": [ + "receipt:hardware:1e92f586a767" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-88c288e8803c", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "workReceipts", + "objectId": "receipt:hardware:1e92f586a767" + } + ], + "observedAt": "2026-05-13T17:00:40Z", + "payloadBytesEstimate": 96, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-5dd1dbdec22d", + "signalType": "receipt_relay", + "sourcePacketId": "compact_receipt_relay:1045", + "sourcePacketType": "compact_receipt_relay", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:00:40Z", + "signalId": "hw-sig-5dd1dbdec22d", + "signalType": "receipt_relay", + "sourcePacketType": "compact_receipt_relay", + "status": "unresolved", + "summary": "Compact WorkReceipt digest relay awaiting normal reconciliation.", + "transport": "meshtastic-control-sim" + }, + { + "envelopeId": "hw-env-3c18d09ea961", + "id": "hw-sig-d7087f4f3257", + "linkedObjectIds": [ + "report:hardware:bd367723d169" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-3c18d09ea961", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "verifierReports", + "objectId": "report:hardware:bd367723d169" + } + ], + "observedAt": "2026-05-13T17:00:30Z", + "payloadBytesEstimate": 128, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-d7087f4f3257", + "signalType": "verifier_digest_relay", + "sourcePacketId": "verifier_report_digest_relay:1044", + "sourcePacketType": "verifier_report_digest_relay", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:00:30Z", + "signalId": "hw-sig-d7087f4f3257", + "signalType": "verifier_digest_relay", + "sourcePacketType": "verifier_report_digest_relay", + "status": "unresolved", + "summary": "Compact VerifierReport digest relay awaiting the full report.", + "transport": "meshtastic-control-sim" + }, + { + "envelopeId": "hw-env-2d165bbf5812", + "id": "hw-sig-2474b9971f93", + "linkedObjectIds": [ + "hw-alert-f187cbc304cb", + "challenge:hardware:5f54ef32073d" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-2d165bbf5812", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "alerts", + "objectId": "hw-alert-f187cbc304cb" + }, + { + "collection": "challenges", + "objectId": "challenge:hardware:5f54ef32073d" + } + ], + "observedAt": "2026-05-13T17:01:30Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-2474b9971f93", + "signalType": "offline_alert_challenge_input", + "sourcePacketId": "emergency_offline_signal:1049", + "sourcePacketType": "emergency_offline_signal", + "status": "pending" + }, + "receivedAt": "2026-05-13T17:01:30Z", + "signalId": "hw-sig-2474b9971f93", + "signalType": "offline_alert_challenge_input", + "sourcePacketType": "emergency_offline_signal", + "status": "pending", + "summary": "Offline alert that can seed a local challenge candidate.", + "transport": "local-simulator" + }, + { + "envelopeId": "hw-env-6b84ec9e2c77", + "id": "hw-sig-590bff4da4b1", + "linkedObjectIds": [ + "bridge-alert:hardware:8662b11ba0d4", + "hw-alert-008de71ad207" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-6b84ec9e2c77", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "bridgeAlerts", + "objectId": "bridge-alert:hardware:8662b11ba0d4" + }, + { + "collection": "alerts", + "objectId": "hw-alert-008de71ad207" + } + ], + "observedAt": "2026-05-13T17:01:35Z", + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketId": "bridge_alert:1050", + "sourcePacketType": "bridge_alert", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:01:35Z", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "summary": "Compact bridge observer alert that does not block local chain progress.", + "transport": "meshtastic-control-sim" + }, + { + "envelopeId": "hw-env-3787edd45bfa", + "id": "hw-sig-97f6fef1f868", + "linkedObjectIds": [ + "artifact:hardware:dffc8c3e46ff", + "memory:hardware:9f8165ee1a5a" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-3787edd45bfa", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "artifactCommitments", + "objectId": "artifact:hardware:dffc8c3e46ff" + }, + { + "collection": "memoryCells", + "objectId": "memory:hardware:9f8165ee1a5a" + } + ], + "observedAt": "2026-05-13T17:01:20Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-97f6fef1f868", + "signalType": "nfc_memory_cartridge_metadata", + "sourcePacketId": "nfc_memory_cartridge_metadata:42", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:01:20Z", + "signalId": "hw-sig-97f6fef1f868", + "signalType": "nfc_memory_cartridge_metadata", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed", + "summary": "NFC metadata pointer projected into artifact and memory references.", + "transport": "local-simulator" + } + ], + "memoryCells": [ + { + "artifactId": "artifact:hardware:dffc8c3e46ff", + "currentRoot": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "latestRoot": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "localOnly": true, + "memoryCellId": "memory:hardware:9f8165ee1a5a", + "receiptId": "receipt:hardware:1e92f586a767", + "resolutionState": "untrusted-metadata-only", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed", + "summary": "NFC cartridge metadata pointer projected into a local memory cell candidate.", + "updatedAt": "2026-05-13T17:01:20Z" + } + ], + "operatorMetadata": [ + { + "displayName": "Local hardware operator fixture", + "hardwareRequiredForPrivateTestnet": false, + "localOnly": true, + "metadataId": "operator-metadata:hardware:874d3583a3b5", + "metadataSource": "device_manifest", + "noSecrets": true, + "nodeId": "fr-e1e7878a2aa8", + "observedAt": "2026-05-13T17:00:00Z", + "operatorId": "operator:local:717ffb1268b0", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "radioPayloadBudgetBytes": 160, + "roles": [ + "hardware_observer", + "fixture_relay" + ], + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "device_manifest", + "transportPreferences": [ + "local-simulator", + "meshtastic-control-sim" + ] + } + ], + "verifierReports": [ + { + "localOnly": true, + "loraEligible": true, + "payloadBytesEstimate": 128, + "reasonCodes": [ + "hardware_digest_relay_only" + ], + "receiptId": "receipt:hardware:1e92f586a767", + "relayReportId": "vr-14d051ae590e", + "reportDigest": "0xb988417bf67c48bab7208ee1fe0531d66fbcb45a1be1b853b70423239c4c8870", + "reportId": "report:hardware:bd367723d169", + "resolutionState": "needs-full-report", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "verifier_report_digest_relay", + "status": "unresolved", + "subjectDigest": "0x1d5a28ff5af6335dccc9412d01d434aa58bf0870011f781bd614a5266740da3b", + "verifierId": "hardware-relay:fr-e1e7878a2aa8" + } + ], + "workReceipts": [ + { + "artifactCommitment": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "chain": "base-sepolia-sim", + "inputRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "localOnly": true, + "locatorHint": { + "blockHint": 1200012, + "logIndexHint": 3, + "txHashPrefix": "0xea723a0a09aa25b7" + }, + "loraEligible": true, + "outputRoot": "0xfbe8a762b036d20581160b7e58a3adccf61f53c653713c439f85cc7bee5246fa", + "payloadBytesEstimate": 96, + "receiptDigest": "0xfbe8a762b036d20581160b7e58a3adccf61f53c653713c439f85cc7bee5246fa", + "receiptId": "receipt:hardware:1e92f586a767", + "resolutionState": "needs-normal-network-reconciliation", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "ruleSet": "flowmemory.hardware.operator_signal.local_alpha.v0", + "sourcePacketType": "compact_receipt_relay", + "status": "unresolved", + "workerId": "hardware-node:fr-e1e7878a2aa8" + } + ] + }, + "environment": "local-devnet-fixture", + "generatedAt": "2026-05-13T17:01:40Z", + "hardwareRequiredForPrivateTestnet": false, + "ingest": { + "idFields": { + "alerts": "incidentId", + "artifactCommitments": "artifactId", + "bridgeAlerts": "bridgeAlertId", + "challenges": "challengeId", + "finalityReceipts": "finalityReceiptId", + "hardwareNodes": "nodeId", + "hardwareSignals": "signalId", + "memoryCells": "memoryCellId", + "operatorMetadata": "metadataId", + "verifierReports": "reportId", + "workReceipts": "receiptId" + }, + "localOnly": true, + "mergePolicy": "replace-by-stable-id", + "normalNetworkReconciliationRequired": true, + "stateKeys": [ + "hardwareSignals", + "operatorMetadata", + "hardwareNodes", + "workReceipts", + "verifierReports", + "bridgeAlerts", + "artifactCommitments", + "memoryCells", + "challenges", + "finalityReceipts", + "alerts" + ] + }, + "mode": "read-only-optional-merge", + "optionalSmokeRows": [ + { + "command": "python hardware/simulator/flowrouter_sim.py --smoke", + "hardwareRequired": false, + "label": "Validate optional hardware operator signal fixtures", + "requiredForChainProgress": false + } + ], + "schema": "flowmemory.hardware_control_plane_handoff.local_alpha.v0", + "sourceFixture": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "workbenchRecords": { + "artifacts": [ + { + "facts": [ + { + "label": "cartridge", + "value": "cart-0cfd3cfcb210" + }, + { + "label": "pointer", + "value": "flowmemory://cache/4549dba629bc" + }, + { + "label": "commitment", + "value": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0" + }, + { + "label": "expires", + "value": "2026-06-13T17:00:00Z" + } + ], + "id": "artifact:hardware:dffc8c3e46ff", + "kind": "NFC cartridge artifact reference", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "artifactId": "artifact:hardware:dffc8c3e46ff", + "availabilityStatus": "metadata-only", + "cartridgeId": "cart-0cfd3cfcb210", + "commitment": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "containsSecrets": false, + "expiresAt": "2026-06-13T17:00:00Z", + "label": "field-test-cache-alpha", + "localOnly": true, + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed", + "trustLevel": "untrusted-pointer", + "uriHint": "flowmemory://cache/4549dba629bc" + }, + "status": "observed", + "summary": "NFC cartridge metadata pointer; content is untrusted until commitment checks pass.", + "title": "field-test-cache-alpha" + } + ], + "bridgeAlerts": [ + { + "facts": [ + { + "label": "bridge", + "value": "bridge-3489980d98a3" + }, + { + "label": "source", + "value": "flowchain-local-alpha" + }, + { + "label": "target", + "value": "base-sepolia-sim" + }, + { + "label": "digest", + "value": "0xaa5bbd37b946a917cd73aa5394466e71c463cf28e5a1091c474139e23cfe236f" + } + ], + "id": "bridge-alert:hardware:8662b11ba0d4", + "kind": "Hardware bridge alert", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "alertCode": "LOCKBOX_OBSERVER_LAG", + "blockHint": 1200024, + "bridgeAlertId": "bridge-alert:hardware:8662b11ba0d4", + "bridgeId": "bridge-3489980d98a3", + "doesNotBlockLocalChain": true, + "eventDigest": "0xaa5bbd37b946a917cd73aa5394466e71c463cf28e5a1091c474139e23cfe236f", + "localOnly": true, + "loraEligible": true, + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "recommendedAction": "review-bridge-observer-and-do-not-block-chain", + "resolutionState": "operator-review-required", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "severity": "warning", + "sourceChain": "flowchain-local-alpha", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "subjectId": "lockbox:dfce05f88cae", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "targetChain": "base-sepolia-sim" + }, + "status": "unresolved", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "title": "LOCKBOX_OBSERVER_LAG" + } + ], + "challenges": [ + { + "facts": [ + { + "label": "target", + "value": "receipt:hardware:1e92f586a767" + }, + { + "label": "report", + "value": "report:hardware:bd367723d169" + }, + { + "label": "ttl seconds", + "value": "900" + }, + { + "label": "action", + "value": "check-upstream-and-power" + } + ], + "id": "challenge:hardware:5f54ef32073d", + "kind": "Offline alert challenge candidate", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "challengeId": "challenge:hardware:5f54ef32073d", + "doesNotExecuteRemoteAction": true, + "localOnly": true, + "openedAt": "2026-05-13T17:01:30Z", + "openedBy": "hardware-node:fr-e1e7878a2aa8", + "reason": "offline-alert-candidate", + "receiptId": "receipt:hardware:1e92f586a767", + "recommendedAction": "check-upstream-and-power", + "reportId": "report:hardware:bd367723d169", + "sourcePacketType": "emergency_offline_signal", + "status": "pending", + "summary": "Upstream unavailable; LAN dashboard and local cache still reachable.", + "targetId": "receipt:hardware:1e92f586a767", + "ttlSeconds": 900 + }, + "status": "pending", + "summary": "Upstream unavailable; LAN dashboard and local cache still reachable.", + "title": "UPSTREAM_LOSS" + } + ], + "hardwareSignals": [ + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "local-simulator" + }, + { + "label": "source packet", + "value": "device_manifest" + }, + { + "label": "linked objects", + "value": "operator-metadata:hardware:874d3583a3b5" + } + ], + "id": "hw-sig-583c993be535", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-adac8a11bc47", + "id": "hw-sig-583c993be535", + "linkedObjectIds": [ + "operator-metadata:hardware:874d3583a3b5" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-adac8a11bc47", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "operatorMetadata", + "objectId": "operator-metadata:hardware:874d3583a3b5" + } + ], + "observedAt": "2026-05-13T17:00:00Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketId": "device_manifest:42", + "sourcePacketType": "device_manifest", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:00:00Z", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketType": "device_manifest", + "status": "observed", + "summary": "Local operator metadata for optional hardware fixture ingestion.", + "transport": "local-simulator" + }, + "status": "observed", + "summary": "Local operator metadata for optional hardware fixture ingestion.", + "title": "operator_metadata" + }, + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "local-simulator" + }, + { + "label": "source packet", + "value": "heartbeat" + }, + { + "label": "linked objects", + "value": "fr-e1e7878a2aa8" + } + ], + "id": "hw-sig-cd45a2ab8b1d", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-69eab6533abb", + "id": "hw-sig-cd45a2ab8b1d", + "linkedObjectIds": [ + "fr-e1e7878a2aa8" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-69eab6533abb", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "hardwareNodes", + "objectId": "fr-e1e7878a2aa8" + } + ], + "observedAt": "2026-05-13T17:00:10Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-cd45a2ab8b1d", + "signalType": "heartbeat", + "sourcePacketId": "heartbeat:1042", + "sourcePacketType": "heartbeat", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:00:10Z", + "signalId": "hw-sig-cd45a2ab8b1d", + "signalType": "heartbeat", + "sourcePacketType": "heartbeat", + "status": "observed", + "summary": "FlowRouter heartbeat and coarse node state.", + "transport": "local-simulator" + }, + "status": "observed", + "summary": "FlowRouter heartbeat and coarse node state.", + "title": "heartbeat" + }, + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "meshtastic-control-sim" + }, + { + "label": "source packet", + "value": "compact_receipt_relay" + }, + { + "label": "linked objects", + "value": "receipt:hardware:1e92f586a767" + } + ], + "id": "hw-sig-5dd1dbdec22d", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-88c288e8803c", + "id": "hw-sig-5dd1dbdec22d", + "linkedObjectIds": [ + "receipt:hardware:1e92f586a767" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-88c288e8803c", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "workReceipts", + "objectId": "receipt:hardware:1e92f586a767" + } + ], + "observedAt": "2026-05-13T17:00:40Z", + "payloadBytesEstimate": 96, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-5dd1dbdec22d", + "signalType": "receipt_relay", + "sourcePacketId": "compact_receipt_relay:1045", + "sourcePacketType": "compact_receipt_relay", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:00:40Z", + "signalId": "hw-sig-5dd1dbdec22d", + "signalType": "receipt_relay", + "sourcePacketType": "compact_receipt_relay", + "status": "unresolved", + "summary": "Compact WorkReceipt digest relay awaiting normal reconciliation.", + "transport": "meshtastic-control-sim" + }, + "status": "unresolved", + "summary": "Compact WorkReceipt digest relay awaiting normal reconciliation.", + "title": "receipt_relay" + }, + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "meshtastic-control-sim" + }, + { + "label": "source packet", + "value": "verifier_report_digest_relay" + }, + { + "label": "linked objects", + "value": "report:hardware:bd367723d169" + } + ], + "id": "hw-sig-d7087f4f3257", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-3c18d09ea961", + "id": "hw-sig-d7087f4f3257", + "linkedObjectIds": [ + "report:hardware:bd367723d169" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-3c18d09ea961", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "verifierReports", + "objectId": "report:hardware:bd367723d169" + } + ], + "observedAt": "2026-05-13T17:00:30Z", + "payloadBytesEstimate": 128, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-d7087f4f3257", + "signalType": "verifier_digest_relay", + "sourcePacketId": "verifier_report_digest_relay:1044", + "sourcePacketType": "verifier_report_digest_relay", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:00:30Z", + "signalId": "hw-sig-d7087f4f3257", + "signalType": "verifier_digest_relay", + "sourcePacketType": "verifier_report_digest_relay", + "status": "unresolved", + "summary": "Compact VerifierReport digest relay awaiting the full report.", + "transport": "meshtastic-control-sim" + }, + "status": "unresolved", + "summary": "Compact VerifierReport digest relay awaiting the full report.", + "title": "verifier_digest_relay" + }, + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "local-simulator" + }, + { + "label": "source packet", + "value": "emergency_offline_signal" + }, + { + "label": "linked objects", + "value": "hw-alert-f187cbc304cb, challenge:hardware:5f54ef32073d" + } + ], + "id": "hw-sig-2474b9971f93", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-2d165bbf5812", + "id": "hw-sig-2474b9971f93", + "linkedObjectIds": [ + "hw-alert-f187cbc304cb", + "challenge:hardware:5f54ef32073d" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-2d165bbf5812", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "alerts", + "objectId": "hw-alert-f187cbc304cb" + }, + { + "collection": "challenges", + "objectId": "challenge:hardware:5f54ef32073d" + } + ], + "observedAt": "2026-05-13T17:01:30Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-2474b9971f93", + "signalType": "offline_alert_challenge_input", + "sourcePacketId": "emergency_offline_signal:1049", + "sourcePacketType": "emergency_offline_signal", + "status": "pending" + }, + "receivedAt": "2026-05-13T17:01:30Z", + "signalId": "hw-sig-2474b9971f93", + "signalType": "offline_alert_challenge_input", + "sourcePacketType": "emergency_offline_signal", + "status": "pending", + "summary": "Offline alert that can seed a local challenge candidate.", + "transport": "local-simulator" + }, + "status": "pending", + "summary": "Offline alert that can seed a local challenge candidate.", + "title": "offline_alert_challenge_input" + }, + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "meshtastic-control-sim" + }, + { + "label": "source packet", + "value": "bridge_alert" + }, + { + "label": "linked objects", + "value": "bridge-alert:hardware:8662b11ba0d4, hw-alert-008de71ad207" + } + ], + "id": "hw-sig-590bff4da4b1", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-6b84ec9e2c77", + "id": "hw-sig-590bff4da4b1", + "linkedObjectIds": [ + "bridge-alert:hardware:8662b11ba0d4", + "hw-alert-008de71ad207" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-6b84ec9e2c77", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "bridgeAlerts", + "objectId": "bridge-alert:hardware:8662b11ba0d4" + }, + { + "collection": "alerts", + "objectId": "hw-alert-008de71ad207" + } + ], + "observedAt": "2026-05-13T17:01:35Z", + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketId": "bridge_alert:1050", + "sourcePacketType": "bridge_alert", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:01:35Z", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "summary": "Compact bridge observer alert that does not block local chain progress.", + "transport": "meshtastic-control-sim" + }, + "status": "unresolved", + "summary": "Compact bridge observer alert that does not block local chain progress.", + "title": "bridge_alert" + }, + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "local-simulator" + }, + { + "label": "source packet", + "value": "nfc_memory_cartridge_metadata" + }, + { + "label": "linked objects", + "value": "artifact:hardware:dffc8c3e46ff, memory:hardware:9f8165ee1a5a" + } + ], + "id": "hw-sig-97f6fef1f868", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-3787edd45bfa", + "id": "hw-sig-97f6fef1f868", + "linkedObjectIds": [ + "artifact:hardware:dffc8c3e46ff", + "memory:hardware:9f8165ee1a5a" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-3787edd45bfa", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "artifactCommitments", + "objectId": "artifact:hardware:dffc8c3e46ff" + }, + { + "collection": "memoryCells", + "objectId": "memory:hardware:9f8165ee1a5a" + } + ], + "observedAt": "2026-05-13T17:01:20Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-97f6fef1f868", + "signalType": "nfc_memory_cartridge_metadata", + "sourcePacketId": "nfc_memory_cartridge_metadata:42", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:01:20Z", + "signalId": "hw-sig-97f6fef1f868", + "signalType": "nfc_memory_cartridge_metadata", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed", + "summary": "NFC metadata pointer projected into artifact and memory references.", + "transport": "local-simulator" + }, + "status": "observed", + "summary": "NFC metadata pointer projected into artifact and memory references.", + "title": "nfc_memory_cartridge_metadata" + } + ], + "memoryCells": [ + { + "facts": [ + { + "label": "rootfield", + "value": "rootfield:hardware:flowrouter-local-alpha" + }, + { + "label": "latest root", + "value": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0" + }, + { + "label": "receipt", + "value": "receipt:hardware:1e92f586a767" + }, + { + "label": "artifact", + "value": "artifact:hardware:dffc8c3e46ff" + } + ], + "id": "memory:hardware:9f8165ee1a5a", + "kind": "Hardware memory cell candidate", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "artifactId": "artifact:hardware:dffc8c3e46ff", + "currentRoot": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "latestRoot": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "localOnly": true, + "memoryCellId": "memory:hardware:9f8165ee1a5a", + "receiptId": "receipt:hardware:1e92f586a767", + "resolutionState": "untrusted-metadata-only", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "nfc_memory_cartridge_metadata", + "status": "observed", + "summary": "NFC cartridge metadata pointer projected into a local memory cell candidate.", + "updatedAt": "2026-05-13T17:01:20Z" + }, + "status": "observed", + "summary": "Projected from NFC cartridge metadata for local operator inspection.", + "title": "memory:hardware:9f8165ee1a5a" + } + ], + "operatorMetadata": [ + { + "facts": [ + { + "label": "operator", + "value": "operator:local:717ffb1268b0" + }, + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "hardware required", + "value": "false" + }, + { + "label": "payload budget", + "value": "160" + } + ], + "id": "operator-metadata:hardware:874d3583a3b5", + "kind": "Hardware operator metadata", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "displayName": "Local hardware operator fixture", + "hardwareRequiredForPrivateTestnet": false, + "localOnly": true, + "metadataId": "operator-metadata:hardware:874d3583a3b5", + "metadataSource": "device_manifest", + "noSecrets": true, + "nodeId": "fr-e1e7878a2aa8", + "observedAt": "2026-05-13T17:00:00Z", + "operatorId": "operator:local:717ffb1268b0", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "radioPayloadBudgetBytes": 160, + "roles": [ + "hardware_observer", + "fixture_relay" + ], + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "device_manifest", + "transportPreferences": [ + "local-simulator", + "meshtastic-control-sim" + ] + }, + "status": "observed", + "summary": "Local-only metadata for the optional hardware signal fixture issuer.", + "title": "Local hardware operator fixture" + } + ], + "provenance": [ + { + "facts": [ + { + "label": "packet fixture", + "value": "hardware/fixtures/flowrouter_sample_seed42.json" + }, + { + "label": "schema", + "value": "flowmemory.hardware_operator_signals.local_alpha.v0" + }, + { + "label": "seed", + "value": "42" + }, + { + "label": "hardware required", + "value": "false" + } + ], + "id": "hardware-operator-signal-fixture", + "kind": "Hardware operator signal fixture", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "sourcePaths": { + "handoffFixture": "fixtures/hardware/flowrouter_control_plane_handoff_seed42.json", + "operatorFixture": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "packetFixture": "hardware/fixtures/flowrouter_sample_seed42.json" + } + }, + "status": "verified", + "summary": "Deterministic optional hardware signal projection for control-plane/workbench import.", + "title": "fixtures/hardware/flowrouter_local_alpha_seed42.json" + } + ], + "receipts": [ + { + "facts": [ + { + "label": "rootfield", + "value": "rootfield:hardware:flowrouter-local-alpha" + }, + { + "label": "receipt digest", + "value": "0xfbe8a762b036d20581160b7e58a3adccf61f53c653713c439f85cc7bee5246fa" + }, + { + "label": "block hint", + "value": "1200012" + }, + { + "label": "tx prefix", + "value": "0xea723a0a09aa25b7" + } + ], + "id": "receipt:hardware:1e92f586a767", + "kind": "Hardware WorkReceipt relay", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "artifactCommitment": "0x7bb78aa4de0935712809a8e46e13b832ba5a761416731e51aeb02d753bbca5b0", + "chain": "base-sepolia-sim", + "inputRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "localOnly": true, + "locatorHint": { + "blockHint": 1200012, + "logIndexHint": 3, + "txHashPrefix": "0xea723a0a09aa25b7" + }, + "loraEligible": true, + "outputRoot": "0xfbe8a762b036d20581160b7e58a3adccf61f53c653713c439f85cc7bee5246fa", + "payloadBytesEstimate": 96, + "receiptDigest": "0xfbe8a762b036d20581160b7e58a3adccf61f53c653713c439f85cc7bee5246fa", + "receiptId": "receipt:hardware:1e92f586a767", + "resolutionState": "needs-normal-network-reconciliation", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "ruleSet": "flowmemory.hardware.operator_signal.local_alpha.v0", + "sourcePacketType": "compact_receipt_relay", + "status": "unresolved", + "workerId": "hardware-node:fr-e1e7878a2aa8" + }, + "status": "unresolved", + "summary": "Compact hardware receipt relay awaiting normal network reconciliation.", + "title": "receipt:hardware:1e92f586a767" + } + ], + "verifierReports": [ + { + "facts": [ + { + "label": "relay report id", + "value": "vr-14d051ae590e" + }, + { + "label": "report digest", + "value": "0xb988417bf67c48bab7208ee1fe0531d66fbcb45a1be1b853b70423239c4c8870" + }, + { + "label": "subject digest", + "value": "0x1d5a28ff5af6335dccc9412d01d434aa58bf0870011f781bd614a5266740da3b" + }, + { + "label": "result", + "value": "unresolved" + } + ], + "id": "report:hardware:bd367723d169", + "kind": "Hardware VerifierReport relay", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "localOnly": true, + "loraEligible": true, + "payloadBytesEstimate": 128, + "reasonCodes": [ + "hardware_digest_relay_only" + ], + "receiptId": "receipt:hardware:1e92f586a767", + "relayReportId": "vr-14d051ae590e", + "reportDigest": "0xb988417bf67c48bab7208ee1fe0531d66fbcb45a1be1b853b70423239c4c8870", + "reportId": "report:hardware:bd367723d169", + "resolutionState": "needs-full-report", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "verifier_report_digest_relay", + "status": "unresolved", + "subjectDigest": "0x1d5a28ff5af6335dccc9412d01d434aa58bf0870011f781bd614a5266740da3b", + "verifierId": "hardware-relay:fr-e1e7878a2aa8" + }, + "status": "unresolved", + "summary": "Compact verifier report digest relay; full report is still required.", + "title": "report:hardware:bd367723d169" + } + ] + } +} diff --git a/fixtures/hardware/flowrouter_local_alpha_seed42.json b/fixtures/hardware/flowrouter_local_alpha_seed42.json index 6ac960cd..9c271de0 100644 --- a/fixtures/hardware/flowrouter_local_alpha_seed42.json +++ b/fixtures/hardware/flowrouter_local_alpha_seed42.json @@ -24,6 +24,29 @@ "status": "unresolved", "summary": "Upstream unavailable; LAN dashboard and local cache still reachable.", "title": "UPSTREAM_LOSS" + }, + { + "id": "hw-alert-008de71ad207", + "incidentId": "hw-alert-008de71ad207", + "linkedObjectIds": [ + "bridge-alert:hardware:8662b11ba0d4" + ], + "localOnly": true, + "openedAt": "2026-05-13T17:01:35Z", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "recommendedAction": "review-bridge-observer-and-do-not-block-chain", + "severity": "warning", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "title": "LOCKBOX_OBSERVER_LAG" } ], "artifactCommitments": [ @@ -49,12 +72,44 @@ "Hardware-originated references are hints until reconciled by normal indexer, receipt, and verifier paths.", "LoRa and Meshtastic packets carry compact control signals, not artifacts, model data, media, or raw memory.", "NFC cartridge metadata is an untrusted pointer until checked against expected commitments.", - "Emergency offline signals are operator alerts or challenge inputs only; they do not execute remote actions." + "Emergency offline signals are operator alerts or challenge inputs only; they do not execute remote actions.", + "Bridge alerts are operator review hints and must not block local chain progress." ], "hardwareRequiredForPrivateTestnet": false, "localOnly": true, "normalNetworkReconciliationRequired": true }, + "bridgeAlerts": [ + { + "alertCode": "LOCKBOX_OBSERVER_LAG", + "blockHint": 1200024, + "bridgeAlertId": "bridge-alert:hardware:8662b11ba0d4", + "bridgeId": "bridge-3489980d98a3", + "doesNotBlockLocalChain": true, + "eventDigest": "0xaa5bbd37b946a917cd73aa5394466e71c463cf28e5a1091c474139e23cfe236f", + "localOnly": true, + "loraEligible": true, + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "recommendedAction": "review-bridge-observer-and-do-not-block-chain", + "resolutionState": "operator-review-required", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "severity": "warning", + "sourceChain": "flowchain-local-alpha", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "subjectId": "lockbox:dfce05f88cae", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "targetChain": "base-sepolia-sim" + } + ], "chainId": "flowmemory-local-alpha", "challenges": [ { @@ -77,19 +132,29 @@ "compatibility": { "controlPlaneStateKeys": [ "hardwareSignals", + "operatorMetadata", "hardwareNodes", "workReceipts", "verifierReports", + "bridgeAlerts", "artifactCommitments", "memoryCells", "challenges", "finalityReceipts", "alerts" ], + "flowchainFullSmokeOptionalRow": { + "command": "python hardware/simulator/flowrouter_sim.py --smoke", + "hardwareRequired": false, + "label": "Validate optional hardware operator signal fixtures", + "requiredForChainProgress": false + }, "jsonRpcBoundary": "Read-only fixture data; no submit, wallet, live indexing, or production settlement method is implied.", "workbenchSectionKeys": [ + "operatorMetadata", "receipts", "verifierReports", + "bridgeAlerts", "artifacts", "memoryCells", "challenges", @@ -142,6 +207,58 @@ } ], "hardwareSignals": [ + { + "envelopeId": "hw-env-adac8a11bc47", + "id": "hw-sig-583c993be535", + "linkedObjectIds": [ + "operator-metadata:hardware:874d3583a3b5" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-adac8a11bc47", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "operatorMetadata", + "objectId": "operator-metadata:hardware:874d3583a3b5" + } + ], + "observedAt": "2026-05-13T17:00:00Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketId": "device_manifest:42", + "sourcePacketType": "device_manifest", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:00:00Z", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketType": "device_manifest", + "status": "observed", + "summary": "Local operator metadata for optional hardware fixture ingestion.", + "transport": "local-simulator" + }, { "envelopeId": "hw-env-69eab6533abb", "id": "hw-sig-cd45a2ab8b1d", @@ -355,6 +472,63 @@ "summary": "Offline alert that can seed a local challenge candidate.", "transport": "local-simulator" }, + { + "envelopeId": "hw-env-6b84ec9e2c77", + "id": "hw-sig-590bff4da4b1", + "linkedObjectIds": [ + "bridge-alert:hardware:8662b11ba0d4", + "hw-alert-008de71ad207" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-6b84ec9e2c77", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "bridgeAlerts", + "objectId": "bridge-alert:hardware:8662b11ba0d4" + }, + { + "collection": "alerts", + "objectId": "hw-alert-008de71ad207" + } + ], + "observedAt": "2026-05-13T17:01:35Z", + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketId": "bridge_alert:1050", + "sourcePacketType": "bridge_alert", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:01:35Z", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "summary": "Compact bridge observer alert that does not block local chain progress.", + "transport": "meshtastic-control-sim" + }, { "envelopeId": "hw-env-3787edd45bfa", "id": "hw-sig-97f6fef1f868", @@ -429,7 +603,47 @@ "updatedAt": "2026-05-13T17:01:20Z" } ], + "operatorMetadata": [ + { + "displayName": "Local hardware operator fixture", + "hardwareRequiredForPrivateTestnet": false, + "localOnly": true, + "metadataId": "operator-metadata:hardware:874d3583a3b5", + "metadataSource": "device_manifest", + "noSecrets": true, + "nodeId": "fr-e1e7878a2aa8", + "observedAt": "2026-05-13T17:00:00Z", + "operatorId": "operator:local:717ffb1268b0", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "radioPayloadBudgetBytes": 160, + "roles": [ + "hardware_observer", + "fixture_relay" + ], + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "device_manifest", + "transportPreferences": [ + "local-simulator", + "meshtastic-control-sim" + ] + } + ], "packetMappings": [ + { + "flowchainSignal": "operator_metadata", + "localAlphaRole": "names the local optional hardware operator fixture issuer", + "objectCollection": "operatorMetadata", + "objectRef": "operator-metadata:hardware:874d3583a3b5", + "sourcePacketType": "device_manifest", + "trustBoundary": "local metadata only; no wallet, secret, or production operator claim" + }, { "flowchainSignal": "hardware_node_status", "localAlphaRole": "shows FlowRouter reachability and coarse device state", @@ -462,6 +676,14 @@ "sourcePacketType": "emergency_offline_signal", "trustBoundary": "local operator attention only; no public emergency-service claim" }, + { + "flowchainSignal": "bridge_observer_alert", + "localAlphaRole": "surfaces bridge-observer lag without blocking the local chain", + "objectCollection": "bridgeAlerts", + "objectRef": "bridge-alert:hardware:8662b11ba0d4", + "sourcePacketType": "bridge_alert", + "trustBoundary": "digest alert only; no production bridge readiness or settlement claim" + }, { "flowchainSignal": "artifact_memory_reference", "localAlphaRole": "connects cartridge metadata to an artifact or memory reference", @@ -473,6 +695,33 @@ ], "schema": "flowmemory.hardware_operator_signals.local_alpha.v0", "signalEnvelopes": [ + { + "envelopeId": "hw-env-adac8a11bc47", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "operatorMetadata", + "objectId": "operator-metadata:hardware:874d3583a3b5" + } + ], + "observedAt": "2026-05-13T17:00:00Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketId": "device_manifest:42", + "sourcePacketType": "device_manifest", + "status": "observed" + }, { "envelopeId": "hw-env-69eab6533abb", "localOnly": true, @@ -585,6 +834,37 @@ "sourcePacketType": "emergency_offline_signal", "status": "pending" }, + { + "envelopeId": "hw-env-6b84ec9e2c77", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "bridgeAlerts", + "objectId": "bridge-alert:hardware:8662b11ba0d4" + }, + { + "collection": "alerts", + "objectId": "hw-alert-008de71ad207" + } + ], + "observedAt": "2026-05-13T17:01:35Z", + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketId": "bridge_alert:1050", + "sourcePacketType": "bridge_alert", + "status": "unresolved" + }, { "envelopeId": "hw-env-3787edd45bfa", "localOnly": true, @@ -619,7 +899,11 @@ ], "source": "fixture", "sourcePaths": { + "handoffFixture": "fixtures/hardware/flowrouter_control_plane_handoff_seed42.json", + "handoffSchema": "schemas/flowmemory/hardware-control-plane-handoff.schema.json", "mappingDoc": "hardware/flowrouter/FLOWCHAIN_LOCAL_ALPHA_SIGNALS.md", + "negativeReport": "fixtures/hardware/flowrouter_negative_validation_seed42.json", + "negativeReportSchema": "hardware/simulator/schemas/negative_validation_report.schema.json", "operatorFixture": "fixtures/hardware/flowrouter_local_alpha_seed42.json", "operatorSchema": "hardware/simulator/schemas/flowchain_operator_signals.schema.json", "packetFixture": "hardware/fixtures/flowrouter_sample_seed42.json" @@ -719,6 +1003,70 @@ "title": "field-test-cache-alpha" } ], + "bridgeAlerts": [ + { + "facts": [ + { + "label": "bridge", + "value": "bridge-3489980d98a3" + }, + { + "label": "source", + "value": "flowchain-local-alpha" + }, + { + "label": "target", + "value": "base-sepolia-sim" + }, + { + "label": "digest", + "value": "0xaa5bbd37b946a917cd73aa5394466e71c463cf28e5a1091c474139e23cfe236f" + } + ], + "id": "bridge-alert:hardware:8662b11ba0d4", + "kind": "Hardware bridge alert", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "alertCode": "LOCKBOX_OBSERVER_LAG", + "blockHint": 1200024, + "bridgeAlertId": "bridge-alert:hardware:8662b11ba0d4", + "bridgeId": "bridge-3489980d98a3", + "doesNotBlockLocalChain": true, + "eventDigest": "0xaa5bbd37b946a917cd73aa5394466e71c463cf28e5a1091c474139e23cfe236f", + "localOnly": true, + "loraEligible": true, + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "recommendedAction": "review-bridge-observer-and-do-not-block-chain", + "resolutionState": "operator-review-required", + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "severity": "warning", + "sourceChain": "flowchain-local-alpha", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "subjectId": "lockbox:dfce05f88cae", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "targetChain": "base-sepolia-sim" + }, + "status": "unresolved", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "title": "LOCKBOX_OBSERVER_LAG" + } + ], "challenges": [ { "facts": [ @@ -771,6 +1119,91 @@ } ], "hardwareSignals": [ + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "local-simulator" + }, + { + "label": "source packet", + "value": "device_manifest" + }, + { + "label": "linked objects", + "value": "operator-metadata:hardware:874d3583a3b5" + } + ], + "id": "hw-sig-583c993be535", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-adac8a11bc47", + "id": "hw-sig-583c993be535", + "linkedObjectIds": [ + "operator-metadata:hardware:874d3583a3b5" + ], + "localOnly": true, + "loraEligible": false, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-adac8a11bc47", + "localOnly": true, + "loraEligible": false, + "objectRefs": [ + { + "collection": "operatorMetadata", + "objectId": "operator-metadata:hardware:874d3583a3b5" + } + ], + "observedAt": "2026-05-13T17:00:00Z", + "payloadBytesEstimate": 0, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketId": "device_manifest:42", + "sourcePacketType": "device_manifest", + "status": "observed" + }, + "receivedAt": "2026-05-13T17:00:00Z", + "signalId": "hw-sig-583c993be535", + "signalType": "operator_metadata", + "sourcePacketType": "device_manifest", + "status": "observed", + "summary": "Local operator metadata for optional hardware fixture ingestion.", + "transport": "local-simulator" + }, + "status": "observed", + "summary": "Local operator metadata for optional hardware fixture ingestion.", + "title": "operator_metadata" + }, { "facts": [ { @@ -1116,6 +1549,96 @@ "summary": "Offline alert that can seed a local challenge candidate.", "title": "offline_alert_challenge_input" }, + { + "facts": [ + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "transport", + "value": "meshtastic-control-sim" + }, + { + "label": "source packet", + "value": "bridge_alert" + }, + { + "label": "linked objects", + "value": "bridge-alert:hardware:8662b11ba0d4, hw-alert-008de71ad207" + } + ], + "id": "hw-sig-590bff4da4b1", + "kind": "Hardware operator signal", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "envelopeId": "hw-env-6b84ec9e2c77", + "id": "hw-sig-590bff4da4b1", + "linkedObjectIds": [ + "bridge-alert:hardware:8662b11ba0d4", + "hw-alert-008de71ad207" + ], + "localOnly": true, + "loraEligible": true, + "nodeId": "fr-e1e7878a2aa8", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "rawEnvelope": { + "envelopeId": "hw-env-6b84ec9e2c77", + "localOnly": true, + "loraEligible": true, + "objectRefs": [ + { + "collection": "bridgeAlerts", + "objectId": "bridge-alert:hardware:8662b11ba0d4" + }, + { + "collection": "alerts", + "objectId": "hw-alert-008de71ad207" + } + ], + "observedAt": "2026-05-13T17:01:35Z", + "payloadBytesEstimate": 136, + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketId": "bridge_alert:1050", + "sourcePacketType": "bridge_alert", + "status": "unresolved" + }, + "receivedAt": "2026-05-13T17:01:35Z", + "signalId": "hw-sig-590bff4da4b1", + "signalType": "bridge_alert", + "sourcePacketType": "bridge_alert", + "status": "unresolved", + "summary": "Compact bridge observer alert that does not block local chain progress.", + "transport": "meshtastic-control-sim" + }, + "status": "unresolved", + "summary": "Compact bridge observer alert that does not block local chain progress.", + "title": "bridge_alert" + }, { "facts": [ { @@ -1256,6 +1779,71 @@ "title": "memory:hardware:9f8165ee1a5a" } ], + "operatorMetadata": [ + { + "facts": [ + { + "label": "operator", + "value": "operator:local:717ffb1268b0" + }, + { + "label": "node", + "value": "fr-e1e7878a2aa8" + }, + { + "label": "hardware required", + "value": "false" + }, + { + "label": "payload budget", + "value": "160" + } + ], + "id": "operator-metadata:hardware:874d3583a3b5", + "kind": "Hardware operator metadata", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "raw": { + "displayName": "Local hardware operator fixture", + "hardwareRequiredForPrivateTestnet": false, + "localOnly": true, + "metadataId": "operator-metadata:hardware:874d3583a3b5", + "metadataSource": "device_manifest", + "noSecrets": true, + "nodeId": "fr-e1e7878a2aa8", + "observedAt": "2026-05-13T17:00:00Z", + "operatorId": "operator:local:717ffb1268b0", + "provenance": { + "capturedAt": "2026-05-13T17:01:40Z", + "chainContext": "flowchain-private-local-testnet", + "fixturePath": "fixtures/hardware/flowrouter_local_alpha_seed42.json", + "localPathHint": "hardware/fixtures/flowrouter_sample_seed42.json", + "origin": "fixture", + "subsystem": "hardware" + }, + "radioPayloadBudgetBytes": 160, + "roles": [ + "hardware_observer", + "fixture_relay" + ], + "rootfieldId": "rootfield:hardware:flowrouter-local-alpha", + "sourcePacketType": "device_manifest", + "transportPreferences": [ + "local-simulator", + "meshtastic-control-sim" + ] + }, + "status": "observed", + "summary": "Local-only metadata for the optional hardware signal fixture issuer.", + "title": "Local hardware operator fixture" + } + ], "provenance": [ { "facts": [ @@ -1288,6 +1876,7 @@ }, "raw": { "sourcePaths": { + "handoffFixture": "fixtures/hardware/flowrouter_control_plane_handoff_seed42.json", "operatorFixture": "fixtures/hardware/flowrouter_local_alpha_seed42.json", "packetFixture": "hardware/fixtures/flowrouter_sample_seed42.json" } diff --git a/fixtures/hardware/flowrouter_negative_validation_seed42.json b/fixtures/hardware/flowrouter_negative_validation_seed42.json new file mode 100644 index 00000000..aca73df5 --- /dev/null +++ b/fixtures/hardware/flowrouter_negative_validation_seed42.json @@ -0,0 +1,57 @@ +{ + "allCasesRejected": true, + "caseCount": 8, + "cases": [ + { + "actualFailure": "heartbeat: missing required key device_id", + "case": "heartbeat_missing_device_id", + "expectedFailure": "missing required key device_id", + "passed": true + }, + { + "actualFailure": "compact_receipt_relay.payload_bytes_estimate: value above maximum 200", + "case": "receipt_relay_payload_exceeds_control_budget", + "expectedFailure": "value above maximum 200", + "passed": true + }, + { + "actualFailure": "nfc_memory_cartridge_metadata.contains_secrets: value True not in enum [False]", + "case": "nfc_metadata_claims_secret_storage", + "expectedFailure": "not in enum [False]", + "passed": true + }, + { + "actualFailure": "operator_signals: missing required key bridgeAlerts", + "case": "operator_projection_missing_bridge_alerts", + "expectedFailure": "missing required key bridgeAlerts", + "passed": true + }, + { + "actualFailure": "operator_signals.boundary.hardwareRequiredForPrivateTestnet: value True not in enum [False]", + "case": "operator_projection_requires_hardware", + "expectedFailure": "not in enum [False]", + "passed": true + }, + { + "actualFailure": "operator_signals.signalEnvelopes[2].payloadBytesEstimate: value above maximum 200", + "case": "operator_envelope_payload_exceeds_control_budget", + "expectedFailure": "value above maximum 200", + "passed": true + }, + { + "actualFailure": "control_plane_handoff.hardwareRequiredForPrivateTestnet: value True not in enum [False]", + "case": "control_plane_handoff_requires_hardware", + "expectedFailure": "not in enum [False]", + "passed": true + }, + { + "actualFailure": "control_plane_handoff.collections: missing required key hardwareSignals", + "case": "control_plane_handoff_missing_hardware_signals", + "expectedFailure": "missing required key hardwareSignals", + "passed": true + } + ], + "generatedAt": "2026-05-13T17:02:10Z", + "schema": "flowmemory.hardware_negative_validation.local_alpha.v0", + "seed": 42 +} diff --git a/fixtures/launch-core/generated/devnet/control-plane-handoff.json b/fixtures/launch-core/generated/devnet/control-plane-handoff.json index f1ba9c3d..cdd079fd 100644 --- a/fixtures/launch-core/generated/devnet/control-plane-handoff.json +++ b/fixtures/launch-core/generated/devnet/control-plane-handoff.json @@ -1,7 +1,7 @@ { "blocks": [ { - "blockHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "blockHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "blockNumber": 1, "logicalTime": 1778688000, "parentHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9", @@ -83,7 +83,7 @@ } ], "schema": "flowmemory.local_devnet.block.v0", - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "txIds": [ "0x2cffda58c783dc026978b06a681587b19d9536ae4e158a69be855da1200f3189", "0x75e63a0257621b8ef7412c6455a19d848996905e21f5ba79ccb0870d6e82eb25", @@ -103,10 +103,10 @@ ] }, { - "blockHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", + "blockHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", "blockNumber": 2, "logicalTime": 1778688001, - "parentHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "parentHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "receipts": [ { "error": null, @@ -115,7 +115,7 @@ } ], "schema": "flowmemory.local_devnet.block.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "txIds": [ "0x8f719c880f17b5d4fb6d9efd54ac276d0dd8050d11c2c7870c36a79b66bc49d7" ] @@ -138,10 +138,10 @@ "schema": "flowmemory.local_devnet.config.v0" }, "latestBlock": { - "blockHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", + "blockHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", "blockNumber": 2, "logicalTime": 1778688001, - "parentHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "parentHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "receipts": [ { "error": null, @@ -150,7 +150,7 @@ } ], "schema": "flowmemory.local_devnet.block.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "txIds": [ "0x8f719c880f17b5d4fb6d9efd54ac276d0dd8050d11c2c7870c36a79b66bc49d7" ] @@ -159,10 +159,11 @@ "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", - "baseAnchorRoot": "0x0f455d919de2d313e88c276b687975249bf3ce53c9cedc27c012a85bcbf0b946", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", + "baseAnchorRoot": "0x50b8b39f15d742afe3efbffc0754fd2956464462de9677e79e00a4736ced8dba", "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "importedObservationRoot": "0x99cb1b939d5a09f800f72e4c5a2b92988571126e1f6f93549f4893b3f7de7880", "importedVerifierReportRoot": "0x6070b1015f000dd509c7b276d2ad68d8a9d188ef1a961c2f573346eb75ea5ad7", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", @@ -205,18 +206,20 @@ "uriHint": "fixture://artifact/demo/001" } }, + "balanceTransfers": {}, "baseAnchors": { - "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885": { + "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d": { "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", - "anchorId": "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885", + "anchorId": "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d", "appchainChainId": "flowmemory-local-devnet-v0", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", "blockRangeEnd": 1, "blockRangeStart": 1, "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "finalityStatus": "local-placeholder", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", "memoryCellRoot": "0x1b4e91099dd8d867201bd880437197ae6c031e538341aaa3cd2046e5706a2c25", @@ -224,7 +227,7 @@ "operatorKeyReferenceRoot": "0x8457aa3ed0f4238834a8f3925f25ccca805828d8427c3ef67590a45659b22a40", "previousAnchorId": "0x0000000000000000000000000000000000000000000000000000000000000000", "rootfieldStateRoot": "0xb72a851dca1103410484e3272945bae5e87fc39b8f32f77d2991959b60d3bfbf", - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "verifierModuleRoot": "0xd6ddd8a2d0f5812d64679656c69983a2e0aecd36bd36199d900245658ae4626c", "verifierReportRoot": "0x4facd21e55423e182eba87355482a35daa93f53190fbd3a8d2969f9d55bc5373", "workReceiptRoot": "0x8b3ef5650c9eea2f608ad9c7cb73df3c289fc0ac72ed04f46e6ae4bce0a1f023" @@ -263,7 +266,7 @@ "finalizedBy": "operator:local-demo", "receiptId": "receipt:demo:001", "rootfieldId": "rootfield:demo:alpha", - "stateRoot": "0x2cff83eaf83ea3ae2e9b248ca6ac2b32e23fa3f9ca067c4a9c93e72ef5679d33" + "stateRoot": "0x9a04c063ceb652a89bcdeba147cc36ceeecce9e8a0c6161a5d13b6363a9b5af2" } }, "localTestUnitBalances": { @@ -365,5 +368,5 @@ }, "pendingTxs": [], "schema": "flowmemory.control_plane_handoff.local_devnet.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f" + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0" } diff --git a/fixtures/launch-core/generated/devnet/dashboard-state.json b/fixtures/launch-core/generated/devnet/dashboard-state.json index df33245c..bee21bdf 100644 --- a/fixtures/launch-core/generated/devnet/dashboard-state.json +++ b/fixtures/launch-core/generated/devnet/dashboard-state.json @@ -29,18 +29,20 @@ "uriHint": "fixture://artifact/demo/001" } }, + "balanceTransfers": {}, "baseAnchors": { - "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885": { + "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d": { "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", - "anchorId": "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885", + "anchorId": "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d", "appchainChainId": "flowmemory-local-devnet-v0", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", "blockRangeEnd": 1, "blockRangeStart": 1, "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "finalityStatus": "local-placeholder", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", "memoryCellRoot": "0x1b4e91099dd8d867201bd880437197ae6c031e538341aaa3cd2046e5706a2c25", @@ -48,7 +50,7 @@ "operatorKeyReferenceRoot": "0x8457aa3ed0f4238834a8f3925f25ccca805828d8427c3ef67590a45659b22a40", "previousAnchorId": "0x0000000000000000000000000000000000000000000000000000000000000000", "rootfieldStateRoot": "0xb72a851dca1103410484e3272945bae5e87fc39b8f32f77d2991959b60d3bfbf", - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "verifierModuleRoot": "0xd6ddd8a2d0f5812d64679656c69983a2e0aecd36bd36199d900245658ae4626c", "verifierReportRoot": "0x4facd21e55423e182eba87355482a35daa93f53190fbd3a8d2969f9d55bc5373", "workReceiptRoot": "0x8b3ef5650c9eea2f608ad9c7cb73df3c289fc0ac72ed04f46e6ae4bce0a1f023" @@ -88,7 +90,7 @@ "finalizedBy": "operator:local-demo", "receiptId": "receipt:demo:001", "rootfieldId": "rootfield:demo:alpha", - "stateRoot": "0x2cff83eaf83ea3ae2e9b248ca6ac2b32e23fa3f9ca067c4a9c93e72ef5679d33" + "stateRoot": "0x9a04c063ceb652a89bcdeba147cc36ceeecce9e8a0c6161a5d13b6363a9b5af2" } }, "genesisConfig": { @@ -121,10 +123,11 @@ "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", - "baseAnchorRoot": "0x0f455d919de2d313e88c276b687975249bf3ce53c9cedc27c012a85bcbf0b946", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", + "baseAnchorRoot": "0x50b8b39f15d742afe3efbffc0754fd2956464462de9677e79e00a4736ced8dba", "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "importedObservationRoot": "0x99cb1b939d5a09f800f72e4c5a2b92988571126e1f6f93549f4893b3f7de7880", "importedVerifierReportRoot": "0x6070b1015f000dd509c7b276d2ad68d8a9d188ef1a961c2f573346eb75ea5ad7", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", @@ -190,7 +193,7 @@ } }, "schema": "flowmemory.dashboard_state.local_devnet.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "verifierModules": { "verifier:local-demo": { "active": true, diff --git a/fixtures/launch-core/generated/devnet/indexer-handoff.json b/fixtures/launch-core/generated/devnet/indexer-handoff.json index 03be138d..9863bbe6 100644 --- a/fixtures/launch-core/generated/devnet/indexer-handoff.json +++ b/fixtures/launch-core/generated/devnet/indexer-handoff.json @@ -21,9 +21,10 @@ "storageBackend": "fixture-local" } }, + "balanceTransfers": {}, "blocks": [ { - "blockHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "blockHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "blockNumber": 1, "logicalTime": 1778688000, "parentHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9", @@ -105,7 +106,7 @@ } ], "schema": "flowmemory.local_devnet.block.v0", - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "txIds": [ "0x2cffda58c783dc026978b06a681587b19d9536ae4e158a69be855da1200f3189", "0x75e63a0257621b8ef7412c6455a19d848996905e21f5ba79ccb0870d6e82eb25", @@ -125,10 +126,10 @@ ] }, { - "blockHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", + "blockHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", "blockNumber": 2, "logicalTime": 1778688001, - "parentHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "parentHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "receipts": [ { "error": null, @@ -137,7 +138,7 @@ } ], "schema": "flowmemory.local_devnet.block.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "txIds": [ "0x8f719c880f17b5d4fb6d9efd54ac276d0dd8050d11c2c7870c36a79b66bc49d7" ] @@ -176,7 +177,7 @@ "finalizedBy": "operator:local-demo", "receiptId": "receipt:demo:001", "rootfieldId": "rootfield:demo:alpha", - "stateRoot": "0x2cff83eaf83ea3ae2e9b248ca6ac2b32e23fa3f9ca067c4a9c93e72ef5679d33" + "stateRoot": "0x9a04c063ceb652a89bcdeba147cc36ceeecce9e8a0c6161a5d13b6363a9b5af2" } }, "genesisConfig": { @@ -210,10 +211,11 @@ "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", - "baseAnchorRoot": "0x0f455d919de2d313e88c276b687975249bf3ce53c9cedc27c012a85bcbf0b946", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", + "baseAnchorRoot": "0x50b8b39f15d742afe3efbffc0754fd2956464462de9677e79e00a4736ced8dba", "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "importedObservationRoot": "0x99cb1b939d5a09f800f72e4c5a2b92988571126e1f6f93549f4893b3f7de7880", "importedVerifierReportRoot": "0x6070b1015f000dd509c7b276d2ad68d8a9d188ef1a961c2f573346eb75ea5ad7", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", @@ -257,5 +259,5 @@ } }, "schema": "flowmemory.indexer_handoff.local_devnet.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f" + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0" } diff --git a/fixtures/launch-core/generated/devnet/state.json b/fixtures/launch-core/generated/devnet/state.json index ccec530d..0f42a2d3 100644 --- a/fixtures/launch-core/generated/devnet/state.json +++ b/fixtures/launch-core/generated/devnet/state.json @@ -19,7 +19,7 @@ "genesisHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9", "nextBlockNumber": 3, "logicalTime": 1778688002, - "parentHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82", + "parentHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b", "operatorKeyReferences": { "operator-key:local-devnet:alpha": { "schema": "flowmemory.local_devnet.operator_key_reference.v0", @@ -81,6 +81,7 @@ "noValue": true } }, + "balanceTransfers": {}, "modelPassports": { "model:demo:local-alpha": { "modelPassportId": "model:demo:local-alpha", @@ -127,7 +128,7 @@ "finalityStatus": "finalized", "challengeCount": 1, "finalizedAtBlock": 1, - "stateRoot": "0x2cff83eaf83ea3ae2e9b248ca6ac2b32e23fa3f9ca067c4a9c93e72ef5679d33" + "stateRoot": "0x9a04c063ceb652a89bcdeba147cc36ceeecce9e8a0c6161a5d13b6363a9b5af2" } }, "artifactCommitments": { @@ -185,12 +186,12 @@ "importedObservations": {}, "importedVerifierReports": {}, "baseAnchors": { - "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885": { - "anchorId": "0xd1112ed01fdf86d0caa079d7668a5c3e0482f6da36d8831f1e12a21bf2a77885", + "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d": { + "anchorId": "0x7dd81dfdeac346efaef81a3d62e064ab7b5ec4f00f2e2c0fd19745d876ae438d", "appchainChainId": "flowmemory-local-devnet-v0", "blockRangeStart": 1, "blockRangeEnd": 1, - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", "workReceiptRoot": "0x8b3ef5650c9eea2f608ad9c7cb73df3c289fc0ac72ed04f46e6ae4bce0a1f023", "verifierReportRoot": "0x4facd21e55423e182eba87355482a35daa93f53190fbd3a8d2969f9d55bc5373", "rootfieldStateRoot": "0xb72a851dca1103410484e3272945bae5e87fc39b8f32f77d2991959b60d3bfbf", @@ -199,10 +200,11 @@ "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", "modelPassportRoot": "0x326aa6b0b372d29d24d747fe0879adfd7aaea206373b24ae2ab77d56357e9529", "memoryCellRoot": "0x1b4e91099dd8d867201bd880437197ae6c031e538341aaa3cd2046e5706a2c25", "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "verifierModuleRoot": "0xd6ddd8a2d0f5812d64679656c69983a2e0aecd36bd36199d900245658ae4626c", "previousAnchorId": "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -309,13 +311,13 @@ "error": null } ], - "stateRoot": "0xd4bf806a2f91cd8255b2c55db91cb59c9f941d9ec92614dcb86dbd926184630c", - "blockHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56" + "stateRoot": "0x01313a55fdb9736570aa707b5ebf030fc1bf4212df721f7bb17612ad5de2502a", + "blockHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c" }, { "schema": "flowmemory.local_devnet.block.v0", "blockNumber": 2, - "parentHash": "0x78c6b0c6b56eae99d2d693878a2239620c713cff50fcb94ba6df3fc6ed08be56", + "parentHash": "0xb2251f1db0005e7e665f849a1f0bc2de35f65b263cb4d7aaa870f73f9551eb5c", "logicalTime": 1778688001, "txIds": [ "0x8f719c880f17b5d4fb6d9efd54ac276d0dd8050d11c2c7870c36a79b66bc49d7" @@ -327,8 +329,8 @@ "error": null } ], - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", - "blockHash": "0xeca4065a019501355c54c1d7ecc4859e4be6355c9ccaa2ce7188822bebc88c82" + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", + "blockHash": "0x6fc3297759b76c4c907d7590525390ae77af11a1db76c6383fb016a289e2600b" } ], "pendingTxs": [] diff --git a/fixtures/launch-core/generated/devnet/verifier-handoff.json b/fixtures/launch-core/generated/devnet/verifier-handoff.json index 5020bb7a..4da2c721 100644 --- a/fixtures/launch-core/generated/devnet/verifier-handoff.json +++ b/fixtures/launch-core/generated/devnet/verifier-handoff.json @@ -11,6 +11,7 @@ "storageBackend": "fixture-local" } }, + "balanceTransfers": {}, "challenges": { "challenge:demo:001": { "challengeId": "challenge:demo:001", @@ -44,7 +45,7 @@ "finalizedBy": "operator:local-demo", "receiptId": "receipt:demo:001", "rootfieldId": "rootfield:demo:alpha", - "stateRoot": "0x2cff83eaf83ea3ae2e9b248ca6ac2b32e23fa3f9ca067c4a9c93e72ef5679d33" + "stateRoot": "0x9a04c063ceb652a89bcdeba147cc36ceeecce9e8a0c6161a5d13b6363a9b5af2" } }, "genesisConfig": { @@ -78,10 +79,11 @@ "agentAccountRoot": "0xcf31230bfff347f79e19a55f4d1ff5fa486b0b1ad4754ce22b93de4b259a3ca7", "artifactAvailabilityProofRoot": "0xfb4b693c45014aae0947f35696e9d864e7b26ac6fd39c1df5edb3e0dcf9bd928", "artifactCommitmentRoot": "0xb772a9f7273032fd3ba2da8b6476d4715bbbafbd2a7eed21ecd0d558bde3beab", - "baseAnchorRoot": "0x0f455d919de2d313e88c276b687975249bf3ce53c9cedc27c012a85bcbf0b946", + "balanceTransferRoot": "0x9b6e249f769a93bc9f34a90156e028d1a830badcd8ccdc5b1487d512cdbf0a6d", + "baseAnchorRoot": "0x50b8b39f15d742afe3efbffc0754fd2956464462de9677e79e00a4736ced8dba", "challengeRoot": "0x16da3d2bf2dcd801bc5deb3987dc01342cb957031ad01408ea77bf5d1583656f", "faucetRecordRoot": "0x2277503a52fab3f9e49b40debfb7d641abee75cf268aa56da403fdcf4fad6cee", - "finalityReceiptRoot": "0x990b60fd5f91eb725b65d36a1324e00be255daaa4bc0fbbe163343c3934b120a", + "finalityReceiptRoot": "0xb2d8234d12f255669267722c21a3841257ca49f304863a7145e7e708a47b3132", "importedObservationRoot": "0x99cb1b939d5a09f800f72e4c5a2b92988571126e1f6f93549f4893b3f7de7880", "importedVerifierReportRoot": "0x6070b1015f000dd509c7b276d2ad68d8a9d188ef1a961c2f573346eb75ea5ad7", "localTestUnitBalanceRoot": "0x167041ef195b5dde2d2cade6ecb26c9a0a596e9ed21ff7bfb02d33c9d2be8d15", @@ -111,7 +113,7 @@ } }, "schema": "flowmemory.verifier_handoff.local_devnet.v0", - "stateRoot": "0x55cab7c41a999da527bdd026a772edb5e4804b070014cccc72622e09ce3e699f", + "stateRoot": "0x00ab998a4d1f28200177680699c941b06ee536c0b70a1b35e20849de241740c0", "verifierModules": { "verifier:local-demo": { "active": true, diff --git a/hardware/README.md b/hardware/README.md index eb561f44..ce166b11 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -29,6 +29,7 @@ FlowRouter V0 is a local FlowMemory gateway POC. It can model or test: - FlowCore light-pipe status. - Enclosure measurement direction. - FlowChain local-alpha operator signals derived from hardware packets, including optional control-plane/workbench fixture collections. +- Control-plane handoff JSON for optional hardware signals, including heartbeat, receipt relay, verifier digest relay, offline alert, bridge alert, NFC metadata, and operator metadata. ## V0 Non-Goals @@ -48,17 +49,19 @@ FlowRouter V0 does not: Generate and validate deterministic simulator output: ```powershell -python hardware/simulator/flowrouter_sim.py --seed 42 --out hardware/fixtures/flowrouter_sample_seed42.json -python hardware/simulator/flowrouter_sim.py --validate-file hardware/fixtures/flowrouter_sample_seed42.json +python hardware/simulator/flowrouter_sim.py --generate-fixtures --seed 42 +python hardware/simulator/flowrouter_sim.py --smoke --seed 42 ``` -Generate and validate the local-alpha FlowChain operator-signal projection: +Validate individual generated files: ```powershell -python hardware/simulator/flowrouter_sim.py --seed 42 --out hardware/fixtures/flowrouter_sample_seed42.json --operator-out fixtures/hardware/flowrouter_local_alpha_seed42.json +python hardware/simulator/flowrouter_sim.py --validate-file hardware/fixtures/flowrouter_sample_seed42.json python hardware/simulator/flowrouter_sim.py --validate-operator-file fixtures/hardware/flowrouter_local_alpha_seed42.json +python hardware/simulator/flowrouter_sim.py --validate-handoff-file fixtures/hardware/flowrouter_control_plane_handoff_seed42.json +python hardware/simulator/flowrouter_sim.py --validate-negative-report-file fixtures/hardware/flowrouter_negative_validation_seed42.json ``` -The operator projection emits `flowmemory.hardware_operator_signals.local_alpha.v0`, with local-only `signalEnvelopes`, a direct `hardwareSignals` view, control-plane-style collections, and `workbenchRecords`. Hardware remains optional for the private/local testnet path. +The operator projection emits `flowmemory.hardware_operator_signals.local_alpha.v0`, with local-only `signalEnvelopes`, a direct `hardwareSignals` view, control-plane-style collections, and `workbenchRecords`. The handoff fixture emits `flowmemory.hardware_control_plane_handoff.local_alpha.v0` and is shaped for read-only optional control-plane ingestion. Hardware remains optional for the private/local testnet path. The simulator uses only the Python standard library. diff --git a/hardware/fixtures/flowrouter_sample_seed42.json b/hardware/fixtures/flowrouter_sample_seed42.json index 0e44f216..a3badedc 100644 --- a/hardware/fixtures/flowrouter_sample_seed42.json +++ b/hardware/fixtures/flowrouter_sample_seed42.json @@ -1,6 +1,26 @@ { "generated_at": "2026-05-13T17:02:00Z", "packets": { + "bridge_alert": { + "alert_code": "LOCKBOX_OBSERVER_LAG", + "block_hint": 1200024, + "bridge_id": "bridge-3489980d98a3", + "device_id": "fr-e1e7878a2aa8", + "digest": "aa5bbd37b946a917cd73aa5394466e71c463cf28e5a1091c474139e23cfe236f", + "emitted_at": "2026-05-13T17:01:35Z", + "lora_eligible": true, + "operator_action": "review-bridge-observer-and-do-not-block-chain", + "packet_type": "bridge_alert", + "payload_bytes_estimate": 136, + "schema_version": "flowrouter.poc.v0", + "sequence": 1050, + "severity": "warning", + "source_chain": "flowchain-local-alpha", + "subject_id": "lockbox:dfce05f88cae", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "target_chain": "base-sepolia-sim", + "verification_state": "advisory" + }, "compact_receipt_relay": { "block_hint": 1200012, "chain": "base-sepolia-sim", @@ -53,6 +73,9 @@ "heartbeat", "gateway_discovery", "compact_receipt_relay", + "verifier_report_digest_relay", + "bridge_alert", + "operator_metadata", "local_cache_status", "sidecar_status", "dashboard_feed" diff --git a/hardware/flowrouter/FLOWCHAIN_LOCAL_ALPHA_SIGNALS.md b/hardware/flowrouter/FLOWCHAIN_LOCAL_ALPHA_SIGNALS.md index c894ae3e..070bbd24 100644 --- a/hardware/flowrouter/FLOWCHAIN_LOCAL_ALPHA_SIGNALS.md +++ b/hardware/flowrouter/FLOWCHAIN_LOCAL_ALPHA_SIGNALS.md @@ -20,6 +20,12 @@ The FlowChain/workbench projection is: fixtures/hardware/flowrouter_local_alpha_seed42.json ``` +The read-only control-plane handoff is: + +```text +fixtures/hardware/flowrouter_control_plane_handoff_seed42.json +``` + The projection is generated by `hardware/simulator/flowrouter_sim.py` from the same seed as the raw packet fixture. It is local-only, advisory, and schema-validated by: ```text @@ -32,9 +38,9 @@ The projection uses the local private-testnet fixture style where applicable: - top-level `schema`, `generatedAt`, `chainId`, `environment`, and `source`; - `signalEnvelopes` with stable local signal ids, source packet references, object references, status, provenance, and payload-size hints; -- `hardwareSignals` as direct workbench/control-plane hardware signal rows for the same five envelopes; -- control-plane-friendly collections: `hardwareNodes`, `workReceipts`, `verifierReports`, `artifactCommitments`, `memoryCells`, `challenges`, `finalityReceipts`, and `alerts`; -- `workbenchRecords` grouped by `receipts`, `verifierReports`, `artifacts`, `memoryCells`, `challenges`, `hardwareSignals`, and `provenance`; +- `hardwareSignals` as direct workbench/control-plane hardware signal rows for the same envelopes; +- control-plane-friendly collections: `operatorMetadata`, `hardwareNodes`, `workReceipts`, `verifierReports`, `bridgeAlerts`, `artifactCommitments`, `memoryCells`, `challenges`, `finalityReceipts`, and `alerts`; +- `workbenchRecords` grouped by `operatorMetadata`, `receipts`, `verifierReports`, `bridgeAlerts`, `artifacts`, `memoryCells`, `challenges`, `hardwareSignals`, and `provenance`; - `boundary.hardwareRequiredForPrivateTestnet = false`. This is an optional fixture projection. It is not a devnet transaction source and it does not make hardware required for the private/local chain, indexer, verifier, control-plane, or workbench to run. @@ -43,10 +49,12 @@ This is an optional fixture projection. It is not a devnet transaction source an | FlowRouter packet | Projection collection | Why it is useful | Boundary | | --- | --- | --- | --- | +| `device_manifest` | `operatorMetadata` | Names the local optional hardware fixture issuer and radio/control-plane constraints. | Local metadata only; not a wallet, key, or production operator claim. | | `heartbeat` | `hardwareNodes` | Shows FlowRouter reachability, power, cache, sidecar, and FlowCore state to an operator. | Local advisory status only; not hardware attestation. | | `compact_receipt_relay` | `workReceipts` and `finalityReceipts` | Gives the workbench a compact WorkReceipt candidate using receipt digest plus block, tx-prefix, and log-index hints. | Requires normal network/indexer reconciliation before it can be trusted. | | `verifier_report_digest_relay` | `verifierReports` | Gives the workbench a VerifierReport candidate using report id, report digest, subject digest, and result. | Digest relay is not the full verifier report. | | `emergency_offline_signal` | `alerts` and `challenges` | Creates a local operator alert and optional challenge input when upstream or local conditions degrade. | Candidate input only; it does not execute remote commands or claim public emergency-service reliability. | +| `bridge_alert` | `bridgeAlerts` and `alerts` | Surfaces compact bridge-observer lag or mismatch signals for operator review. | Digest alert only; it never blocks local chain progress or claims production bridge readiness. | | `nfc_memory_cartridge_metadata` | `artifactCommitments` and `memoryCells` | Lets a cartridge label, pointer, digest, and expiration become an artifact/memory reference for operator workflows. | Untrusted metadata pointer only; it is not a secret store or proof. | ## Control-Plane And Workbench Consumption @@ -54,7 +62,10 @@ This is an optional fixture projection. It is not a devnet transaction source an The fixture is intentionally shaped so another agent can add it to the local control-plane without creating a second hardware pipeline: - `compatibility.controlPlaneStateKeys` names the collections a local read API can expose or merge. +- `fixtures/hardware/flowrouter_control_plane_handoff_seed42.json` mirrors those state keys under `collections` with stable id fields and read-only merge policy. - `workReceipts`, `verifierReports`, `artifactCommitments`, `memoryCells`, `challenges`, and `finalityReceipts` use the same object names the private/local workbench already scans for in fixture state. +- `bridgeAlerts` is present as a compact operator alert stream for bridge observer issues; it does not submit, settle, or pause local chain activity. +- `operatorMetadata` is local fixture metadata only and contains no secrets. - `hardwareSignals` is present as a direct collection for workbench hardware-signal sections that do not want to re-project `signalEnvelopes`. - `workbenchRecords` provides ready-to-render records when a UI chooses not to re-project the raw collections. - Every projected object keeps `sourcePacketType`, `localOnly`, and provenance back to `hardware/fixtures/flowrouter_sample_seed42.json`. @@ -65,6 +76,7 @@ The receipt, report, memory cell, challenge, and finality objects are hints. The - Operator-visible status for a FlowRouter-like node. - Compact receipt and verifier-report breadcrumbs during degraded connectivity. +- Compact bridge observer alert breadcrumbs during local review. - A local alert signal that can seed dashboard/operator attention. - A physical metadata path for memory/artifact references through NFC cartridge reads. - A low-bandwidth control-signal fixture that workbench, dashboard, or control-plane code can import. @@ -73,6 +85,7 @@ The receipt, report, memory cell, challenge, and finality objects are hints. The - Device status and packet timing. - Receipt and verifier digest relays until reconciled against normal receipt, indexer, and verifier data. +- Bridge alert digests until reconciled by normal bridge observer and operator workflows. - NFC labels, pointers, and cartridge ids until checked against expected commitments. - Emergency/offline alert state until a local operator or later challenge workflow acts on it. - Any simulated radio, cache, display, FlowCore, and cartridge state. @@ -92,10 +105,16 @@ The receipt, report, memory cell, challenge, and finality objects are hints. The Generate the raw packet fixture and the local-alpha projection: ```powershell -python hardware/simulator/flowrouter_sim.py --seed 42 --out hardware/fixtures/flowrouter_sample_seed42.json --operator-out fixtures/hardware/flowrouter_local_alpha_seed42.json +python hardware/simulator/flowrouter_sim.py --generate-fixtures --seed 42 ``` -This command is the simulator smoke path because the generator validates both outputs before writing them. +The generator validates the raw packet fixture, operator projection, handoff fixture, and negative validation report before writing them. + +Run the stricter simulator smoke path: + +```powershell +python hardware/simulator/flowrouter_sim.py --smoke --seed 42 +``` Validate the raw simulator packet fixture: @@ -109,12 +128,21 @@ Validate the FlowChain local-alpha projection: python hardware/simulator/flowrouter_sim.py --validate-operator-file fixtures/hardware/flowrouter_local_alpha_seed42.json ``` +Validate the FlowChain control-plane handoff and negative validation report: + +```powershell +python hardware/simulator/flowrouter_sim.py --validate-handoff-file fixtures/hardware/flowrouter_control_plane_handoff_seed42.json +python hardware/simulator/flowrouter_sim.py --validate-negative-report-file fixtures/hardware/flowrouter_negative_validation_seed42.json +``` + ## Integration Notes - A dashboard, workbench, or control-plane can read `packetMappings` to understand which packet produced each local-alpha object. - `hardwareSignals` can feed workbench hardware-signal tables directly. +- `operatorMetadata` can seed local fixture issuer rows. - `hardwareNodes` can feed hardware node cards or operator status rows. - `workReceipts` and `verifierReports` are breadcrumbs for later reconciliation; they are not final evidence. +- `bridgeAlerts` can seed local bridge observer alert rows without blocking the local chain. - `alerts` and `challenges` can seed local operator attention without blocking the rest of the local flow. - `artifactCommitments` and `memoryCells` can seed artifact or memory-reference panels while keeping cartridge contents untrusted. - `workbenchRecords` can be rendered directly by a fixture-backed workbench, or ignored if the workbench re-projects the canonical collections itself. diff --git a/hardware/flowrouter/README.md b/hardware/flowrouter/README.md index a86565cf..74b9cf9d 100644 --- a/hardware/flowrouter/README.md +++ b/hardware/flowrouter/README.md @@ -58,6 +58,6 @@ V0 uses certified router/radio hardware and off-the-shelf compute. It does not i - A local operator can tell whether the node has upstream internet, LAN availability, cache health, power/thermal status, and sidecar status. - A second node can receive compact Meshtastic status or digest messages during degraded IP connectivity. - Cached state is clearly marked local-only until verified through normal network, indexer, or chain-derived paths. -- Hardware packets can be projected into local-alpha `hardwareSignals`, `hardwareNodes`, `workReceipts`, `verifierReports`, `alerts`, `challenges`, `artifactCommitments`, and `memoryCells` without blocking the main local chain flow. +- Hardware packets can be projected into local-alpha `hardwareSignals`, `operatorMetadata`, `hardwareNodes`, `workReceipts`, `verifierReports`, `bridgeAlerts`, `alerts`, `challenges`, `artifactCommitments`, and `memoryCells` without blocking the main local chain flow. - The prototype can be measured for thermal, power, serviceability, and enclosure-fit constraints. - The docs make it difficult to overclaim bandwidth, production readiness, trustlessness, or regulatory status. diff --git a/hardware/lora-sidecar/CONTROL_MESSAGE_INVENTORY.md b/hardware/lora-sidecar/CONTROL_MESSAGE_INVENTORY.md index 26ebf095..7ecb7481 100644 --- a/hardware/lora-sidecar/CONTROL_MESSAGE_INVENTORY.md +++ b/hardware/lora-sidecar/CONTROL_MESSAGE_INVENTORY.md @@ -29,6 +29,7 @@ Common fields: | FlowPulse digest | Compact digest | `v,type,node,seq,chain,from,to,digest32,count,flags,auth` | Sends a hash over a FlowPulse range, not events. | Digest cannot be trusted until verified by indexer/receipts. | | Artifact availability digest | Compact digest | `v,type,node,seq,namespace,digest32,count,bytes_class,ttl,auth` | Announces cache hints only; no artifacts. | May leak inventory metadata; needs privacy review. | | Compact receipt reference | Compact receipt reference | `v,type,node,seq,chain,block_hint,tx_hash_prefix/log_hint,receipt_hash,auth` | Carries short pointer and hash, not receipt body. | Prefix collisions and stale hints; full verification requires normal network path. | +| Bridge alert digest | Compact bridge alert | `v,type,node,seq,bridge,src,dst,code,digest32,block_hint,auth` | Sends a bridge-observer alert code and digest, not bridge payloads or settlement state. | Advisory only; must not block local chain progress or imply production bridge readiness. | | Field diagnostic | Field diagnostic | `v,type,node,seq,temp,power_class,rssi,snr,loss,flags,auth` | Numeric summary only. | Sensor accuracy, spoofing, and replay risk. | | Emergency/local signal | Emergency signal | `v,type,node,seq,code,priority,ttl,location_hint?,auth` | Short code and optional coarse hint. | Abuse risk; no public emergency-service claim. | | Operator command warning | Operator command | `v,type,node,seq,command_id,intent,ttl,auth` | Intent marker only; no scripts or payloads. | Must not execute privileged action in v0; needs strong auth, authorization, replay protection, and audit before any future action. | @@ -55,6 +56,10 @@ Use to say "this cache may have content matching this digest or namespace." It m Use as a breadcrumb for later reconciliation. It can include a chain id, block hint, short transaction/log hint, and receipt hash. It is not final proof by itself. +### Bridge Alert Digest + +Use as a compact local operator warning when a bridge observer reports lag, mismatch, or stale relay state. It is a review hint only; the local private chain keeps running and normal bridge observer workflows must reconcile the digest. + ### Field Diagnostic Use for temperature, power class, RSSI/SNR, message loss, and degraded-state flags. It helps correlate field notes after the test. diff --git a/hardware/simulator/README.md b/hardware/simulator/README.md index ed1be3db..740dbaca 100644 --- a/hardware/simulator/README.md +++ b/hardware/simulator/README.md @@ -10,13 +10,33 @@ The simulator emits deterministic FlowRouter V0 proof-of-concept packets for das python hardware/simulator/flowrouter_sim.py --seed 42 --out hardware/fixtures/flowrouter_sample_seed42.json ``` -## Generate Local-Alpha Operator Signals +## Generate Canonical Fixtures ```powershell -python hardware/simulator/flowrouter_sim.py --seed 42 --out hardware/fixtures/flowrouter_sample_seed42.json --operator-out fixtures/hardware/flowrouter_local_alpha_seed42.json +python hardware/simulator/flowrouter_sim.py --generate-fixtures --seed 42 ``` -This is also the simulator smoke command: generation validates the raw packet fixture and the local-alpha operator projection before writing either output file. +This writes the raw packet fixture, the local-alpha operator projection, the control-plane handoff fixture, and the negative validation report. + +The PowerShell wrapper is: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File hardware/simulator/flowrouter-generate-fixtures.ps1 +``` + +## Simulator Smoke + +```powershell +python hardware/simulator/flowrouter_sim.py --smoke --seed 42 +``` + +The smoke command validates all canonical fixtures, compares them against deterministic regenerated output, and runs negative validation cases. + +The PowerShell wrapper is: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File hardware/simulator/flowrouter-smoke.ps1 +``` ## Validate Existing Fixture @@ -30,6 +50,19 @@ python hardware/simulator/flowrouter_sim.py --validate-file hardware/fixtures/fl python hardware/simulator/flowrouter_sim.py --validate-operator-file fixtures/hardware/flowrouter_local_alpha_seed42.json ``` +## Validate Control-Plane Handoff + +```powershell +python hardware/simulator/flowrouter_sim.py --validate-handoff-file fixtures/hardware/flowrouter_control_plane_handoff_seed42.json +``` + +## Validate Negative Cases + +```powershell +python hardware/simulator/flowrouter_sim.py --run-negative-cases --seed 42 +python hardware/simulator/flowrouter_sim.py --validate-negative-report-file fixtures/hardware/flowrouter_negative_validation_seed42.json +``` + ## Packet Types - Device manifest @@ -37,6 +70,7 @@ python hardware/simulator/flowrouter_sim.py --validate-operator-file fixtures/ha - FlowPulse digest relay - Verifier report digest relay - Compact receipt relay +- Bridge alert - Local cache status - Gateway discovery - Sidecar status @@ -49,10 +83,12 @@ The packets are JSON versions of compact, binary-inspired fields. They are not p The local-alpha projection is a `flowmemory.hardware_operator_signals.local_alpha.v0` document. It uses camelCase, a top-level `schema`, local-only `signalEnvelopes`, a direct `hardwareSignals` view, and workbench/control-plane-ready collections: +- `device_manifest` -> `operatorMetadata` - `heartbeat` -> `hardwareNodes` - `compact_receipt_relay` -> `workReceipts` - `verifier_report_digest_relay` -> `verifierReports` - `emergency_offline_signal` -> `alerts` and `challenges` +- `bridge_alert` -> `bridgeAlerts` and `alerts` - `nfc_memory_cartridge_metadata` -> `artifactCommitments` and `memoryCells` -It also includes `workbenchRecords` grouped by `receipts`, `verifierReports`, `artifacts`, `memoryCells`, `challenges`, `hardwareSignals`, and `provenance`. These projection objects are local-only and advisory until reconciled through normal FlowMemory indexer, receipt, verifier, or operator workflows. +It also includes `workbenchRecords` grouped by `operatorMetadata`, `receipts`, `verifierReports`, `bridgeAlerts`, `artifacts`, `memoryCells`, `challenges`, `hardwareSignals`, and `provenance`. The companion `flowmemory.hardware_control_plane_handoff.local_alpha.v0` fixture carries the same state keys under `collections` plus an optional `flowchain:full-smoke` row that runs `python hardware/simulator/flowrouter_sim.py --smoke`. These projection objects are local-only and advisory until reconciled through normal FlowMemory indexer, receipt, verifier, or operator workflows. diff --git a/hardware/simulator/flowrouter-generate-fixtures.ps1 b/hardware/simulator/flowrouter-generate-fixtures.ps1 new file mode 100644 index 00000000..ed88406d --- /dev/null +++ b/hardware/simulator/flowrouter-generate-fixtures.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\..") +Push-Location -LiteralPath $repoRoot +try { + python hardware/simulator/flowrouter_sim.py --generate-fixtures --seed 42 +} +finally { + Pop-Location +} diff --git a/hardware/simulator/flowrouter-smoke.ps1 b/hardware/simulator/flowrouter-smoke.ps1 new file mode 100644 index 00000000..e0ad4d4e --- /dev/null +++ b/hardware/simulator/flowrouter-smoke.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\..") +Push-Location -LiteralPath $repoRoot +try { + python hardware/simulator/flowrouter_sim.py --smoke --seed 42 +} +finally { + Pop-Location +} diff --git a/hardware/simulator/flowrouter_sim.py b/hardware/simulator/flowrouter_sim.py index 643bacf9..0f7bfe4c 100644 --- a/hardware/simulator/flowrouter_sim.py +++ b/hardware/simulator/flowrouter_sim.py @@ -11,6 +11,7 @@ from typing import Any +DEFAULT_SEED = 42 SCHEMA_FILES = { "device_manifest": "device_manifest.schema.json", "heartbeat": "heartbeat.schema.json", @@ -22,10 +23,12 @@ "sidecar_status": "sidecar_status.schema.json", "nfc_memory_cartridge_metadata": "nfc_memory_cartridge_metadata.schema.json", "emergency_offline_signal": "emergency_offline_signal.schema.json", + "bridge_alert": "bridge_alert.schema.json", "dashboard_feed": "dashboard_feed.schema.json", } OPERATOR_SIGNALS_SCHEMA_FILE = "flowchain_operator_signals.schema.json" +NEGATIVE_REPORT_SCHEMA_FILE = "negative_validation_report.schema.json" ZERO_HASH = "0x0000000000000000000000000000000000000000000000000000000000000000" HARDWARE_ROOTFIELD_ID = "rootfield:hardware:flowrouter-local-alpha" HARDWARE_CHAIN_CONTEXT = "flowchain-private-local-testnet" @@ -51,6 +54,26 @@ def iso_tick(offset_seconds: int) -> str: return f"2026-05-13T{base_hour:02d}:{minute:02d}:{second:02d}Z" +def default_raw_fixture_path(seed: int) -> Path: + return Path(f"hardware/fixtures/flowrouter_sample_seed{seed}.json") + + +def default_operator_fixture_path(seed: int) -> Path: + return Path(f"fixtures/hardware/flowrouter_local_alpha_seed{seed}.json") + + +def default_handoff_fixture_path(seed: int) -> Path: + return Path(f"fixtures/hardware/flowrouter_control_plane_handoff_seed{seed}.json") + + +def default_negative_report_path(seed: int) -> Path: + return Path(f"fixtures/hardware/flowrouter_negative_validation_seed{seed}.json") + + +def clone_json(value: Any) -> Any: + return json.loads(json.dumps(value, sort_keys=True)) + + def build_packets(seed: int) -> dict[str, Any]: device_id = f"fr-{short_id(seed, 'device')}" gateway_id = f"gw-{short_id(seed, 'gateway')}" @@ -58,6 +81,7 @@ def build_packets(seed: int) -> dict[str, Any]: receipt_digest = digest(seed, "receipt") flowpulse_digest = digest(seed, "flowpulse") verifier_digest = digest(seed, "verifier") + bridge_digest = digest(seed, "bridge-alert") cache_digest = digest(seed, "cache") manifest = { @@ -79,6 +103,9 @@ def build_packets(seed: int) -> dict[str, Any]: "heartbeat", "gateway_discovery", "compact_receipt_relay", + "verifier_report_digest_relay", + "bridge_alert", + "operator_metadata", "local_cache_status", "sidecar_status", "dashboard_feed", @@ -227,6 +254,27 @@ def build_packets(seed: int) -> dict[str, Any]: "operator_action": "check-upstream-and-power", } + bridge_alert = { + "packet_type": "bridge_alert", + "schema_version": "flowrouter.poc.v0", + "device_id": device_id, + "sequence": 1008 + seed, + "emitted_at": iso_tick(95), + "bridge_id": f"bridge-{short_id(seed, 'bridge')}", + "source_chain": "flowchain-local-alpha", + "target_chain": "base-sepolia-sim", + "alert_code": "LOCKBOX_OBSERVER_LAG", + "severity": "warning", + "subject_id": f"lockbox:{short_id(seed, 'bridge-subject')}", + "digest": bridge_digest, + "block_hint": 1200024, + "payload_bytes_estimate": 136, + "lora_eligible": True, + "verification_state": "advisory", + "summary": "Bridge observer digest lag detected; local chain continues while operator reviews.", + "operator_action": "review-bridge-observer-and-do-not-block-chain", + } + dashboard_feed = { "packet_type": "dashboard_feed", "schema_version": "flowrouter.poc.v0", @@ -267,28 +315,37 @@ def build_packets(seed: int) -> dict[str, Any]: "sidecar_status": sidecar_status, "nfc_memory_cartridge_metadata": cartridge, "emergency_offline_signal": emergency, + "bridge_alert": bridge_alert, "dashboard_feed": dashboard_feed, } def build_operator_signals(seed: int, packets: dict[str, Any] | None = None) -> dict[str, Any]: packet_set = packets if packets is not None else build_packets(seed) + manifest = packet_set["device_manifest"] heartbeat = packet_set["heartbeat"] receipt = packet_set["compact_receipt_relay"] verifier = packet_set["verifier_report_digest_relay"] emergency = packet_set["emergency_offline_signal"] + bridge = packet_set["bridge_alert"] cartridge = packet_set["nfc_memory_cartridge_metadata"] + sidecar = packet_set["sidecar_status"] dashboard = packet_set["dashboard_feed"] device_id = heartbeat["device_id"] generated_at = dashboard["generated_at"] packet_fixture_path = f"hardware/fixtures/flowrouter_sample_seed{seed}.json" operator_fixture_path = f"fixtures/hardware/flowrouter_local_alpha_seed{seed}.json" + handoff_fixture_path = f"fixtures/hardware/flowrouter_control_plane_handoff_seed{seed}.json" + negative_report_path = f"fixtures/hardware/flowrouter_negative_validation_seed{seed}.json" + operator_metadata_id = f"operator-metadata:hardware:{short_id(seed, 'operator-metadata')}" worker_id = f"hardware-node:{device_id}" verifier_id = f"hardware-relay:{device_id}" receipt_id = f"receipt:hardware:{short_id(seed, 'work-receipt-ref')}" verifier_report_id = f"report:hardware:{short_id(seed, 'verifier-report-ref')}" alert_id = f"hw-alert-{short_id(seed, 'offline-alert')}" + bridge_alert_id = f"bridge-alert:hardware:{short_id(seed, 'bridge-alert-ref')}" + bridge_incident_id = f"hw-alert-{short_id(seed, 'bridge-incident')}" challenge_id = f"challenge:hardware:{short_id(seed, 'offline-challenge')}" artifact_id = f"artifact:hardware:{short_id(seed, 'cartridge-artifact-ref')}" memory_cell_id = f"memory:hardware:{short_id(seed, 'cartridge-memory-ref')}" @@ -324,6 +381,24 @@ def build_operator_signals(seed: int, packets: dict[str, Any] | None = None) -> "provenance": provenance, } + operator_metadata = { + "metadataId": operator_metadata_id, + "operatorId": f"operator:local:{short_id(seed, 'operator')}", + "nodeId": device_id, + "rootfieldId": HARDWARE_ROOTFIELD_ID, + "displayName": "Local hardware operator fixture", + "roles": ["hardware_observer", "fixture_relay"], + "transportPreferences": ["local-simulator", "meshtastic-control-sim"], + "hardwareRequiredForPrivateTestnet": False, + "noSecrets": True, + "radioPayloadBudgetBytes": sidecar["payload_budget_bytes"], + "metadataSource": "device_manifest", + "observedAt": manifest["generated_at"], + "localOnly": True, + "sourcePacketType": "device_manifest", + "provenance": provenance, + } + work_receipt = { "receiptId": receipt_id, "rootfieldId": HARDWARE_ROOTFIELD_ID, @@ -364,6 +439,29 @@ def build_operator_signals(seed: int, packets: dict[str, Any] | None = None) -> "sourcePacketType": "verifier_report_digest_relay", } + bridge_alert = { + "bridgeAlertId": bridge_alert_id, + "bridgeId": bridge["bridge_id"], + "rootfieldId": HARDWARE_ROOTFIELD_ID, + "severity": bridge["severity"], + "alertCode": bridge["alert_code"], + "sourceChain": bridge["source_chain"], + "targetChain": bridge["target_chain"], + "subjectId": bridge["subject_id"], + "eventDigest": ensure_hex(bridge["digest"]), + "blockHint": bridge["block_hint"], + "status": "unresolved", + "resolutionState": "operator-review-required", + "summary": bridge["summary"], + "recommendedAction": bridge["operator_action"], + "payloadBytesEstimate": bridge["payload_bytes_estimate"], + "loraEligible": bridge["lora_eligible"], + "localOnly": True, + "doesNotBlockLocalChain": True, + "sourcePacketType": "bridge_alert", + "provenance": provenance, + } + artifact = { "artifactId": artifact_id, "rootfieldId": HARDWARE_ROOTFIELD_ID, @@ -439,6 +537,21 @@ def build_operator_signals(seed: int, packets: dict[str, Any] | None = None) -> "provenance": provenance, } + bridge_incident = { + "id": bridge_incident_id, + "incidentId": bridge_incident_id, + "severity": bridge["severity"], + "title": bridge["alert_code"], + "summary": bridge["summary"], + "openedAt": bridge["emitted_at"], + "linkedObjectIds": [bridge_alert_id], + "recommendedAction": bridge["operator_action"], + "status": "unresolved", + "localOnly": True, + "sourcePacketType": "bridge_alert", + "provenance": provenance, + } + def signal_envelope( label: str, signal_type: str, @@ -448,6 +561,7 @@ def signal_envelope( ) -> dict[str, Any]: packet_type = packet["packet_type"] sequence = packet.get("sequence", seed) + observed_at = packet.get("emitted_at", packet.get("created_at", packet.get("generated_at", generated_at))) return { "schema": "flowmemory.hardware_operator_signal_envelope.local_alpha.v0", "envelopeId": f"hw-env-{short_id(seed, f'{label}-envelope')}", @@ -455,7 +569,7 @@ def signal_envelope( "signalType": signal_type, "sourcePacketType": packet_type, "sourcePacketId": f"{packet_type}:{sequence}", - "observedAt": packet.get("emitted_at", packet.get("created_at", generated_at)), + "observedAt": observed_at, "status": status, "localOnly": True, "payloadBytesEstimate": packet.get("payload_bytes_estimate", 0), @@ -485,6 +599,13 @@ def workbench_record( } signal_envelopes = [ + signal_envelope( + "operator-metadata", + "operator_metadata", + manifest, + [{"collection": "operatorMetadata", "objectId": operator_metadata_id}], + "observed", + ), signal_envelope( "heartbeat", "heartbeat", @@ -516,6 +637,16 @@ def workbench_record( ], "pending", ), + signal_envelope( + "bridge-alert", + "bridge_alert", + bridge, + [ + {"collection": "bridgeAlerts", "objectId": bridge_alert_id}, + {"collection": "alerts", "objectId": bridge_incident_id}, + ], + "unresolved", + ), signal_envelope( "nfc-memory-cartridge", "nfc_memory_cartridge_metadata", @@ -529,10 +660,12 @@ def workbench_record( ] signal_summaries = { + "operator_metadata": "Local operator metadata for optional hardware fixture ingestion.", "heartbeat": "FlowRouter heartbeat and coarse node state.", "receipt_relay": "Compact WorkReceipt digest relay awaiting normal reconciliation.", "verifier_digest_relay": "Compact VerifierReport digest relay awaiting the full report.", "offline_alert_challenge_input": "Offline alert that can seed a local challenge candidate.", + "bridge_alert": "Compact bridge observer alert that does not block local chain progress.", "nfc_memory_cartridge_metadata": "NFC metadata pointer projected into artifact and memory references.", } hardware_signals = [ @@ -566,6 +699,10 @@ def workbench_record( "packetFixture": packet_fixture_path, "operatorFixture": operator_fixture_path, "operatorSchema": "hardware/simulator/schemas/flowchain_operator_signals.schema.json", + "handoffFixture": handoff_fixture_path, + "handoffSchema": "schemas/flowmemory/hardware-control-plane-handoff.schema.json", + "negativeReport": negative_report_path, + "negativeReportSchema": "hardware/simulator/schemas/negative_validation_report.schema.json", "mappingDoc": "hardware/flowrouter/FLOWCHAIN_LOCAL_ALPHA_SIGNALS.md", }, "boundary": { @@ -578,9 +715,18 @@ def workbench_record( "LoRa and Meshtastic packets carry compact control signals, not artifacts, model data, media, or raw memory.", "NFC cartridge metadata is an untrusted pointer until checked against expected commitments.", "Emergency offline signals are operator alerts or challenge inputs only; they do not execute remote actions.", + "Bridge alerts are operator review hints and must not block local chain progress.", ], }, "packetMappings": [ + { + "sourcePacketType": "device_manifest", + "flowchainSignal": "operator_metadata", + "objectCollection": "operatorMetadata", + "objectRef": operator_metadata_id, + "localAlphaRole": "names the local optional hardware operator fixture issuer", + "trustBoundary": "local metadata only; no wallet, secret, or production operator claim", + }, { "sourcePacketType": "heartbeat", "flowchainSignal": "hardware_node_status", @@ -613,6 +759,14 @@ def workbench_record( "localAlphaRole": "creates an operator alert and optional challenge input", "trustBoundary": "local operator attention only; no public emergency-service claim", }, + { + "sourcePacketType": "bridge_alert", + "flowchainSignal": "bridge_observer_alert", + "objectCollection": "bridgeAlerts", + "objectRef": bridge_alert_id, + "localAlphaRole": "surfaces bridge-observer lag without blocking the local chain", + "trustBoundary": "digest alert only; no production bridge readiness or settlement claim", + }, { "sourcePacketType": "nfc_memory_cartridge_metadata", "flowchainSignal": "artifact_memory_reference", @@ -624,15 +778,33 @@ def workbench_record( ], "signalEnvelopes": signal_envelopes, "hardwareSignals": hardware_signals, + "operatorMetadata": [operator_metadata], "hardwareNodes": [hardware_node], "workReceipts": [work_receipt], "verifierReports": [verifier_report], + "bridgeAlerts": [bridge_alert], "artifactCommitments": [artifact], "memoryCells": [memory_cell], "challenges": [challenge], "finalityReceipts": [finality_receipt], - "alerts": [alert], + "alerts": [alert, bridge_incident], "workbenchRecords": { + "operatorMetadata": [ + workbench_record( + operator_metadata_id, + "Hardware operator metadata", + operator_metadata["displayName"], + "Local-only metadata for the optional hardware signal fixture issuer.", + "observed", + [ + {"label": "operator", "value": operator_metadata["operatorId"]}, + {"label": "node", "value": operator_metadata["nodeId"]}, + {"label": "hardware required", "value": "false"}, + {"label": "payload budget", "value": str(operator_metadata["radioPayloadBudgetBytes"])}, + ], + operator_metadata, + ) + ], "receipts": [ workbench_record( receipt_id, @@ -665,6 +837,22 @@ def workbench_record( verifier_report, ) ], + "bridgeAlerts": [ + workbench_record( + bridge_alert_id, + "Hardware bridge alert", + bridge["alert_code"], + bridge["summary"], + "unresolved", + [ + {"label": "bridge", "value": bridge["bridge_id"]}, + {"label": "source", "value": bridge["source_chain"]}, + {"label": "target", "value": bridge["target_chain"]}, + {"label": "digest", "value": ensure_hex(bridge["digest"])}, + ], + bridge_alert, + ) + ], "artifacts": [ workbench_record( artifact_id, @@ -747,6 +935,7 @@ def workbench_record( "sourcePaths": { "packetFixture": packet_fixture_path, "operatorFixture": operator_fixture_path, + "handoffFixture": handoff_fixture_path, } }, ) @@ -755,9 +944,11 @@ def workbench_record( "compatibility": { "controlPlaneStateKeys": [ "hardwareSignals", + "operatorMetadata", "hardwareNodes", "workReceipts", "verifierReports", + "bridgeAlerts", "artifactCommitments", "memoryCells", "challenges", @@ -765,8 +956,10 @@ def workbench_record( "alerts", ], "workbenchSectionKeys": [ + "operatorMetadata", "receipts", "verifierReports", + "bridgeAlerts", "artifacts", "memoryCells", "challenges", @@ -774,10 +967,53 @@ def workbench_record( "provenance", ], "jsonRpcBoundary": "Read-only fixture data; no submit, wallet, live indexing, or production settlement method is implied.", + "flowchainFullSmokeOptionalRow": { + "label": "Validate optional hardware operator signal fixtures", + "command": "python hardware/simulator/flowrouter_sim.py --smoke", + "requiredForChainProgress": False, + "hardwareRequired": False, + }, }, } +def build_control_plane_handoff(seed: int, operator_signals: dict[str, Any] | None = None) -> dict[str, Any]: + signal_doc = operator_signals if operator_signals is not None else build_operator_signals(seed) + state_keys = signal_doc["compatibility"]["controlPlaneStateKeys"] + return { + "schema": "flowmemory.hardware_control_plane_handoff.local_alpha.v0", + "generatedAt": signal_doc["generatedAt"], + "chainId": signal_doc["chainId"], + "environment": signal_doc["environment"], + "sourceFixture": signal_doc["sourcePaths"]["operatorFixture"], + "hardwareRequiredForPrivateTestnet": False, + "mode": "read-only-optional-merge", + "boundary": signal_doc["boundary"], + "ingest": { + "stateKeys": state_keys, + "mergePolicy": "replace-by-stable-id", + "idFields": { + "hardwareSignals": "signalId", + "operatorMetadata": "metadataId", + "hardwareNodes": "nodeId", + "workReceipts": "receiptId", + "verifierReports": "reportId", + "bridgeAlerts": "bridgeAlertId", + "artifactCommitments": "artifactId", + "memoryCells": "memoryCellId", + "challenges": "challengeId", + "finalityReceipts": "finalityReceiptId", + "alerts": "incidentId", + }, + "localOnly": True, + "normalNetworkReconciliationRequired": True, + }, + "collections": {key: signal_doc[key] for key in state_keys}, + "workbenchRecords": signal_doc["workbenchRecords"], + "optionalSmokeRows": [signal_doc["compatibility"]["flowchainFullSmokeOptionalRow"]], + } + + class ValidationError(Exception): pass @@ -803,6 +1039,9 @@ def validate_value(schema: dict[str, Any], value: Any, path: str) -> None: if expected and not check_type(value, expected): raise ValidationError(f"{path}: expected {expected}, got {type(value).__name__}") + if "const" in schema and value != schema["const"]: + raise ValidationError(f"{path}: value {value!r} does not match const {schema['const']!r}") + if "enum" in schema and value not in schema["enum"]: raise ValidationError(f"{path}: value {value!r} not in enum {schema['enum']!r}") @@ -831,6 +1070,10 @@ def validate_value(schema: dict[str, Any], value: Any, path: str) -> None: validate_value(child, value[key], f"{path}.{key}") if isinstance(value, list) and "items" in schema: + if "minItems" in schema and len(value) < schema["minItems"]: + raise ValidationError(f"{path}: array shorter than {schema['minItems']}") + if "maxItems" in schema and len(value) > schema["maxItems"]: + raise ValidationError(f"{path}: array longer than {schema['maxItems']}") for index, item in enumerate(value): validate_value(schema["items"], item, f"{path}[{index}]") @@ -858,6 +1101,17 @@ def validate_operator_signals(operator_signals: dict[str, Any], schema_dir: Path validate_value(schema, operator_signals, "operator_signals") +def validate_control_plane_handoff(handoff: dict[str, Any], repo_root: Path) -> None: + schema_path = repo_root / "schemas" / "flowmemory" / "hardware-control-plane-handoff.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + validate_value(schema, handoff, "control_plane_handoff") + + +def validate_negative_report(report: dict[str, Any], schema_dir: Path) -> None: + schema = json.loads((schema_dir / NEGATIVE_REPORT_SCHEMA_FILE).read_text(encoding="utf-8")) + validate_value(schema, report, "negative_report") + + def output_document(seed: int) -> dict[str, Any]: return { "simulator": "flowrouter-v0-poc", @@ -867,18 +1121,155 @@ def output_document(seed: int) -> dict[str, Any]: } +def run_negative_cases(seed: int, schema_dir: Path, repo_root: Path) -> list[dict[str, Any]]: + packets = build_packets(seed) + operator_doc = build_operator_signals(seed, packets) + handoff_doc = build_control_plane_handoff(seed, operator_doc) + + cases: list[dict[str, Any]] = [] + + def expect_rejected(name: str, validator: Any, value: Any, expected: str) -> None: + try: + validator(value) + except ValidationError as exc: + message = str(exc) + if expected not in message: + raise ValidationError(f"{name}: expected error containing {expected!r}, got {message!r}") from exc + cases.append({"case": name, "expectedFailure": expected, "actualFailure": message, "passed": True}) + return + raise ValidationError(f"{name}: negative case unexpectedly passed validation") + + missing_heartbeat_device = clone_json(packets) + del missing_heartbeat_device["heartbeat"]["device_id"] + expect_rejected( + "heartbeat_missing_device_id", + lambda value: validate_packets(value, schema_dir), + missing_heartbeat_device, + "missing required key device_id", + ) + + oversized_receipt_relay = clone_json(packets) + oversized_receipt_relay["compact_receipt_relay"]["payload_bytes_estimate"] = 512 + expect_rejected( + "receipt_relay_payload_exceeds_control_budget", + lambda value: validate_packets(value, schema_dir), + oversized_receipt_relay, + "value above maximum 200", + ) + + nfc_secret_claim = clone_json(packets) + nfc_secret_claim["nfc_memory_cartridge_metadata"]["contains_secrets"] = True + expect_rejected( + "nfc_metadata_claims_secret_storage", + lambda value: validate_packets(value, schema_dir), + nfc_secret_claim, + "not in enum [False]", + ) + + missing_bridge_handoff = clone_json(operator_doc) + del missing_bridge_handoff["bridgeAlerts"] + expect_rejected( + "operator_projection_missing_bridge_alerts", + lambda value: validate_operator_signals(value, schema_dir), + missing_bridge_handoff, + "missing required key bridgeAlerts", + ) + + hardware_required = clone_json(operator_doc) + hardware_required["boundary"]["hardwareRequiredForPrivateTestnet"] = True + expect_rejected( + "operator_projection_requires_hardware", + lambda value: validate_operator_signals(value, schema_dir), + hardware_required, + "not in enum [False]", + ) + + oversized_operator_envelope = clone_json(operator_doc) + oversized_operator_envelope["signalEnvelopes"][2]["payloadBytesEstimate"] = 512 + expect_rejected( + "operator_envelope_payload_exceeds_control_budget", + lambda value: validate_operator_signals(value, schema_dir), + oversized_operator_envelope, + "value above maximum 200", + ) + + handoff_requires_hardware = clone_json(handoff_doc) + handoff_requires_hardware["hardwareRequiredForPrivateTestnet"] = True + expect_rejected( + "control_plane_handoff_requires_hardware", + lambda value: validate_control_plane_handoff(value, repo_root), + handoff_requires_hardware, + "not in enum [False]", + ) + + handoff_missing_hardware_signals = clone_json(handoff_doc) + del handoff_missing_hardware_signals["collections"]["hardwareSignals"] + expect_rejected( + "control_plane_handoff_missing_hardware_signals", + lambda value: validate_control_plane_handoff(value, repo_root), + handoff_missing_hardware_signals, + "missing required key hardwareSignals", + ) + + return cases + + +def build_negative_report(seed: int, cases: list[dict[str, Any]]) -> dict[str, Any]: + return { + "schema": "flowmemory.hardware_negative_validation.local_alpha.v0", + "generatedAt": iso_tick(130), + "seed": seed, + "caseCount": len(cases), + "allCasesRejected": all(case["passed"] for case in cases), + "cases": cases, + } + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def assert_matches_file(path: Path, expected: dict[str, Any]) -> None: + if not path.exists(): + raise ValidationError(f"missing fixture: {path}") + actual = json.loads(path.read_text(encoding="utf-8")) + if actual != expected: + raise ValidationError(f"fixture drift: {path}") + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--seed", type=int, default=42, help="deterministic seed") + parser.add_argument("--seed", type=int, default=DEFAULT_SEED, help="deterministic seed") + parser.add_argument("--generate-fixtures", action="store_true", help="write the canonical raw, operator, handoff, and negative validation fixtures") + parser.add_argument("--smoke", action="store_true", help="validate canonical fixtures, check deterministic drift, and run negative cases") + parser.add_argument("--run-negative-cases", action="store_true", help="run in-memory negative validation cases") parser.add_argument("--out", type=Path, help="write generated JSON to this path") parser.add_argument("--operator-out", type=Path, help="write FlowChain local-alpha operator signal JSON to this path") + parser.add_argument("--handoff-out", type=Path, help="write control-plane handoff JSON to this path") + parser.add_argument("--negative-report-out", type=Path, help="write negative validation report JSON to this path") parser.add_argument("--validate-file", type=Path, help="validate an existing simulator JSON file") parser.add_argument("--validate-operator-file", type=Path, help="validate an existing FlowChain local-alpha operator signal JSON file") + parser.add_argument("--validate-handoff-file", type=Path, help="validate an existing hardware control-plane handoff JSON file") + parser.add_argument("--validate-negative-report-file", type=Path, help="validate an existing negative validation report JSON file") args = parser.parse_args() schema_dir = Path(__file__).resolve().parent / "schemas" + repo_root = Path(__file__).resolve().parents[2] try: + if args.validate_negative_report_file: + negative_report = json.loads(args.validate_negative_report_file.read_text(encoding="utf-8")) + validate_negative_report(negative_report, schema_dir) + print(f"valid: {args.validate_negative_report_file}") + return 0 + + if args.validate_handoff_file: + handoff_doc = json.loads(args.validate_handoff_file.read_text(encoding="utf-8")) + validate_control_plane_handoff(handoff_doc, repo_root) + print(f"valid: {args.validate_handoff_file}") + return 0 + if args.validate_operator_file: operator_doc = json.loads(args.validate_operator_file.read_text(encoding="utf-8")) validate_operator_signals(operator_doc, schema_dir) @@ -891,27 +1282,58 @@ def main() -> int: print(f"valid: {args.validate_file}") return 0 + if args.run_negative_cases: + cases = run_negative_cases(args.seed, schema_dir, repo_root) + print(f"negative cases passed: {len(cases)}") + return 0 + packets = build_packets(args.seed) - doc = { - "simulator": "flowrouter-v0-poc", - "seed": args.seed, - "generated_at": iso_tick(120), - "packets": packets, - } + doc = output_document(args.seed) validate_packets(doc["packets"], schema_dir) operator_doc = build_operator_signals(args.seed, packets) validate_operator_signals(operator_doc, schema_dir) + handoff_doc = build_control_plane_handoff(args.seed, operator_doc) + validate_control_plane_handoff(handoff_doc, repo_root) + negative_cases = run_negative_cases(args.seed, schema_dir, repo_root) + negative_report = build_negative_report(args.seed, negative_cases) + validate_negative_report(negative_report, schema_dir) + + if args.smoke: + assert_matches_file(default_raw_fixture_path(args.seed), doc) + assert_matches_file(default_operator_fixture_path(args.seed), operator_doc) + assert_matches_file(default_handoff_fixture_path(args.seed), handoff_doc) + assert_matches_file(default_negative_report_path(args.seed), negative_report) + print( + "smoke passed: raw packets, operator signals, control-plane handoff, " + f"fixture drift check, and {len(negative_cases)} negative cases" + ) + return 0 + encoded = json.dumps(doc, indent=2, sort_keys=True) + "\n" - if args.out: - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(encoded, encoding="utf-8") - print(f"wrote: {args.out}") + out_path = args.out + operator_out_path = args.operator_out + handoff_out_path = args.handoff_out + negative_report_out_path = args.negative_report_out + if args.generate_fixtures: + out_path = out_path or default_raw_fixture_path(args.seed) + operator_out_path = operator_out_path or default_operator_fixture_path(args.seed) + handoff_out_path = handoff_out_path or default_handoff_fixture_path(args.seed) + negative_report_out_path = negative_report_out_path or default_negative_report_path(args.seed) + + if out_path: + write_json(out_path, doc) + print(f"wrote: {out_path}") else: sys.stdout.write(encoded) - if args.operator_out: - args.operator_out.parent.mkdir(parents=True, exist_ok=True) - args.operator_out.write_text(json.dumps(operator_doc, indent=2, sort_keys=True) + "\n", encoding="utf-8") - print(f"wrote: {args.operator_out}") + if operator_out_path: + write_json(operator_out_path, operator_doc) + print(f"wrote: {operator_out_path}") + if handoff_out_path: + write_json(handoff_out_path, handoff_doc) + print(f"wrote: {handoff_out_path}") + if negative_report_out_path: + write_json(negative_report_out_path, negative_report) + print(f"wrote: {negative_report_out_path}") return 0 except (KeyError, json.JSONDecodeError, OSError, ValidationError) as exc: print(f"error: {exc}", file=sys.stderr) diff --git a/hardware/simulator/schemas/bridge_alert.schema.json b/hardware/simulator/schemas/bridge_alert.schema.json new file mode 100644 index 00000000..6c578be9 --- /dev/null +++ b/hardware/simulator/schemas/bridge_alert.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Bridge Alert", + "type": "object", + "additionalProperties": false, + "required": [ + "packet_type", + "schema_version", + "device_id", + "sequence", + "emitted_at", + "bridge_id", + "source_chain", + "target_chain", + "alert_code", + "severity", + "subject_id", + "digest", + "block_hint", + "payload_bytes_estimate", + "lora_eligible", + "verification_state", + "summary", + "operator_action" + ], + "properties": { + "packet_type": { "type": "string", "enum": ["bridge_alert"] }, + "schema_version": { "type": "string" }, + "device_id": { "type": "string", "maxLength": 32 }, + "sequence": { "type": "integer", "minimum": 0 }, + "emitted_at": { "type": "string" }, + "bridge_id": { "type": "string", "maxLength": 48 }, + "source_chain": { "type": "string", "maxLength": 48 }, + "target_chain": { "type": "string", "maxLength": 48 }, + "alert_code": { + "type": "string", + "enum": ["LOCKBOX_OBSERVER_LAG", "BRIDGE_DIGEST_MISMATCH", "BRIDGE_RELAY_STALE"] + }, + "severity": { "type": "string", "enum": ["info", "warning", "critical"] }, + "subject_id": { "type": "string", "maxLength": 64 }, + "digest": { "type": "string", "maxLength": 64 }, + "block_hint": { "type": "integer", "minimum": 0 }, + "payload_bytes_estimate": { "type": "integer", "minimum": 0, "maximum": 200 }, + "lora_eligible": { "type": "boolean" }, + "verification_state": { "type": "string", "enum": ["advisory", "unresolved"] }, + "summary": { "type": "string", "maxLength": 160 }, + "operator_action": { "type": "string", "maxLength": 96 } + } +} diff --git a/hardware/simulator/schemas/flowchain_operator_signals.schema.json b/hardware/simulator/schemas/flowchain_operator_signals.schema.json index 05926abe..1761d321 100644 --- a/hardware/simulator/schemas/flowchain_operator_signals.schema.json +++ b/hardware/simulator/schemas/flowchain_operator_signals.schema.json @@ -14,9 +14,11 @@ "packetMappings", "signalEnvelopes", "hardwareSignals", + "operatorMetadata", "hardwareNodes", "workReceipts", "verifierReports", + "bridgeAlerts", "artifactCommitments", "memoryCells", "challenges", @@ -37,11 +39,24 @@ "sourcePaths": { "type": "object", "additionalProperties": false, - "required": ["packetFixture", "operatorFixture", "operatorSchema", "mappingDoc"], + "required": [ + "packetFixture", + "operatorFixture", + "operatorSchema", + "handoffFixture", + "handoffSchema", + "negativeReport", + "negativeReportSchema", + "mappingDoc" + ], "properties": { "packetFixture": { "type": "string", "maxLength": 128 }, "operatorFixture": { "type": "string", "maxLength": 128 }, "operatorSchema": { "type": "string", "maxLength": 128 }, + "handoffFixture": { "type": "string", "maxLength": 128 }, + "handoffSchema": { "type": "string", "maxLength": 128 }, + "negativeReport": { "type": "string", "maxLength": 128 }, + "negativeReportSchema": { "type": "string", "maxLength": 128 }, "mappingDoc": { "type": "string", "maxLength": 128 } } }, @@ -83,30 +98,36 @@ "sourcePacketType": { "type": "string", "enum": [ + "device_manifest", "heartbeat", "compact_receipt_relay", "verifier_report_digest_relay", "emergency_offline_signal", + "bridge_alert", "nfc_memory_cartridge_metadata" ] }, "flowchainSignal": { "type": "string", "enum": [ + "operator_metadata", "hardware_node_status", "work_receipt_reference", "verifier_report_reference", "alert_challenge_input", + "bridge_observer_alert", "artifact_memory_reference" ] }, "objectCollection": { "type": "string", "enum": [ + "operatorMetadata", "hardwareNodes", "workReceipts", "verifierReports", "challenges", + "bridgeAlerts", "artifactCommitments" ] }, @@ -146,10 +167,12 @@ "signalType": { "type": "string", "enum": [ + "operator_metadata", "heartbeat", "receipt_relay", "verifier_digest_relay", "offline_alert_challenge_input", + "bridge_alert", "nfc_memory_cartridge_metadata" ] }, @@ -206,10 +229,12 @@ "signalType": { "type": "string", "enum": [ + "operator_metadata", "heartbeat", "receipt_relay", "verifier_digest_relay", "offline_alert_challenge_input", + "bridge_alert", "nfc_memory_cartridge_metadata" ] }, @@ -226,6 +251,44 @@ } } }, + "operatorMetadata": { + "type": "array", + "items": { + "type": "object", + "required": [ + "metadataId", + "operatorId", + "nodeId", + "rootfieldId", + "displayName", + "roles", + "transportPreferences", + "hardwareRequiredForPrivateTestnet", + "noSecrets", + "radioPayloadBudgetBytes", + "metadataSource", + "observedAt", + "localOnly", + "sourcePacketType" + ], + "properties": { + "metadataId": { "type": "string", "maxLength": 96 }, + "operatorId": { "type": "string", "maxLength": 96 }, + "nodeId": { "type": "string", "maxLength": 64 }, + "rootfieldId": { "type": "string", "maxLength": 96 }, + "displayName": { "type": "string", "maxLength": 96 }, + "roles": { "type": "array", "items": { "type": "string", "maxLength": 48 } }, + "transportPreferences": { "type": "array", "items": { "type": "string", "maxLength": 48 } }, + "hardwareRequiredForPrivateTestnet": { "type": "boolean", "enum": [false] }, + "noSecrets": { "type": "boolean", "enum": [true] }, + "radioPayloadBudgetBytes": { "type": "integer", "minimum": 0, "maximum": 200 }, + "metadataSource": { "type": "string", "enum": ["device_manifest"] }, + "observedAt": { "type": "string" }, + "localOnly": { "type": "boolean", "enum": [true] }, + "sourcePacketType": { "type": "string", "enum": ["device_manifest"] } + } + } + }, "hardwareNodes": { "type": "array", "items": { @@ -311,6 +374,54 @@ } } }, + "bridgeAlerts": { + "type": "array", + "items": { + "type": "object", + "required": [ + "bridgeAlertId", + "bridgeId", + "rootfieldId", + "severity", + "alertCode", + "sourceChain", + "targetChain", + "subjectId", + "eventDigest", + "blockHint", + "status", + "resolutionState", + "summary", + "recommendedAction", + "payloadBytesEstimate", + "loraEligible", + "localOnly", + "doesNotBlockLocalChain", + "sourcePacketType" + ], + "properties": { + "bridgeAlertId": { "type": "string", "maxLength": 96 }, + "bridgeId": { "type": "string", "maxLength": 48 }, + "rootfieldId": { "type": "string", "maxLength": 96 }, + "severity": { "type": "string", "enum": ["info", "warning", "critical"] }, + "alertCode": { "type": "string", "maxLength": 64 }, + "sourceChain": { "type": "string", "maxLength": 48 }, + "targetChain": { "type": "string", "maxLength": 48 }, + "subjectId": { "type": "string", "maxLength": 64 }, + "eventDigest": { "type": "string", "maxLength": 66 }, + "blockHint": { "type": "integer", "minimum": 0 }, + "status": { "type": "string", "enum": ["unresolved", "pending", "verified", "failed"] }, + "resolutionState": { "type": "string", "enum": ["operator-review-required"] }, + "summary": { "type": "string", "maxLength": 180 }, + "recommendedAction": { "type": "string", "maxLength": 96 }, + "payloadBytesEstimate": { "type": "integer", "minimum": 0, "maximum": 200 }, + "loraEligible": { "type": "boolean" }, + "localOnly": { "type": "boolean", "enum": [true] }, + "doesNotBlockLocalChain": { "type": "boolean", "enum": [true] }, + "sourcePacketType": { "type": "string", "enum": ["bridge_alert"] } + } + } + }, "artifactCommitments": { "type": "array", "items": { @@ -397,17 +508,29 @@ "recommendedAction": { "type": "string", "maxLength": 96 }, "status": { "type": "string", "enum": ["unresolved", "pending", "verified", "failed"] }, "localOnly": { "type": "boolean", "enum": [true] }, - "sourcePacketType": { "type": "string", "enum": ["emergency_offline_signal"] } + "sourcePacketType": { "type": "string", "enum": ["emergency_offline_signal", "bridge_alert"] } } } }, "workbenchRecords": { "type": "object", "additionalProperties": false, - "required": ["receipts", "verifierReports", "artifacts", "memoryCells", "challenges", "hardwareSignals", "provenance"], + "required": [ + "operatorMetadata", + "receipts", + "verifierReports", + "bridgeAlerts", + "artifacts", + "memoryCells", + "challenges", + "hardwareSignals", + "provenance" + ], "properties": { + "operatorMetadata": { "type": "array", "items": { "type": "object" } }, "receipts": { "type": "array", "items": { "type": "object" } }, "verifierReports": { "type": "array", "items": { "type": "object" } }, + "bridgeAlerts": { "type": "array", "items": { "type": "object" } }, "artifacts": { "type": "array", "items": { "type": "object" } }, "memoryCells": { "type": "array", "items": { "type": "object" } }, "challenges": { "type": "array", "items": { "type": "object" } }, @@ -418,11 +541,22 @@ "compatibility": { "type": "object", "additionalProperties": false, - "required": ["controlPlaneStateKeys", "workbenchSectionKeys", "jsonRpcBoundary"], + "required": ["controlPlaneStateKeys", "workbenchSectionKeys", "jsonRpcBoundary", "flowchainFullSmokeOptionalRow"], "properties": { "controlPlaneStateKeys": { "type": "array", "items": { "type": "string", "maxLength": 64 } }, "workbenchSectionKeys": { "type": "array", "items": { "type": "string", "maxLength": 64 } }, - "jsonRpcBoundary": { "type": "string", "maxLength": 160 } + "jsonRpcBoundary": { "type": "string", "maxLength": 160 }, + "flowchainFullSmokeOptionalRow": { + "type": "object", + "additionalProperties": false, + "required": ["label", "command", "requiredForChainProgress", "hardwareRequired"], + "properties": { + "label": { "type": "string", "maxLength": 96 }, + "command": { "type": "string", "maxLength": 128 }, + "requiredForChainProgress": { "type": "boolean", "enum": [false] }, + "hardwareRequired": { "type": "boolean", "enum": [false] } + } + } } } } diff --git a/hardware/simulator/schemas/negative_validation_report.schema.json b/hardware/simulator/schemas/negative_validation_report.schema.json new file mode 100644 index 00000000..15ada7a8 --- /dev/null +++ b/hardware/simulator/schemas/negative_validation_report.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FlowRouter Negative Validation Report", + "type": "object", + "additionalProperties": false, + "required": ["schema", "generatedAt", "seed", "caseCount", "allCasesRejected", "cases"], + "properties": { + "schema": { "type": "string", "enum": ["flowmemory.hardware_negative_validation.local_alpha.v0"] }, + "generatedAt": { "type": "string" }, + "seed": { "type": "integer", "minimum": 0 }, + "caseCount": { "type": "integer", "minimum": 1 }, + "allCasesRejected": { "type": "boolean", "enum": [true] }, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["case", "expectedFailure", "actualFailure", "passed"], + "properties": { + "case": { "type": "string", "maxLength": 96 }, + "expectedFailure": { "type": "string", "maxLength": 160 }, + "actualFailure": { "type": "string", "maxLength": 220 }, + "passed": { "type": "boolean", "enum": [true] } + } + } + } + } +} diff --git a/infra/scripts/bridge-base-mainnet-canary-read.ps1 b/infra/scripts/bridge-base-mainnet-canary-read.ps1 index 0f996613..b4af8a88 100644 --- a/infra/scripts/bridge-base-mainnet-canary-read.ps1 +++ b/infra/scripts/bridge-base-mainnet-canary-read.ps1 @@ -18,7 +18,11 @@ param( [ValidateRange(0.01, 25)] [double]$MaxUsd, - [string]$Out = "services/bridge-relayer/out/base-mainnet-canary-bridge-observation.json" + [string]$Out = "services/bridge-relayer/out/base-mainnet-canary-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/base-mainnet-canary-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/base-mainnet-canary-bridge-handoff.json" ) $ErrorActionPreference = "Stop" @@ -26,6 +30,13 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location -LiteralPath $repoRoot +Write-Host "Reading Base mainnet bridge canary logs." -ForegroundColor Yellow +Write-Host "Chain: Base mainnet (8453)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Block range: $FromBlock-$ToBlock" +Write-Host "Max USD guardrail: $MaxUsd" +Write-Host "Broadcast: false; this command is read-only." + npm run bridge:observe -- ` --mode base-mainnet-canary ` --rpc-url $RpcUrl ` @@ -34,6 +45,8 @@ npm run bridge:observe -- ` --to-block $ToBlock ` --acknowledge-real-funds ` --max-usd $MaxUsd ` - --out $Out + --out $Out ` + --credit-out $CreditOut ` + --handoff-out $HandoffOut -Write-Host "Base mainnet canary bridge read wrote $Out" -ForegroundColor Green +Write-Host "Base mainnet canary bridge read wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/infra/scripts/bridge-base-sepolia-observe.ps1 b/infra/scripts/bridge-base-sepolia-observe.ps1 new file mode 100644 index 00000000..0e203cd2 --- /dev/null +++ b/infra/scripts/bridge-base-sepolia-observe.ps1 @@ -0,0 +1,48 @@ +param( + [string]$RpcUrl = $env:BASE_SEPOLIA_RPC_URL, + + [string]$LockboxAddress = $env:BASE_BRIDGE_LOCKBOX_ADDRESS, + + [string]$FromBlock = $env:BASE_BRIDGE_FROM_BLOCK, + + [string]$ToBlock = $env:BASE_BRIDGE_TO_BLOCK, + + [string]$Out = "services/bridge-relayer/out/base-sepolia-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/base-sepolia-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/base-sepolia-bridge-handoff.json" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +$missing = @() +if ([string]::IsNullOrWhiteSpace($RpcUrl)) { $missing += "BASE_SEPOLIA_RPC_URL or -RpcUrl" } +if ([string]::IsNullOrWhiteSpace($LockboxAddress)) { $missing += "BASE_BRIDGE_LOCKBOX_ADDRESS or -LockboxAddress" } +if ([string]::IsNullOrWhiteSpace($FromBlock)) { $missing += "BASE_BRIDGE_FROM_BLOCK or -FromBlock" } +if ([string]::IsNullOrWhiteSpace($ToBlock)) { $missing += "BASE_BRIDGE_TO_BLOCK or -ToBlock" } + +if ($missing.Count -gt 0) { + throw "Base Sepolia bridge observation needs: $($missing -join ', '). No private key is required." +} + +Write-Host "Observing Base Sepolia bridge deposits." -ForegroundColor Cyan +Write-Host "Chain: Base Sepolia (84532)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Block range: $FromBlock-$ToBlock" +Write-Host "Broadcast: false; private key not required." + +npm run bridge:observe -- ` + --mode base-sepolia ` + --rpc-url $RpcUrl ` + --lockbox-address $LockboxAddress ` + --from-block $FromBlock ` + --to-block $ToBlock ` + --out $Out ` + --credit-out $CreditOut ` + --handoff-out $HandoffOut + +Write-Host "Base Sepolia bridge observation wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/infra/scripts/bridge-base-sepolia-smoke.ps1 b/infra/scripts/bridge-base-sepolia-smoke.ps1 index b3f09b13..f25f1dd0 100644 --- a/infra/scripts/bridge-base-sepolia-smoke.ps1 +++ b/infra/scripts/bridge-base-sepolia-smoke.ps1 @@ -11,7 +11,11 @@ param( [Parameter(Mandatory = $true)] [string]$ToBlock, - [string]$Out = "services/bridge-relayer/out/base-sepolia-bridge-observation.json" + [string]$Out = "services/bridge-relayer/out/base-sepolia-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/base-sepolia-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/base-sepolia-bridge-handoff.json" ) $ErrorActionPreference = "Stop" @@ -19,12 +23,13 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location -LiteralPath $repoRoot -npm run bridge:observe -- ` - --mode base-sepolia ` - --rpc-url $RpcUrl ` - --lockbox-address $LockboxAddress ` - --from-block $FromBlock ` - --to-block $ToBlock ` - --out $Out +powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "bridge-base-sepolia-observe.ps1") ` + -RpcUrl $RpcUrl ` + -LockboxAddress $LockboxAddress ` + -FromBlock $FromBlock ` + -ToBlock $ToBlock ` + -Out $Out ` + -CreditOut $CreditOut ` + -HandoffOut $HandoffOut -Write-Host "Base Sepolia bridge smoke wrote $Out" -ForegroundColor Green +Write-Host "Base Sepolia bridge smoke wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/infra/scripts/bridge-local-anvil-observe.ps1 b/infra/scripts/bridge-local-anvil-observe.ps1 new file mode 100644 index 00000000..19c87092 --- /dev/null +++ b/infra/scripts/bridge-local-anvil-observe.ps1 @@ -0,0 +1,51 @@ +param( + [string]$RpcUrl = $env:ANVIL_RPC_URL, + + [string]$LockboxAddress = $env:ANVIL_BRIDGE_LOCKBOX_ADDRESS, + + [string]$FromBlock = $env:ANVIL_BRIDGE_FROM_BLOCK, + + [string]$ToBlock = $env:ANVIL_BRIDGE_TO_BLOCK, + + [string]$Out = "services/bridge-relayer/out/local-anvil-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/local-anvil-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/local-anvil-bridge-handoff.json" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +if ([string]::IsNullOrWhiteSpace($RpcUrl)) { + $RpcUrl = "http://127.0.0.1:8545" +} + +$missing = @() +if ([string]::IsNullOrWhiteSpace($LockboxAddress)) { $missing += "ANVIL_BRIDGE_LOCKBOX_ADDRESS or -LockboxAddress" } +if ([string]::IsNullOrWhiteSpace($FromBlock)) { $missing += "ANVIL_BRIDGE_FROM_BLOCK or -FromBlock" } +if ([string]::IsNullOrWhiteSpace($ToBlock)) { $missing += "ANVIL_BRIDGE_TO_BLOCK or -ToBlock" } + +if ($missing.Count -gt 0) { + throw "Local Anvil bridge observation needs: $($missing -join ', ')." +} + +Write-Host "Observing local Anvil bridge deposits." -ForegroundColor Cyan +Write-Host "Chain: local Anvil (31337)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Block range: $FromBlock-$ToBlock" +Write-Host "Broadcast: false; this command only reads logs." + +npm run bridge:observe -- ` + --mode local-anvil ` + --rpc-url $RpcUrl ` + --lockbox-address $LockboxAddress ` + --from-block $FromBlock ` + --to-block $ToBlock ` + --out $Out ` + --credit-out $CreditOut ` + --handoff-out $HandoffOut + +Write-Host "Local Anvil bridge observation wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/infra/scripts/flowchain-common.ps1 b/infra/scripts/flowchain-common.ps1 index 173e11d2..0fc9d2d8 100644 --- a/infra/scripts/flowchain-common.ps1 +++ b/infra/scripts/flowchain-common.ps1 @@ -78,6 +78,21 @@ function Invoke-FlowChainCommand { } } +function Join-FlowChainProcessArguments { + param( + [string[]] $ArgumentList = @() + ) + + return ($ArgumentList | ForEach-Object { + if ($_.IndexOfAny([char[]] @(" ", "`t", '"')) -ge 0) { + '"' + ($_.Replace('"', '\"')) + '"' + } + else { + $_ + } + }) -join " " +} + function Set-FlowChainCargoTargetDir { param( [Parameter(Mandatory = $true)] diff --git a/infra/scripts/flowchain-faucet.ps1 b/infra/scripts/flowchain-faucet.ps1 new file mode 100644 index 00000000..62c89cc5 --- /dev/null +++ b/infra/scripts/flowchain-faucet.ps1 @@ -0,0 +1,45 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $Account = "local-account:operator", + [UInt64] $Amount = 1000, + [string] $Reason = "local-private-testnet-faucet", + [string] $AuthorizedBy = "local-operator", + [switch] $Direct +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "faucet", + "--account", + $Account, + "--amount", + "$Amount", + "--reason", + $Reason, + "--authorized-by", + $AuthorizedBy +) + +if ($Direct) { + $arguments += "--direct" +} + +Invoke-FlowChainCommand -Label "Submit FlowChain local faucet transaction" -FilePath "cargo" -ArgumentList $arguments diff --git a/infra/scripts/flowchain-full-smoke.ps1 b/infra/scripts/flowchain-full-smoke.ps1 index fe31bb8a..e01b0d94 100644 --- a/infra/scripts/flowchain-full-smoke.ps1 +++ b/infra/scripts/flowchain-full-smoke.ps1 @@ -1,5 +1,6 @@ param( [switch] $SkipDashboardBuild, + [switch] $SkipBridge, [switch] $SkipHardware ) @@ -89,6 +90,14 @@ finally { $env:FLOWMEMORY_TEST_WALLET_PASSWORD = $previousWalletPassword } +if (-not $SkipBridge) { + Invoke-FlowChainCommand -Label "Run bridge local-credit handoff smoke" -FilePath "npm" -ArgumentList @("run", "bridge:local-credit:smoke") +} + +if (-not $SkipHardware) { + Invoke-FlowChainCommand -Label "Run FlowRouter operator-signal smoke" -FilePath "npm" -ArgumentList @("run", "flowchain:hardware:smoke") +} + Assert-FlowChainNoSecretFiles -Path $fullSmokeRoot Invoke-FlowChainCommand -Label "Check working tree patch whitespace" -FilePath "git" -ArgumentList @("diff", "--check") @@ -118,8 +127,10 @@ $report = [ordered]@{ controlPlaneSmoke = $smokeReport.controlPlaneSmoke localWalletCli = "passed" localTransactionEnvelope = $walletEnvelopePath + bridgeLocalCreditSmoke = $(if ($SkipBridge) { "skipped" } else { "passed" }) dashboardBuild = $smokeReport.dashboardBuild hardwareFixture = $smokeReport.hardwareFixture + hardwareOperatorSignals = $(if ($SkipHardware) { "skipped" } else { "passed" }) noSecretExportScan = $smokeReport.noSecretExportScan gitDiffCheck = "passed" acceptanceCoverage = [ordered]@{ @@ -129,6 +140,8 @@ $report = [ordered]@{ workbenchBuild = $smokeReport.dashboardBuild deterministicReplay = "passed" walletEnvelope = "passed" + bridgeLocalCredit = $(if ($SkipBridge) { "skipped" } else { "passed" }) + hardwareOperatorSignals = $(if ($SkipHardware) { "skipped" } else { "passed" }) noSecretExportScan = "passed" unsafeClaimScan = "passed" } diff --git a/infra/scripts/flowchain-multi-node-smoke.ps1 b/infra/scripts/flowchain-multi-node-smoke.ps1 new file mode 100644 index 00000000..e18b59b3 --- /dev/null +++ b/infra/scripts/flowchain-multi-node-smoke.ps1 @@ -0,0 +1,166 @@ +param( + [string] $SmokeDir = "devnet/local/multi-node-smoke" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$smokeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $SmokeDir) + +if (Test-Path -LiteralPath $smokeFullDir) { + Remove-Item -LiteralPath $smokeFullDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $smokeFullDir | Out-Null + +$stateA = Join-Path $smokeFullDir "node-a-state.json" +$stateB = Join-Path $smokeFullDir "node-b-state.json" +$nodeA = Join-Path $smokeFullDir "node-a" +$nodeB = Join-Path $smokeFullDir "node-b" +$peerA = Join-Path $smokeFullDir "node-a-peers.json" +$peerB = Join-Path $smokeFullDir "node-b-peers.json" +$stdoutA = Join-Path $smokeFullDir "node-a.stdout.jsonl" +$stderrA = Join-Path $smokeFullDir "node-a.stderr.log" +$stdoutB = Join-Path $smokeFullDir "node-b.stdout.jsonl" +$stderrB = Join-Path $smokeFullDir "node-b.stderr.log" + +Write-FlowChainJson -Path $peerA -Value ([ordered]@{ + schema = "flowmemory.local_devnet.static_peers.v0" + nodeId = "node:smoke:a" + peers = @( + [ordered]@{ + nodeId = "node:smoke:b" + statePath = $stateB + } + ) +}) + +Write-FlowChainJson -Path $peerB -Value ([ordered]@{ + schema = "flowmemory.local_devnet.static_peers.v0" + nodeId = "node:smoke:b" + peers = @( + [ordered]@{ + nodeId = "node:smoke:a" + statePath = $stateA + } + ) +}) + +$argsA = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateA, + "--node-dir", + $nodeA, + "node", + "--node-id", + "node:smoke:a", + "--block-ms", + "250", + "--max-blocks", + "12", + "--peer-config", + $peerA +) + +$processA = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $argsA) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutA -RedirectStandardError $stderrA +Start-Sleep -Milliseconds 500 + +Invoke-FlowChainCommand -Label "Submit locally authorized transaction to node A" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateA, + "--node-dir", + $nodeA, + "faucet", + "--account", + "local-account:multi-node", + "--amount", + "77", + "--reason", + "multi-node-smoke", + "--authorized-by", + "local-smoke-operator" +) + +$argsB = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateB, + "--node-dir", + $nodeB, + "node", + "--node-id", + "node:smoke:b", + "--block-ms", + "250", + "--max-blocks", + "12", + "--peer-config", + $peerB +) + +$processB = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $argsB) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutB -RedirectStandardError $stderrB + +foreach ($process in @($processA, $processB)) { + if (-not $process.WaitForExit(45000)) { + & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateA --node-dir $nodeA node-stop | Out-Null + & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateB --node-dir $nodeB node-stop | Out-Null + $process.Kill() + throw "Multi-node smoke runtime did not stop after bounded run." + } + $process.Refresh() + if ($null -ne $process.ExitCode -and $process.ExitCode -ne 0) { + throw "Multi-node smoke process $($process.Id) failed with exit code $($process.ExitCode)." + } +} + +$summaryA = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateA --node-dir $nodeA node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed for node A." +} +$summaryB = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateB --node-dir $nodeB node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed for node B." +} + +if ($summaryA.state.localBalances -lt 1) { + throw "Node A did not include the local balance transaction." +} +if ($summaryB.state.localBalances -lt 1) { + throw "Node B did not reconcile the local balance transaction from node A." +} +if ($summaryA.state.stateRoot -ne $summaryB.state.stateRoot) { + throw "Multi-node deterministic reconciliation failed. Node A root $($summaryA.state.stateRoot), node B root $($summaryB.state.stateRoot)." +} + +$reportPath = Join-Path $smokeFullDir "multi-node-smoke-report.json" +$report = [ordered]@{ + schema = "flowchain.private_testnet.multi_node_smoke.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + nodeAState = $stateA + nodeBState = $stateB + nodeABlocks = $summaryA.state.blocks + nodeBBlocks = $summaryB.state.blocks + reconciledStateRoot = $summaryA.state.stateRoot + staticPeerConfig = @($peerA, $peerB) + lanMode = "not exposed; this smoke proves two local processes reconcile through static local-file peer state paths" +} +Write-FlowChainJson -Path $reportPath -Value $report + +Write-Host "" +Write-Host "FlowChain multi-node local-file smoke passed." +Write-Host "Reconciled state root: $($summaryA.state.stateRoot)" +Write-Host "Report: $reportPath" diff --git a/infra/scripts/flowchain-node-smoke.ps1 b/infra/scripts/flowchain-node-smoke.ps1 new file mode 100644 index 00000000..2004e8f4 --- /dev/null +++ b/infra/scripts/flowchain-node-smoke.ps1 @@ -0,0 +1,173 @@ +param( + [string] $SmokeDir = "devnet/local/node-smoke" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$smokeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $SmokeDir) + +if (Test-Path -LiteralPath $smokeFullDir) { + Remove-Item -LiteralPath $smokeFullDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $smokeFullDir | Out-Null + +$statePath = Join-Path $smokeFullDir "state.json" +$nodeDir = Join-Path $smokeFullDir "node" +$snapshotPath = Join-Path $smokeFullDir "state-snapshot.json" +$importedStatePath = Join-Path $smokeFullDir "imported-state.json" +$stdoutPath = Join-Path $smokeFullDir "node.stdout.jsonl" +$stderrPath = Join-Path $smokeFullDir "node.stderr.log" + +$nodeArgs = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "--node-dir", + $nodeDir, + "node", + "--node-id", + "node:smoke:one", + "--block-ms", + "250", + "--max-blocks", + "10" +) + +$process = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $nodeArgs) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath +Start-Sleep -Milliseconds 700 + +Invoke-FlowChainCommand -Label "Submit locally authorized faucet transaction to running node" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "--node-dir", + $nodeDir, + "faucet", + "--account", + "local-account:node-smoke", + "--amount", + "42", + "--reason", + "one-node-smoke", + "--authorized-by", + "local-smoke-operator" +) + +if (-not $process.WaitForExit(30000)) { + & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir node-stop | Out-Null + $process.Kill() + throw "One-node smoke runtime did not stop after bounded 10-block run." +} +$process.Refresh() + +if ($null -ne $process.ExitCode -and $process.ExitCode -ne 0) { + $stderr = Get-Content -Raw -LiteralPath $stderrPath + throw "One-node smoke runtime failed with exit code $($process.ExitCode): $stderr" +} + +$summary = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed after one-node smoke." +} +if ($summary.state.blocks -lt 10) { + throw "Expected one-node smoke to produce at least 10 blocks, got $($summary.state.blocks)." +} +if ($summary.state.localBalances -lt 1 -or $summary.state.faucetRecords -lt 1) { + throw "Expected locally authorized faucet transaction to be included in one-node smoke." +} + +Invoke-FlowChainCommand -Label "Restart node for persistence check" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "--node-dir", + $nodeDir, + "node", + "--node-id", + "node:smoke:one", + "--block-ms", + "50", + "--max-blocks", + "1" +) + +$restartedSummary = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed after persistence restart." +} +if ($restartedSummary.state.blocks -lt 11) { + throw "Expected restart to preserve state and add a block." +} +if ($restartedSummary.state.localBalances -lt 1 -or $restartedSummary.state.faucetRecords -lt 1) { + throw "Expected local balance state to survive restart." +} + +Invoke-FlowChainCommand -Label "Export runtime state snapshot" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "export-state", + "--out", + $snapshotPath +) + +Invoke-FlowChainCommand -Label "Import runtime state snapshot" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $importedStatePath, + "import-state", + "--from", + $snapshotPath +) + +$original = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath inspect-state --summary | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "Failed to inspect original smoke state." +} +$imported = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $importedStatePath inspect-state --summary | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "Failed to inspect imported smoke state." +} +if ($original.stateRoot -ne $imported.stateRoot) { + throw "Export/import state roots differ: $($original.stateRoot) vs $($imported.stateRoot)" +} + +$reportPath = Join-Path $smokeFullDir "one-node-smoke-report.json" +$report = [ordered]@{ + schema = "flowchain.private_testnet.one_node_smoke.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + statePath = $statePath + nodeDir = $nodeDir + blocksAfterRestart = $restartedSummary.state.blocks + locallyAuthorizedTxIncluded = $true + stateSurvivedRestart = $true + exportImportStateRoot = $original.stateRoot + lanMode = "not exposed; static local-file peers only" +} +Write-FlowChainJson -Path $reportPath -Value $report + +Write-Host "" +Write-Host "FlowChain one-node runtime smoke passed." +Write-Host "Blocks after restart: $($restartedSummary.state.blocks)" +Write-Host "State root: $($original.stateRoot)" +Write-Host "Report: $reportPath" diff --git a/infra/scripts/flowchain-node-status.ps1 b/infra/scripts/flowchain-node-status.ps1 new file mode 100644 index 00000000..f8f2b212 --- /dev/null +++ b/infra/scripts/flowchain-node-status.ps1 @@ -0,0 +1,25 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node" +) +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +Invoke-FlowChainCommand -Label "Inspect FlowChain node status" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node-status" +) diff --git a/infra/scripts/flowchain-node-stop.ps1 b/infra/scripts/flowchain-node-stop.ps1 new file mode 100644 index 00000000..537df2aa --- /dev/null +++ b/infra/scripts/flowchain-node-stop.ps1 @@ -0,0 +1,25 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node" +) +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +Invoke-FlowChainCommand -Label "Request FlowChain node stop" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node-stop" +) diff --git a/infra/scripts/flowchain-node.ps1 b/infra/scripts/flowchain-node.ps1 new file mode 100644 index 00000000..f3e60655 --- /dev/null +++ b/infra/scripts/flowchain-node.ps1 @@ -0,0 +1,45 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $NodeId = "node:local:alpha", + [int] $BlockMs = 1000, + [int] $MaxBlocks = 0, + [string] $PeerConfig = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node", + "--node-id", + $NodeId, + "--block-ms", + "$BlockMs" +) + +if ($MaxBlocks -gt 0) { + $arguments += @("--max-blocks", "$MaxBlocks") +} + +if (-not [string]::IsNullOrWhiteSpace($PeerConfig)) { + $peerConfigFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $PeerConfig) + $arguments += @("--peer-config", $peerConfigFullPath) +} + +Invoke-FlowChainCommand -Label "Run FlowChain private/local node" -FilePath "cargo" -ArgumentList $arguments diff --git a/infra/scripts/flowchain-start.ps1 b/infra/scripts/flowchain-start.ps1 index 4bafab1f..ea04ecb2 100644 --- a/infra/scripts/flowchain-start.ps1 +++ b/infra/scripts/flowchain-start.ps1 @@ -42,15 +42,17 @@ $status = [ordered]@{ startedAt = (Get-Date).ToUniversalTime().ToString("o") statePath = $stateFullPath runtimeMode = "bounded-local-cli" - longRunningNode = $false + longRunningNode = "available through npm run flowchain:node" launchCoreGenerated = -not $SkipLaunchCore workbenchCommand = "npm run workbench:dev" smokeCommand = "npm run flowchain:smoke" - note = "Current merged runtime is a deterministic local CLI, not a daemon. Keep this file as operator state for the second-computer package." + nodeCommand = "npm run flowchain:node" + note = "This compatibility wrapper prepares local state and launch-core fixtures. Use npm run flowchain:node for the long-running private/local runtime." } Write-FlowChainJson -Path $statusPath -Value $status Write-Host "" Write-Host "FlowChain private/local stack is ready in bounded local CLI mode." Write-Host "Next command for a transaction demo: npm run flowchain:demo" +Write-Host "Long-running node command: npm run flowchain:node" Write-Host "Workbench command: npm run workbench:dev" diff --git a/infra/scripts/flowchain-stop.ps1 b/infra/scripts/flowchain-stop.ps1 index 980b8a56..841a216b 100644 --- a/infra/scripts/flowchain-stop.ps1 +++ b/infra/scripts/flowchain-stop.ps1 @@ -1,5 +1,6 @@ param( [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", [switch] $ResetLocalState ) @@ -11,8 +12,21 @@ Set-StrictMode -Version Latest $repoRoot = Set-FlowChainRepoRoot Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null $stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) $statusPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path "devnet/local/flowchain-stack-status.json") +Invoke-FlowChainCommand -Label "Request long-running node stop" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node-stop" +) + if ($ResetLocalState) { Invoke-FlowChainCommand -Label "Reset local devnet state" -FilePath "cargo" -ArgumentList @( "run", @@ -30,8 +44,10 @@ $status = [ordered]@{ status = "stopped" stoppedAt = (Get-Date).ToUniversalTime().ToString("o") statePath = $stateFullPath + nodeDir = $nodeFullDir + nodeStopRequested = $true resetLocalState = [bool] $ResetLocalState - note = "No long-running private/local node process is merged yet. Stop records operator state and can reset the ignored local devnet state when explicitly requested." + note = "Stop records operator state, requests the long-running local node to stop through its stop file, and can reset ignored local devnet state when explicitly requested." } Write-FlowChainJson -Path $statusPath -Value $status diff --git a/infra/scripts/flowchain-tx.ps1 b/infra/scripts/flowchain-tx.ps1 new file mode 100644 index 00000000..3ffcb4c9 --- /dev/null +++ b/infra/scripts/flowchain-tx.ps1 @@ -0,0 +1,69 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $TxFile = "", + [string] $AuthorizedBy = "local-operator", + [switch] $Direct +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +if ([string]::IsNullOrWhiteSpace($TxFile)) { + $txDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path "devnet/local/tx") + New-Item -ItemType Directory -Force -Path $txDir | Out-Null + $txFullPath = Join-Path $txDir "sample-agent-registration.json" + $sampleTx = [ordered]@{ + schema = "flowmemory.local_devnet.sample_tx.v0" + txs = @( + [ordered]@{ + type = "RegisterModelPassport" + modelPassportId = "model:local-cli:sample" + issuer = "operator:local-cli" + modelFamily = "local-cli-sample" + modelHash = "0x1111111111111111111111111111111111111111111111111111111111111111" + metadataHash = "0x2222222222222222222222222222222222222222222222222222222222222222" + }, + [ordered]@{ + type = "RegisterAgent" + agentId = "agent:local-cli:sample" + controller = "operator:local-cli" + modelPassportId = "model:local-cli:sample" + metadataHash = "0x3333333333333333333333333333333333333333333333333333333333333333" + } + ) + } + Write-FlowChainJson -Path $txFullPath -Value $sampleTx +} +else { + $txFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $TxFile) +} + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "submit-tx", + "--tx-file", + $txFullPath, + "--authorized-by", + $AuthorizedBy +) + +if ($Direct) { + $arguments += "--direct" +} + +Invoke-FlowChainCommand -Label "Submit FlowChain local transaction" -FilePath "cargo" -ArgumentList $arguments diff --git a/package.json b/package.json index 59f3086b..b0ae96e7 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,14 @@ "flowchain:init": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-init.ps1", "flowchain:start": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-start.ps1", "flowchain:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-stop.ps1", + "flowchain:node": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node.ps1", + "flowchain:node:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-stop.ps1", + "flowchain:node:status": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-status.ps1", + "flowchain:tx": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-tx.ps1", + "flowchain:faucet": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-faucet.ps1", + "flowchain:node:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-smoke.ps1", + "flowchain:multi-node:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-multi-node-smoke.ps1", + "flowchain:hardware:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File hardware/simulator/flowrouter-smoke.ps1", "flowchain:demo": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-demo.ps1", "flowchain:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-smoke.ps1", "flowchain:full-smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-full-smoke.ps1", @@ -50,6 +58,9 @@ "control-plane:serve": "npm run serve --prefix services/control-plane", "bridge:test": "npm test --prefix services/bridge-relayer", "bridge:mock": "npm run mock --prefix services/bridge-relayer", + "bridge:sepolia:observe": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-sepolia-observe.ps1", + "bridge:anvil:observe": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-local-anvil-observe.ps1", + "bridge:local-credit:smoke": "npm run local-credit:smoke --prefix services/bridge-relayer", "bridge:observe": "node services/bridge-relayer/src/observe-base-lockbox.ts" }, "devDependencies": { diff --git a/schemas/flowmemory/bridge-credit-set.schema.json b/schemas/flowmemory/bridge-credit-set.schema.json new file mode 100644 index 00000000..c1a55c33 --- /dev/null +++ b/schemas/flowmemory/bridge-credit-set.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-credit-set.schema.json", + "title": "FlowChainBridgeCreditSet", + "type": "object", + "additionalProperties": false, + "required": ["schema", "creditSetId", "generatedAt", "count", "credits", "productionReady"], + "properties": { + "schema": { "const": "flowmemory.bridge_credit_set.v0" }, + "creditSetId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "generatedAt": { "type": "string" }, + "count": { "type": "integer", "minimum": 0 }, + "credits": { + "type": "array", + "items": { "$ref": "bridge-credit.schema.json" } + }, + "productionReady": { "const": false } + } +} diff --git a/schemas/flowmemory/bridge-credit.schema.json b/schemas/flowmemory/bridge-credit.schema.json index 2ef17891..9dbbfe2f 100644 --- a/schemas/flowmemory/bridge-credit.schema.json +++ b/schemas/flowmemory/bridge-credit.schema.json @@ -1,36 +1,86 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "flowchain.bridge_credit.v0", - "title": "FlowChain Local Alpha BridgeCredit V0", - "type": "object", - "additionalProperties": false, - "required": [ - "schema", - "creditId", - "depositId", - "recipient", - "assetId", - "amount", - "creditedAtBlockNumber", - "creditedAtUnixMs", - "status", - "statusCode", - "nonce" + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-credit.schema.json", + "title": "FlowChainBridgeCredit", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "creditId", + "depositId", + "recipient", + "assetId", + "amount", + "creditedAtBlockNumber", + "creditedAtUnixMs", + "status", + "statusCode", + "nonce" + ], + "properties": { + "schema": { "const": "flowchain.bridge_credit.v0" }, + "creditId": { "$ref": "#/$defs/hex32" }, + "depositId": { "$ref": "#/$defs/hex32" }, + "recipient": { "$ref": "#/$defs/hex32" }, + "assetId": { "$ref": "#/$defs/hex32" }, + "amount": { "$ref": "#/$defs/uintString" }, + "creditedAtBlockNumber": { "$ref": "#/$defs/uintString" }, + "creditedAtUnixMs": { "$ref": "#/$defs/uintString" }, + "status": { "enum": ["credited"] }, + "statusCode": { "const": 3 }, + "nonce": { "$ref": "#/$defs/hex32" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "creditId", + "observationId", + "depositId", + "replayKey", + "source", + "token", + "amount", + "flowchainRecipient", + "status", + "localOnly", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_credit.v0" }, + "creditId": { "$ref": "#/$defs/hex32" }, + "observationId": { "$ref": "#/$defs/hex32" }, + "depositId": { "$ref": "#/$defs/hex32" }, + "replayKey": { "$ref": "#/$defs/hex32" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["chainId", "contract", "txHash", "logIndex"], + "properties": { + "chainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "contract": { "$ref": "#/$defs/address" }, + "txHash": { "$ref": "#/$defs/hex32" }, + "logIndex": { "type": "integer", "minimum": 0 } + } + }, + "token": { "$ref": "#/$defs/address" }, + "amount": { "$ref": "#/$defs/uintString" }, + "flowchainRecipient": { "$ref": "#/$defs/hex32" }, + "status": { "type": "string", "enum": ["pending", "applied", "rejected"] }, + "pendingReason": { "type": "string" }, + "appliedAt": { "type": "string" }, + "rejectionReason": { "type": "string" }, + "localOnly": { "const": true }, + "productionReady": { "const": false } + } + } ], - "properties": { - "schema": { "const": "flowchain.bridge_credit.v0" }, - "creditId": { "$ref": "#/$defs/hex32" }, - "depositId": { "$ref": "#/$defs/hex32" }, - "recipient": { "$ref": "#/$defs/hex32" }, - "assetId": { "$ref": "#/$defs/hex32" }, - "amount": { "$ref": "#/$defs/uintString" }, - "creditedAtBlockNumber": { "$ref": "#/$defs/uintString" }, - "creditedAtUnixMs": { "$ref": "#/$defs/uintString" }, - "status": { "enum": ["credited"] }, - "statusCode": { "const": 3 }, - "nonce": { "$ref": "#/$defs/hex32" } - }, "$defs": { + "address": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, "hex32": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, "uintString": { "type": "string", "pattern": "^[0-9]+$" } } diff --git a/schemas/flowmemory/bridge-deposit.schema.json b/schemas/flowmemory/bridge-deposit.schema.json index 05744cf8..dd42d827 100644 --- a/schemas/flowmemory/bridge-deposit.schema.json +++ b/schemas/flowmemory/bridge-deposit.schema.json @@ -21,10 +21,13 @@ "properties": { "schema": { "const": "flowmemory.bridge_deposit.v0" }, "depositId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, - "sourceChainId": { "type": "integer", "enum": [84532, 8453] }, + "sourceChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, "sourceContract": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, "txHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, "logIndex": { "type": "integer", "minimum": 0 }, + "sourceBlockNumber": { "type": "string", "pattern": "^[0-9]+$" }, + "sourceBlockHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "transactionIndex": { "type": "integer", "minimum": 0 }, "token": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, "amount": { "type": "string", "pattern": "^[0-9]+$" }, "sender": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, diff --git a/schemas/flowmemory/bridge-observation-set.schema.json b/schemas/flowmemory/bridge-observation-set.schema.json new file mode 100644 index 00000000..b85fb578 --- /dev/null +++ b/schemas/flowmemory/bridge-observation-set.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-observation-set.schema.json", + "title": "FlowChainBridgeObservationSet", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "observationSetId", + "observedAt", + "mode", + "productionReady", + "count", + "observations" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_observation_set.v0" }, + "observationSetId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "observedAt": { "type": "string" }, + "mode": { "type": "string", "enum": ["mock", "local-anvil", "base-sepolia", "base-mainnet-canary"] }, + "productionReady": { "const": false }, + "count": { "type": "integer", "minimum": 0 }, + "observations": { + "type": "array", + "items": { "$ref": "bridge-observation.schema.json" } + } + } +} diff --git a/schemas/flowmemory/bridge-observation.schema.json b/schemas/flowmemory/bridge-observation.schema.json index fb29756c..0297b6cb 100644 --- a/schemas/flowmemory/bridge-observation.schema.json +++ b/schemas/flowmemory/bridge-observation.schema.json @@ -7,6 +7,7 @@ "required": [ "schema", "observationId", + "replayKey", "observedAt", "mode", "productionReady", @@ -16,8 +17,9 @@ "properties": { "schema": { "const": "flowmemory.bridge_deposit_observation.v0" }, "observationId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "replayKey": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, "observedAt": { "type": "string" }, - "mode": { "type": "string", "enum": ["mock", "base-sepolia", "base-mainnet-canary"] }, + "mode": { "type": "string", "enum": ["mock", "local-anvil", "base-sepolia", "base-mainnet-canary"] }, "productionReady": { "const": false }, "deposit": { "$ref": "bridge-deposit.schema.json" }, "guardrails": { diff --git a/schemas/flowmemory/bridge-runtime-handoff.schema.json b/schemas/flowmemory/bridge-runtime-handoff.schema.json new file mode 100644 index 00000000..f464934e --- /dev/null +++ b/schemas/flowmemory/bridge-runtime-handoff.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-runtime-handoff.schema.json", + "title": "FlowChainBridgeRuntimeHandoff", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "handoffId", + "generatedAt", + "mode", + "productionReady", + "localOnly", + "observations", + "credits", + "withdrawalIntents", + "replayProtection", + "runtimeIntake", + "workbenchTimeline", + "workbenchRecords", + "limitations" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_runtime_handoff.v0" }, + "handoffId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "generatedAt": { "type": "string" }, + "mode": { "type": "string", "enum": ["mock", "local-anvil", "base-sepolia", "base-mainnet-canary"] }, + "productionReady": { "const": false }, + "localOnly": { "const": true }, + "observations": { + "type": "array", + "items": { "$ref": "bridge-observation.schema.json" } + }, + "credits": { + "type": "array", + "items": { "$ref": "bridge-credit.schema.json" } + }, + "withdrawalIntents": { + "type": "array", + "items": { "$ref": "bridge-withdrawal-intent.schema.json" } + }, + "replayProtection": { + "type": "object", + "additionalProperties": false, + "required": ["strategy", "replayKeys", "duplicateReplayKeys"], + "properties": { + "strategy": { "const": "source-chain-contract-tx-log-deposit" }, + "replayKeys": { + "type": "array", + "items": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uniqueItems": true + }, + "duplicateReplayKeys": { + "type": "array", + "items": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uniqueItems": true + } + } + }, + "runtimeIntake": { + "type": "object", + "additionalProperties": false, + "required": ["status", "consumer", "expectedPath", "note"], + "properties": { + "status": { "const": "handoff_file" }, + "consumer": { "const": "flowchain-runtime-agent" }, + "expectedPath": { "type": "string" }, + "note": { "type": "string" } + } + }, + "workbenchTimeline": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["phase", "status", "objectId", "title", "summary"], + "properties": { + "phase": { + "type": "string", + "enum": ["deposit_observed", "credit_pending", "credit_applied", "withdrawal_requested"] + }, + "status": { "type": "string", "enum": ["observed", "pending", "applied", "requested"] }, + "objectId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "title": { "type": "string" }, + "summary": { "type": "string" } + } + } + }, + "workbenchRecords": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["sectionKey", "id", "kind", "title", "summary", "status", "facts", "rawRef"], + "properties": { + "sectionKey": { "type": "string", "enum": ["transactions", "receipts", "finality", "rawJson"] }, + "id": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "kind": { "type": "string" }, + "title": { "type": "string" }, + "summary": { "type": "string" }, + "status": { "type": "string", "enum": ["observed", "pending", "verified"] }, + "facts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["label", "value"], + "properties": { + "label": { "type": "string" }, + "value": { "type": "string" } + } + } + }, + "rawRef": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" } + } + } + }, + "limitations": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json b/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json new file mode 100644 index 00000000..cb14b3d6 --- /dev/null +++ b/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json", + "title": "FlowChainBridgeWithdrawalIntentSet", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "withdrawalIntentSetId", + "generatedAt", + "count", + "withdrawalIntents", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_withdrawal_intent_set.v0" }, + "withdrawalIntentSetId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "generatedAt": { "type": "string" }, + "count": { "type": "integer", "minimum": 0 }, + "withdrawalIntents": { + "type": "array", + "items": { "$ref": "bridge-withdrawal-intent.schema.json" } + }, + "productionReady": { "const": false } + } +} diff --git a/schemas/flowmemory/bridge-withdrawal-intent.schema.json b/schemas/flowmemory/bridge-withdrawal-intent.schema.json new file mode 100644 index 00000000..72e12e43 --- /dev/null +++ b/schemas/flowmemory/bridge-withdrawal-intent.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-withdrawal-intent.schema.json", + "title": "FlowChainBridgeWithdrawalIntent", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "withdrawalIntentId", + "creditId", + "depositId", + "sourceChainId", + "destinationChainId", + "token", + "amount", + "flowchainAccount", + "baseRecipient", + "status", + "requestedAt", + "testMode", + "broadcast", + "releasePolicy", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_withdrawal_intent.v0" }, + "withdrawalIntentId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "creditId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "depositId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "sourceChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "destinationChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "token": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "amount": { "type": "string", "pattern": "^[0-9]+$" }, + "flowchainAccount": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "baseRecipient": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "status": { "type": "string", "enum": ["requested", "cancelled", "released_test_record", "rejected"] }, + "requestedAt": { "type": "string" }, + "testMode": { "const": true }, + "broadcast": { "const": false }, + "releasePolicy": { "const": "test_record_only" }, + "productionReady": { "const": false } + } +} diff --git a/schemas/flowmemory/hardware-control-plane-handoff.schema.json b/schemas/flowmemory/hardware-control-plane-handoff.schema.json new file mode 100644 index 00000000..dd4bb478 --- /dev/null +++ b/schemas/flowmemory/hardware-control-plane-handoff.schema.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "flowmemory.hardware_control_plane_handoff.local_alpha.v0", + "title": "FlowMemory Hardware Control-Plane Handoff Local Alpha", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "generatedAt", + "chainId", + "environment", + "sourceFixture", + "hardwareRequiredForPrivateTestnet", + "mode", + "boundary", + "ingest", + "collections", + "workbenchRecords", + "optionalSmokeRows" + ], + "properties": { + "schema": { "type": "string", "enum": ["flowmemory.hardware_control_plane_handoff.local_alpha.v0"] }, + "generatedAt": { "type": "string" }, + "chainId": { "type": "string", "enum": ["flowmemory-local-alpha"] }, + "environment": { "type": "string", "enum": ["local-devnet-fixture"] }, + "sourceFixture": { "type": "string", "maxLength": 128 }, + "hardwareRequiredForPrivateTestnet": { "type": "boolean", "enum": [false] }, + "mode": { "type": "string", "enum": ["read-only-optional-merge"] }, + "boundary": { + "type": "object", + "additionalProperties": false, + "required": [ + "localOnly", + "advisory", + "normalNetworkReconciliationRequired", + "hardwareRequiredForPrivateTestnet", + "claimLimitations" + ], + "properties": { + "localOnly": { "type": "boolean", "enum": [true] }, + "advisory": { "type": "boolean", "enum": [true] }, + "normalNetworkReconciliationRequired": { "type": "boolean", "enum": [true] }, + "hardwareRequiredForPrivateTestnet": { "type": "boolean", "enum": [false] }, + "claimLimitations": { "type": "array", "items": { "type": "string", "maxLength": 180 } } + } + }, + "ingest": { + "type": "object", + "additionalProperties": false, + "required": [ + "stateKeys", + "mergePolicy", + "idFields", + "localOnly", + "normalNetworkReconciliationRequired" + ], + "properties": { + "stateKeys": { "type": "array", "items": { "type": "string", "maxLength": 64 } }, + "mergePolicy": { "type": "string", "enum": ["replace-by-stable-id"] }, + "idFields": { + "type": "object", + "additionalProperties": false, + "required": [ + "hardwareSignals", + "operatorMetadata", + "hardwareNodes", + "workReceipts", + "verifierReports", + "bridgeAlerts", + "artifactCommitments", + "memoryCells", + "challenges", + "finalityReceipts", + "alerts" + ], + "properties": { + "hardwareSignals": { "type": "string", "enum": ["signalId"] }, + "operatorMetadata": { "type": "string", "enum": ["metadataId"] }, + "hardwareNodes": { "type": "string", "enum": ["nodeId"] }, + "workReceipts": { "type": "string", "enum": ["receiptId"] }, + "verifierReports": { "type": "string", "enum": ["reportId"] }, + "bridgeAlerts": { "type": "string", "enum": ["bridgeAlertId"] }, + "artifactCommitments": { "type": "string", "enum": ["artifactId"] }, + "memoryCells": { "type": "string", "enum": ["memoryCellId"] }, + "challenges": { "type": "string", "enum": ["challengeId"] }, + "finalityReceipts": { "type": "string", "enum": ["finalityReceiptId"] }, + "alerts": { "type": "string", "enum": ["incidentId"] } + } + }, + "localOnly": { "type": "boolean", "enum": [true] }, + "normalNetworkReconciliationRequired": { "type": "boolean", "enum": [true] } + } + }, + "collections": { + "type": "object", + "additionalProperties": false, + "required": [ + "hardwareSignals", + "operatorMetadata", + "hardwareNodes", + "workReceipts", + "verifierReports", + "bridgeAlerts", + "artifactCommitments", + "memoryCells", + "challenges", + "finalityReceipts", + "alerts" + ], + "properties": { + "hardwareSignals": { "type": "array", "items": { "type": "object" } }, + "operatorMetadata": { "type": "array", "items": { "type": "object" } }, + "hardwareNodes": { "type": "array", "items": { "type": "object" } }, + "workReceipts": { "type": "array", "items": { "type": "object" } }, + "verifierReports": { "type": "array", "items": { "type": "object" } }, + "bridgeAlerts": { "type": "array", "items": { "type": "object" } }, + "artifactCommitments": { "type": "array", "items": { "type": "object" } }, + "memoryCells": { "type": "array", "items": { "type": "object" } }, + "challenges": { "type": "array", "items": { "type": "object" } }, + "finalityReceipts": { "type": "array", "items": { "type": "object" } }, + "alerts": { "type": "array", "items": { "type": "object" } } + } + }, + "workbenchRecords": { + "type": "object", + "additionalProperties": false, + "required": [ + "operatorMetadata", + "receipts", + "verifierReports", + "bridgeAlerts", + "artifacts", + "memoryCells", + "challenges", + "hardwareSignals", + "provenance" + ], + "properties": { + "operatorMetadata": { "type": "array", "items": { "type": "object" } }, + "receipts": { "type": "array", "items": { "type": "object" } }, + "verifierReports": { "type": "array", "items": { "type": "object" } }, + "bridgeAlerts": { "type": "array", "items": { "type": "object" } }, + "artifacts": { "type": "array", "items": { "type": "object" } }, + "memoryCells": { "type": "array", "items": { "type": "object" } }, + "challenges": { "type": "array", "items": { "type": "object" } }, + "hardwareSignals": { "type": "array", "items": { "type": "object" } }, + "provenance": { "type": "array", "items": { "type": "object" } } + } + }, + "optionalSmokeRows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["label", "command", "requiredForChainProgress", "hardwareRequired"], + "properties": { + "label": { "type": "string", "maxLength": 96 }, + "command": { "type": "string", "maxLength": 128 }, + "requiredForChainProgress": { "type": "boolean", "enum": [false] }, + "hardwareRequired": { "type": "boolean", "enum": [false] } + } + } + } + } +} diff --git a/schemas/flowmemory/local-account-balance.schema.json b/schemas/flowmemory/local-account-balance.schema.json new file mode 100644 index 00000000..7367a653 --- /dev/null +++ b/schemas/flowmemory/local-account-balance.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "flowchain.local_account_balance.v0", + "title": "FlowChain LocalAccountBalance V0", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "balanceId", + "chainId", + "accountId", + "assetId", + "available", + "locked", + "stateNonce", + "balanceRoot", + "status" + ], + "properties": { + "schema": { "const": "flowchain.local_account_balance.v0" }, + "balanceId": { "$ref": "#/$defs/hex32" }, + "chainId": { "$ref": "#/$defs/uintString" }, + "accountId": { "$ref": "#/$defs/hex32" }, + "assetId": { "$ref": "#/$defs/hex32" }, + "available": { "$ref": "#/$defs/uintString" }, + "locked": { "$ref": "#/$defs/uintString" }, + "stateNonce": { "$ref": "#/$defs/uintString" }, + "balanceRoot": { "$ref": "#/$defs/hex32" }, + "status": { "enum": ["active", "frozen", "closed"] } + }, + "$defs": { + "hex32": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uintString": { "type": "string", "pattern": "^[0-9]+$" } + } +} diff --git a/schemas/flowmemory/local-wallet-public-metadata.schema.json b/schemas/flowmemory/local-wallet-public-metadata.schema.json new file mode 100644 index 00000000..14af5b26 --- /dev/null +++ b/schemas/flowmemory/local-wallet-public-metadata.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "flowchain.local_wallet_public_metadata.v0", + "title": "FlowChain Local Wallet Public Metadata V0", + "type": "object", + "additionalProperties": false, + "required": ["schema", "vaultId", "createdAtUnixMs", "updatedAtUnixMs", "accounts"], + "properties": { + "schema": { "const": "flowchain.local_wallet_public_metadata.v0" }, + "vaultId": { "$ref": "#/$defs/hex32" }, + "createdAtUnixMs": { "$ref": "#/$defs/uintString" }, + "updatedAtUnixMs": { "$ref": "#/$defs/uintString" }, + "accounts": { + "type": "array", + "items": { "$ref": "#/$defs/publicAccount" } + }, + "importedAccounts": { + "type": "array", + "items": { "$ref": "#/$defs/importedAccount" } + } + }, + "$defs": { + "hex32": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uintString": { "type": "string", "pattern": "^[0-9]+$" }, + "publicAccount": { + "type": "object", + "additionalProperties": false, + "required": [ + "accountId", + "signerId", + "signerKeyId", + "signerRole", + "signerRoleCode", + "publicKey", + "label", + "status", + "createdAtUnixMs", + "nextNonce" + ], + "properties": { + "accountId": { "$ref": "#/$defs/hex32" }, + "signerId": { "$ref": "#/$defs/hex32" }, + "signerKeyId": { "$ref": "#/$defs/hex32" }, + "signerRole": { "enum": ["operator", "agent", "verifier", "hardware"] }, + "signerRoleCode": { "type": "integer", "minimum": 1, "maximum": 255 }, + "publicKey": { "type": "string", "pattern": "^0x([0-9a-fA-F]{66}|[0-9a-fA-F]{130})$" }, + "label": { "type": "string", "minLength": 1 }, + "status": { "enum": ["active", "rotated", "revoked"] }, + "createdAtUnixMs": { "$ref": "#/$defs/uintString" }, + "nextNonce": { "$ref": "#/$defs/uintString" } + } + }, + "importedAccount": { + "type": "object", + "additionalProperties": false, + "required": [ + "accountId", + "signerId", + "signerKeyId", + "signerRole", + "signerRoleCode", + "publicKey", + "label", + "status", + "createdAtUnixMs", + "nextNonce", + "importedFromVaultId", + "importedAtUnixMs" + ], + "properties": { + "accountId": { "$ref": "#/$defs/hex32" }, + "signerId": { "$ref": "#/$defs/hex32" }, + "signerKeyId": { "$ref": "#/$defs/hex32" }, + "signerRole": { "enum": ["operator", "agent", "verifier", "hardware"] }, + "signerRoleCode": { "type": "integer", "minimum": 1, "maximum": 255 }, + "publicKey": { "type": "string", "pattern": "^0x([0-9a-fA-F]{66}|[0-9a-fA-F]{130})$" }, + "label": { "type": "string", "minLength": 1 }, + "status": { "enum": ["active", "rotated", "revoked"] }, + "createdAtUnixMs": { "$ref": "#/$defs/uintString" }, + "nextNonce": { "$ref": "#/$defs/uintString" }, + "importedFromVaultId": { "$ref": "#/$defs/hex32" }, + "importedAtUnixMs": { "$ref": "#/$defs/uintString" } + } + } + } +} diff --git a/script/DeployBridgeSpine.s.sol b/script/DeployBridgeSpine.s.sol new file mode 100644 index 00000000..0792e9b1 --- /dev/null +++ b/script/DeployBridgeSpine.s.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BaseBridgeLockbox} from "../contracts/bridge/BaseBridgeLockbox.sol"; +import {FlowChainSettlementSpine} from "../contracts/FlowChainSettlementSpine.sol"; + +interface BridgeSpineVm { + function startBroadcast(address signer) external; + function stopBroadcast() external; + function envAddress(string calldata key) external returns (address value); + function envBool(string calldata key) external returns (bool value); + function envUint(string calldata key) external returns (uint256 value); +} + +/// @title DeployBridgeSpine +/// @notice Foundry script for local Anvil and Base Sepolia bridge-spine testing. +/// @dev Dry-run with `forge script` by default. Add `--broadcast` only after +/// setting explicit test environment variables. +contract DeployBridgeSpine { + BridgeSpineVm private constant VM = BridgeSpineVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + struct Deployment { + address lockbox; + address settlementSpine; + address owner; + address releaseAuthority; + address settlementSubmitter; + address erc20Token; + bool allowNative; + bool allowErc20; + } + + struct Config { + address owner; + address releaseAuthority; + address settlementSubmitter; + address erc20Token; + bool allowNative; + bool allowErc20; + uint256 nativePerDepositCap; + uint256 nativeTotalCap; + uint256 erc20PerDepositCap; + uint256 erc20TotalCap; + } + + error Erc20TokenRequired(); + + event FlowChainBridgeSpineDeployed( + address indexed lockbox, + address indexed settlementSpine, + address indexed owner, + address releaseAuthority, + address settlementSubmitter, + address erc20Token, + bool allowNative, + bool allowErc20 + ); + + function run() external returns (Deployment memory deployment) { + Config memory config = _readConfig(); + + if (config.allowErc20 && config.erc20Token == address(0)) { + revert Erc20TokenRequired(); + } + + VM.startBroadcast(config.owner); + + BaseBridgeLockbox lockbox = new BaseBridgeLockbox(config.owner, config.releaseAuthority); + FlowChainSettlementSpine settlementSpine = new FlowChainSettlementSpine(config.owner); + + if (config.allowNative) { + lockbox.configureToken(address(0), true, config.nativePerDepositCap, config.nativeTotalCap); + } + if (config.allowErc20) { + lockbox.configureToken(config.erc20Token, true, config.erc20PerDepositCap, config.erc20TotalCap); + } + if (config.settlementSubmitter != config.owner) { + settlementSpine.setSubmitterAuthorization(config.settlementSubmitter, true); + } + + deployment = Deployment({ + lockbox: address(lockbox), + settlementSpine: address(settlementSpine), + owner: config.owner, + releaseAuthority: config.releaseAuthority, + settlementSubmitter: config.settlementSubmitter, + erc20Token: config.erc20Token, + allowNative: config.allowNative, + allowErc20: config.allowErc20 + }); + + emit FlowChainBridgeSpineDeployed( + address(lockbox), + address(settlementSpine), + config.owner, + config.releaseAuthority, + config.settlementSubmitter, + config.erc20Token, + config.allowNative, + config.allowErc20 + ); + + VM.stopBroadcast(); + } + + function _readConfig() private returns (Config memory config) { + config = Config({ + owner: VM.envAddress("FLOWCHAIN_BRIDGE_OWNER"), + releaseAuthority: VM.envAddress("FLOWCHAIN_BRIDGE_RELEASE_AUTHORITY"), + settlementSubmitter: VM.envAddress("FLOWCHAIN_SETTLEMENT_SUBMITTER"), + erc20Token: VM.envAddress("FLOWCHAIN_BRIDGE_ERC20_TOKEN"), + allowNative: VM.envBool("FLOWCHAIN_BRIDGE_ALLOW_NATIVE"), + allowErc20: VM.envBool("FLOWCHAIN_BRIDGE_ALLOW_ERC20"), + nativePerDepositCap: VM.envUint("FLOWCHAIN_BRIDGE_NATIVE_PER_DEPOSIT_CAP"), + nativeTotalCap: VM.envUint("FLOWCHAIN_BRIDGE_NATIVE_TOTAL_CAP"), + erc20PerDepositCap: VM.envUint("FLOWCHAIN_BRIDGE_ERC20_PER_DEPOSIT_CAP"), + erc20TotalCap: VM.envUint("FLOWCHAIN_BRIDGE_ERC20_TOTAL_CAP") + }); + } +} diff --git a/services/bridge-relayer/package.json b/services/bridge-relayer/package.json index 5319660f..d6580122 100644 --- a/services/bridge-relayer/package.json +++ b/services/bridge-relayer/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "scripts": { - "mock": "node src/observe-base-lockbox.ts --fixture ../../fixtures/bridge/base-sepolia-mock-deposit.json --out out/bridge-observation.json", + "mock": "node src/observe-base-lockbox.ts --mode mock --fixture ../../fixtures/bridge/base-sepolia-mock-deposit.json --out out/bridge-observation.json --credit-out out/bridge-credit.json --handoff-out out/bridge-runtime-handoff.json", + "local-credit:smoke": "node src/observe-base-lockbox.ts --mode mock --fixture ../../fixtures/bridge/base-sepolia-mock-deposit.json --out out/local-credit-observation.json --credit-out out/local-credit.json --handoff-out ../../fixtures/bridge/local-runtime-bridge-handoff.json --withdrawal-out out/local-withdrawal-intent.json --apply-credit --withdrawal-intent", "test": "node --test test/*.test.ts" } } diff --git a/services/bridge-relayer/src/observe-base-lockbox.ts b/services/bridge-relayer/src/observe-base-lockbox.ts index dcf09fec..c4234953 100644 --- a/services/bridge-relayer/src/observe-base-lockbox.ts +++ b/services/bridge-relayer/src/observe-base-lockbox.ts @@ -2,20 +2,46 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { canonicalJson, keccak256Utf8 } from "../../shared/src/index.ts"; +import { + canonicalJson, + decodeAddressTopic, + decodeBytes32Word, + decodeUint256Word, + hexToBytes, + keccak256Utf8, + normalizeAddress, + normalizeBytes32, +} from "../../shared/src/index.ts"; export const BASE_MAINNET_CHAIN_ID = 8453; export const BASE_SEPOLIA_CHAIN_ID = 84532; +export const LOCAL_ANVIL_CHAIN_ID = 31337; export const MAX_CANARY_USD = 25; export const MAX_BLOCK_RANGE = 5_000n; +export const BRIDGE_DEPOSIT_EVENT_SIGNATURE_TEXT = + "BridgeDeposit(bytes32,uint256,address,address,uint256,bytes32,uint256,bytes32)"; +export const BRIDGE_DEPOSIT_TOPIC0 = keccak256Utf8(BRIDGE_DEPOSIT_EVENT_SIGNATURE_TEXT); +export const FIXED_TEST_OBSERVED_AT = "2026-05-13T00:00:00.000Z"; + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue | undefined }; + +export type BridgeSourceChainId = + | typeof LOCAL_ANVIL_CHAIN_ID + | typeof BASE_SEPOLIA_CHAIN_ID + | typeof BASE_MAINNET_CHAIN_ID; + +export type BridgeMode = "mock" | "local-anvil" | "base-sepolia" | "base-mainnet-canary"; export interface BridgeDeposit { schema: "flowmemory.bridge_deposit.v0"; depositId: `0x${string}`; - sourceChainId: 84532 | 8453; + sourceChainId: BridgeSourceChainId; sourceContract: `0x${string}`; txHash: `0x${string}`; logIndex: number; + sourceBlockNumber?: string; + sourceBlockHash?: `0x${string}`; + transactionIndex?: number; token: `0x${string}`; amount: string; sender: `0x${string}`; @@ -28,8 +54,9 @@ export interface BridgeDeposit { export interface BridgeObservation { schema: "flowmemory.bridge_deposit_observation.v0"; observationId: `0x${string}`; + replayKey: `0x${string}`; observedAt: string; - mode: "mock" | "base-sepolia" | "base-mainnet-canary"; + mode: BridgeMode; productionReady: false; deposit: BridgeDeposit; guardrails: { @@ -41,16 +68,157 @@ export interface BridgeObservation { }; } +export interface BridgeObservationSet { + schema: "flowmemory.bridge_observation_set.v0"; + observationSetId: `0x${string}`; + observedAt: string; + mode: BridgeMode; + productionReady: false; + count: number; + observations: BridgeObservation[]; +} + +export interface BridgeCredit { + schema: "flowmemory.bridge_credit.v0"; + creditId: `0x${string}`; + observationId: `0x${string}`; + depositId: `0x${string}`; + replayKey: `0x${string}`; + source: { + chainId: BridgeSourceChainId; + contract: `0x${string}`; + txHash: `0x${string}`; + logIndex: number; + }; + token: `0x${string}`; + amount: string; + flowchainRecipient: `0x${string}`; + status: "pending" | "applied" | "rejected"; + pendingReason?: string; + appliedAt?: string; + rejectionReason?: string; + localOnly: true; + productionReady: false; +} + +export interface BridgeCreditSet { + schema: "flowmemory.bridge_credit_set.v0"; + creditSetId: `0x${string}`; + generatedAt: string; + count: number; + credits: BridgeCredit[]; + productionReady: false; +} + +export interface BridgeWithdrawalIntent { + schema: "flowmemory.bridge_withdrawal_intent.v0"; + withdrawalIntentId: `0x${string}`; + creditId: `0x${string}`; + depositId: `0x${string}`; + sourceChainId: BridgeSourceChainId; + destinationChainId: BridgeSourceChainId; + token: `0x${string}`; + amount: string; + flowchainAccount: `0x${string}`; + baseRecipient: `0x${string}`; + status: "requested" | "cancelled" | "released_test_record" | "rejected"; + requestedAt: string; + testMode: true; + broadcast: false; + releasePolicy: "test_record_only"; + productionReady: false; +} + +export interface BridgeWithdrawalIntentSet { + schema: "flowmemory.bridge_withdrawal_intent_set.v0"; + withdrawalIntentSetId: `0x${string}`; + generatedAt: string; + count: number; + withdrawalIntents: BridgeWithdrawalIntent[]; + productionReady: false; +} + +export interface BridgeRuntimeHandoff { + schema: "flowmemory.bridge_runtime_handoff.v0"; + handoffId: `0x${string}`; + generatedAt: string; + mode: BridgeMode; + productionReady: false; + localOnly: true; + observations: BridgeObservation[]; + credits: BridgeCredit[]; + withdrawalIntents: BridgeWithdrawalIntent[]; + replayProtection: { + strategy: "source-chain-contract-tx-log-deposit"; + replayKeys: `0x${string}`[]; + duplicateReplayKeys: `0x${string}`[]; + }; + runtimeIntake: { + status: "handoff_file"; + consumer: "flowchain-runtime-agent"; + expectedPath: string; + note: string; + }; + workbenchTimeline: { + phase: "deposit_observed" | "credit_pending" | "credit_applied" | "withdrawal_requested"; + status: "observed" | "pending" | "applied" | "requested"; + objectId: `0x${string}`; + title: string; + summary: string; + }[]; + workbenchRecords: { + sectionKey: "transactions" | "receipts" | "finality" | "rawJson"; + id: `0x${string}`; + kind: string; + title: string; + summary: string; + status: "observed" | "pending" | "verified"; + facts: { label: string; value: string }[]; + rawRef: `0x${string}`; + }[]; + limitations: string[]; +} + interface CliOptions { - mode: "mock" | "base-sepolia" | "base-mainnet-canary"; + mode: BridgeMode; fixturePath?: string; outPath: string; + creditOutPath?: string; + handoffOutPath?: string; + withdrawalOutPath?: string; rpcUrl?: string; lockboxAddress?: `0x${string}`; fromBlock?: string; toBlock?: string; + expectedChainId?: BridgeSourceChainId; acknowledgeRealFunds: boolean; maxUsd?: number; + applyCredit: boolean; + withdrawalIntent: boolean; + withdrawalBaseRecipient?: `0x${string}`; +} + +export interface BridgePipelineResult { + observations: BridgeObservation[]; + credits: BridgeCredit[]; + withdrawalIntents: BridgeWithdrawalIntent[]; + handoff: BridgeRuntimeHandoff; +} + +interface RpcLog { + address: string; + topics: string[]; + data: string; + blockNumber?: string; + blockHash?: string; + transactionHash: string; + transactionIndex?: string; + logIndex: string; + removed?: boolean; +} + +function stableId(schema: string, value: JsonValue): `0x${string}` { + return keccak256Utf8(canonicalJson({ schema, value })); } function argValue(args: string[], index: number, name: string): string { @@ -62,17 +230,35 @@ function argValue(args: string[], index: number, name: string): string { } function asAddress(value: string, name: string): `0x${string}` { - if (!/^0x[0-9a-fA-F]{40}$/.test(value)) { + try { + return normalizeAddress(value) as `0x${string}`; + } catch { throw new Error(`${name} must be a 20-byte hex address`); } - return value.toLowerCase() as `0x${string}`; } function asHash(value: string, name: string): `0x${string}` { - if (!/^0x[0-9a-fA-F]{64}$/.test(value)) { + try { + return normalizeBytes32(value) as `0x${string}`; + } catch { throw new Error(`${name} must be a 32-byte hex value`); } - return value.toLowerCase() as `0x${string}`; +} + +function asDecimalString(value: unknown, name: string): string { + const text = String(value); + if (!/^[0-9]+$/.test(text)) { + throw new Error(`${name} must be a decimal string`); + } + return text; +} + +function asNonNegativeInteger(value: unknown, name: string): number { + const number = Number(value); + if (!Number.isInteger(number) || number < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return number; } function asBlock(value: string, name: string): bigint { @@ -82,23 +268,60 @@ function asBlock(value: string, name: string): bigint { return BigInt(value); } +function asSourceChainId(value: unknown, name: string): BridgeSourceChainId { + const chainId = Number(value); + if ( + chainId !== LOCAL_ANVIL_CHAIN_ID + && chainId !== BASE_SEPOLIA_CHAIN_ID + && chainId !== BASE_MAINNET_CHAIN_ID + ) { + throw new Error(`${name} must be ${LOCAL_ANVIL_CHAIN_ID}, ${BASE_SEPOLIA_CHAIN_ID}, or ${BASE_MAINNET_CHAIN_ID}`); + } + return chainId as BridgeSourceChainId; +} + +function expectedChainIdForMode(mode: BridgeMode, explicit?: BridgeSourceChainId): BridgeSourceChainId { + if (explicit !== undefined) { + return explicit; + } + if (mode === "local-anvil") { + return LOCAL_ANVIL_CHAIN_ID; + } + if (mode === "base-sepolia") { + return BASE_SEPOLIA_CHAIN_ID; + } + return BASE_MAINNET_CHAIN_ID; +} + export function parseBridgeArgs(args: string[]): CliOptions { let mode: CliOptions["mode"] = "mock"; let fixturePath: string | undefined; let outPath = "out/bridge-observation.json"; + let creditOutPath: string | undefined; + let handoffOutPath: string | undefined; + let withdrawalOutPath: string | undefined; let rpcUrl: string | undefined; let lockboxAddress: `0x${string}` | undefined; let fromBlock: string | undefined; let toBlock: string | undefined; + let expectedChainId: BridgeSourceChainId | undefined; let acknowledgeRealFunds = false; let maxUsd: number | undefined; + let applyCredit = false; + let withdrawalIntent = false; + let withdrawalBaseRecipient: `0x${string}` | undefined; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--mode") { const value = argValue(args, index, arg); - if (value !== "mock" && value !== "base-sepolia" && value !== "base-mainnet-canary") { - throw new Error("--mode must be mock, base-sepolia, or base-mainnet-canary"); + if ( + value !== "mock" + && value !== "local-anvil" + && value !== "base-sepolia" + && value !== "base-mainnet-canary" + ) { + throw new Error("--mode must be mock, local-anvil, base-sepolia, or base-mainnet-canary"); } mode = value; index += 1; @@ -108,6 +331,15 @@ export function parseBridgeArgs(args: string[]): CliOptions { } else if (arg === "--out") { outPath = argValue(args, index, arg); index += 1; + } else if (arg === "--credit-out") { + creditOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--handoff-out") { + handoffOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--withdrawal-out") { + withdrawalOutPath = argValue(args, index, arg); + index += 1; } else if (arg === "--rpc-url") { rpcUrl = argValue(args, index, arg); index += 1; @@ -120,11 +352,21 @@ export function parseBridgeArgs(args: string[]): CliOptions { } else if (arg === "--to-block") { toBlock = argValue(args, index, arg); index += 1; + } else if (arg === "--expected-chain-id") { + expectedChainId = asSourceChainId(argValue(args, index, arg), arg); + index += 1; } else if (arg === "--acknowledge-real-funds") { acknowledgeRealFunds = true; } else if (arg === "--max-usd") { maxUsd = Number(argValue(args, index, arg)); index += 1; + } else if (arg === "--apply-credit") { + applyCredit = true; + } else if (arg === "--withdrawal-intent") { + withdrawalIntent = true; + } else if (arg === "--withdrawal-base-recipient") { + withdrawalBaseRecipient = asAddress(argValue(args, index, arg), arg); + index += 1; } else { throw new Error(`unknown argument: ${arg}`); } @@ -136,7 +378,7 @@ export function parseBridgeArgs(args: string[]): CliOptions { if (mode !== "mock") { if (!rpcUrl || !lockboxAddress || !fromBlock || !toBlock) { - throw new Error("--rpc-url, --lockbox-address, --from-block, and --to-block are required for Base reads"); + throw new Error("--rpc-url, --lockbox-address, --from-block, and --to-block are required for RPC reads"); } const from = asBlock(fromBlock, "--from-block"); const to = asBlock(toBlock, "--to-block"); @@ -161,12 +403,19 @@ export function parseBridgeArgs(args: string[]): CliOptions { mode, fixturePath, outPath, + creditOutPath, + handoffOutPath, + withdrawalOutPath, rpcUrl, lockboxAddress, fromBlock, toBlock, + expectedChainId, acknowledgeRealFunds, maxUsd, + applyCredit, + withdrawalIntent, + withdrawalBaseRecipient, }; } @@ -178,36 +427,72 @@ export function validateDeposit(value: unknown): BridgeDeposit { if (deposit.schema !== "flowmemory.bridge_deposit.v0") { throw new Error("unsupported bridge deposit schema"); } + const status = deposit.status; + if (status !== "observed") { + throw new Error("fixture status must be observed"); + } + return { schema: "flowmemory.bridge_deposit.v0", depositId: asHash(String(deposit.depositId), "depositId"), - sourceChainId: deposit.sourceChainId === 8453 ? 8453 : deposit.sourceChainId === 84532 ? 84532 : (() => { - throw new Error("sourceChainId must be 84532 or 8453"); - })(), + sourceChainId: asSourceChainId(deposit.sourceChainId, "sourceChainId"), sourceContract: asAddress(String(deposit.sourceContract), "sourceContract"), txHash: asHash(String(deposit.txHash), "txHash"), - logIndex: Number(deposit.logIndex), + logIndex: asNonNegativeInteger(deposit.logIndex, "logIndex"), + sourceBlockNumber: deposit.sourceBlockNumber === undefined + ? undefined + : asDecimalString(deposit.sourceBlockNumber, "sourceBlockNumber"), + sourceBlockHash: deposit.sourceBlockHash === undefined + ? undefined + : asHash(String(deposit.sourceBlockHash), "sourceBlockHash"), + transactionIndex: deposit.transactionIndex === undefined + ? undefined + : asNonNegativeInteger(deposit.transactionIndex, "transactionIndex"), token: asAddress(String(deposit.token), "token"), - amount: String(deposit.amount), + amount: asDecimalString(deposit.amount, "amount"), sender: asAddress(String(deposit.sender), "sender"), flowchainRecipient: asHash(String(deposit.flowchainRecipient), "flowchainRecipient"), - nonce: String(deposit.nonce), + nonce: asDecimalString(deposit.nonce, "nonce"), metadataHash: deposit.metadataHash === undefined ? undefined : asHash(String(deposit.metadataHash), "metadataHash"), - status: deposit.status === "observed" ? "observed" : (() => { - throw new Error("fixture status must be observed"); - })(), + status, }; } +function fixtureDeposits(fixture: unknown): BridgeDeposit[] { + if (fixture !== null && typeof fixture === "object" && !Array.isArray(fixture)) { + const maybeBatch = fixture as Record; + if (Array.isArray(maybeBatch.deposits)) { + return maybeBatch.deposits.map((entry) => validateDeposit(entry)); + } + } + return [validateDeposit(fixture)]; +} + +export function bridgeReplayKey(deposit: BridgeDeposit): `0x${string}` { + return stableId("flowmemory.bridge_replay_key.v0", { + sourceChainId: deposit.sourceChainId, + sourceContract: deposit.sourceContract, + txHash: deposit.txHash, + logIndex: deposit.logIndex, + depositId: deposit.depositId, + }); +} + export function makeObservation( deposit: BridgeDeposit, mode: BridgeObservation["mode"], maxUsd?: number, ): BridgeObservation { + const replayKey = bridgeReplayKey(deposit); return { schema: "flowmemory.bridge_deposit_observation.v0", - observationId: keccak256Utf8(canonicalJson({ deposit, mode })) as `0x${string}`, - observedAt: "2026-05-13T00:00:00.000Z", + observationId: stableId("flowmemory.bridge_observation.v0", { + mode, + replayKey, + depositId: deposit.depositId, + }), + replayKey, + observedAt: FIXED_TEST_OBSERVED_AT, mode, productionReady: false, deposit, @@ -221,60 +506,487 @@ export function makeObservation( }; } -async function readChainId(rpcUrl: string): Promise { +export function makeObservationSet(observations: BridgeObservation[], mode: BridgeMode): BridgeObservationSet { + return { + schema: "flowmemory.bridge_observation_set.v0", + observationSetId: stableId("flowmemory.bridge_observation_set.v0", { + mode, + observationIds: observations.map((observation) => observation.observationId), + }), + observedAt: FIXED_TEST_OBSERVED_AT, + mode, + productionReady: false, + count: observations.length, + observations, + }; +} + +export function makeBridgeCredit( + observation: BridgeObservation, + status: BridgeCredit["status"] = "pending", + rejectionReason?: string, +): BridgeCredit { + const deposit = observation.deposit; + return { + schema: "flowmemory.bridge_credit.v0", + creditId: stableId("flowmemory.bridge_credit.v0", { + observationId: observation.observationId, + depositId: deposit.depositId, + replayKey: observation.replayKey, + sourceChainId: deposit.sourceChainId, + sourceContract: deposit.sourceContract, + txHash: deposit.txHash, + logIndex: deposit.logIndex, + }), + observationId: observation.observationId, + depositId: deposit.depositId, + replayKey: observation.replayKey, + source: { + chainId: deposit.sourceChainId, + contract: deposit.sourceContract, + txHash: deposit.txHash, + logIndex: deposit.logIndex, + }, + token: deposit.token, + amount: deposit.amount, + flowchainRecipient: deposit.flowchainRecipient, + status, + pendingReason: status === "pending" ? "runtime_intake_pending_handoff_file" : undefined, + appliedAt: status === "applied" ? FIXED_TEST_OBSERVED_AT : undefined, + rejectionReason, + localOnly: true, + productionReady: false, + }; +} + +export function makeCreditSet(credits: BridgeCredit[]): BridgeCreditSet { + return { + schema: "flowmemory.bridge_credit_set.v0", + creditSetId: stableId("flowmemory.bridge_credit_set.v0", { + creditIds: credits.map((credit) => credit.creditId), + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + count: credits.length, + credits, + productionReady: false, + }; +} + +function makeCredits(observations: BridgeObservation[], applyCredit: boolean): BridgeCredit[] { + const seen = new Set<`0x${string}`>(); + return observations.map((observation) => { + if (seen.has(observation.replayKey)) { + return makeBridgeCredit(observation, "rejected", "duplicate_replay_key"); + } + seen.add(observation.replayKey); + return makeBridgeCredit(observation, applyCredit ? "applied" : "pending"); + }); +} + +export function makeWithdrawalIntent( + credit: BridgeCredit, + deposit: BridgeDeposit, + baseRecipient: `0x${string}` = deposit.sender, +): BridgeWithdrawalIntent { + return { + schema: "flowmemory.bridge_withdrawal_intent.v0", + withdrawalIntentId: stableId("flowmemory.bridge_withdrawal_intent.v0", { + creditId: credit.creditId, + depositId: credit.depositId, + destinationChainId: deposit.sourceChainId, + token: credit.token, + amount: credit.amount, + flowchainAccount: credit.flowchainRecipient, + baseRecipient, + testMode: true, + }), + creditId: credit.creditId, + depositId: credit.depositId, + sourceChainId: deposit.sourceChainId, + destinationChainId: deposit.sourceChainId, + token: credit.token, + amount: credit.amount, + flowchainAccount: credit.flowchainRecipient, + baseRecipient, + status: "requested", + requestedAt: FIXED_TEST_OBSERVED_AT, + testMode: true, + broadcast: false, + releasePolicy: "test_record_only", + productionReady: false, + }; +} + +export function makeWithdrawalIntentSet(withdrawalIntents: BridgeWithdrawalIntent[]): BridgeWithdrawalIntentSet { + return { + schema: "flowmemory.bridge_withdrawal_intent_set.v0", + withdrawalIntentSetId: stableId("flowmemory.bridge_withdrawal_intent_set.v0", { + withdrawalIntentIds: withdrawalIntents.map((intent) => intent.withdrawalIntentId), + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + count: withdrawalIntents.length, + withdrawalIntents, + productionReady: false, + }; +} + +function duplicateReplayKeys(observations: BridgeObservation[]): `0x${string}`[] { + const seen = new Set<`0x${string}`>(); + const duplicates = new Set<`0x${string}`>(); + for (const observation of observations) { + if (seen.has(observation.replayKey)) { + duplicates.add(observation.replayKey); + } + seen.add(observation.replayKey); + } + return [...duplicates].sort(); +} + +export function makeRuntimeHandoff( + mode: BridgeMode, + observations: BridgeObservation[], + credits: BridgeCredit[], + withdrawalIntents: BridgeWithdrawalIntent[], + expectedPath = "fixtures/bridge/local-runtime-bridge-handoff.json", +): BridgeRuntimeHandoff { + const normalizedExpectedPath = normalizeHandoffExpectedPath(expectedPath); + const replayKeys = [...new Set(observations.map((observation) => observation.replayKey))].sort() as `0x${string}`[]; + const firstObservation = observations[0]; + const firstCredit = credits[0]; + const firstAppliedCredit = credits.find((credit) => credit.status === "applied"); + const firstWithdrawal = withdrawalIntents[0]; + + const workbenchTimeline: BridgeRuntimeHandoff["workbenchTimeline"] = []; + if (firstObservation !== undefined) { + workbenchTimeline.push({ + phase: "deposit_observed", + status: "observed", + objectId: firstObservation.observationId, + title: "Deposit observed", + summary: `Observed lockbox deposit ${firstObservation.deposit.depositId} on chain ${firstObservation.deposit.sourceChainId}.`, + }); + } + if (firstCredit !== undefined) { + workbenchTimeline.push({ + phase: "credit_pending", + status: "pending", + objectId: firstCredit.creditId, + title: "Credit pending", + summary: `${firstCredit.amount} test units queued for ${firstCredit.flowchainRecipient}.`, + }); + } + if (firstAppliedCredit !== undefined) { + workbenchTimeline.push({ + phase: "credit_applied", + status: "applied", + objectId: firstAppliedCredit.creditId, + title: "Credit applied", + summary: `${firstAppliedCredit.amount} test units applied in local bridge smoke state.`, + }); + } + if (firstWithdrawal !== undefined) { + workbenchTimeline.push({ + phase: "withdrawal_requested", + status: "requested", + objectId: firstWithdrawal.withdrawalIntentId, + title: "Withdrawal requested", + summary: "Test-mode local-to-Base withdrawal intent recorded with no broadcast or real release.", + }); + } + + const workbenchRecords: BridgeRuntimeHandoff["workbenchRecords"] = [ + ...observations.map((observation) => ({ + sectionKey: "transactions" as const, + id: observation.observationId, + kind: "Bridge deposit observation", + title: observation.deposit.txHash, + summary: `Deposit ${observation.deposit.depositId} observed from ${observation.mode}.`, + status: "observed" as const, + facts: [ + { label: "chain", value: String(observation.deposit.sourceChainId) }, + { label: "lockbox", value: observation.deposit.sourceContract }, + { label: "log index", value: String(observation.deposit.logIndex) }, + { label: "amount", value: observation.deposit.amount }, + ], + rawRef: observation.observationId, + })), + ...credits.map((credit) => ({ + sectionKey: "receipts" as const, + id: credit.creditId, + kind: "Bridge credit", + title: credit.creditId, + summary: `Credit ${credit.status} for deposit ${credit.depositId}.`, + status: credit.status === "applied" ? "verified" as const : credit.status === "pending" ? "pending" as const : "observed" as const, + facts: [ + { label: "recipient", value: credit.flowchainRecipient }, + { label: "amount", value: credit.amount }, + { label: "token", value: credit.token }, + { label: "replay key", value: credit.replayKey }, + ], + rawRef: credit.creditId, + })), + ...withdrawalIntents.map((intent) => ({ + sectionKey: "transactions" as const, + id: intent.withdrawalIntentId, + kind: "Bridge withdrawal intent", + title: intent.withdrawalIntentId, + summary: "Test-mode withdrawal intent recorded; no mainnet or real-funds release is broadcast.", + status: "pending" as const, + facts: [ + { label: "base recipient", value: intent.baseRecipient }, + { label: "amount", value: intent.amount }, + { label: "broadcast", value: String(intent.broadcast) }, + { label: "policy", value: intent.releasePolicy }, + ], + rawRef: intent.withdrawalIntentId, + })), + ]; + + return { + schema: "flowmemory.bridge_runtime_handoff.v0", + handoffId: stableId("flowmemory.bridge_runtime_handoff.v0", { + mode, + observationIds: observations.map((observation) => observation.observationId), + creditIds: credits.map((credit) => credit.creditId), + withdrawalIntentIds: withdrawalIntents.map((intent) => intent.withdrawalIntentId), + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + mode, + productionReady: false, + localOnly: true, + observations, + credits, + withdrawalIntents, + replayProtection: { + strategy: "source-chain-contract-tx-log-deposit", + replayKeys, + duplicateReplayKeys: duplicateReplayKeys(observations), + }, + runtimeIntake: { + status: "handoff_file", + consumer: "flowchain-runtime-agent", + expectedPath: normalizedExpectedPath, + note: "Runtime/control-plane bridge intake is not merged in this scope. Consume this file as the deterministic bridge credit handoff.", + }, + workbenchTimeline, + workbenchRecords, + limitations: [ + "Bridge objects are for mock, local Anvil, and Base Sepolia test validation by default.", + "No production bridge readiness, audited security, or trustless finality is claimed.", + "Withdrawal intents are test-mode records only and do not broadcast releases.", + "RPC URLs and private keys are never written to bridge artifacts.", + ], + }; +} + +function normalizeHandoffExpectedPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const marker = "fixtures/bridge/"; + const markerIndex = normalized.lastIndexOf(marker); + return markerIndex >= 0 ? normalized.slice(markerIndex) : normalized; +} + +function hexQuantityToBigInt(value: string, name: string): bigint { + if (!/^0x[0-9a-fA-F]+$/.test(value)) { + throw new Error(`${name} must be an RPC hex quantity`); + } + return BigInt(value); +} + +function hexQuantityToDecimalString(value: string | undefined, name: string): string | undefined { + if (value === undefined) { + return undefined; + } + return hexQuantityToBigInt(value, name).toString(); +} + +function hexQuantityToNumber(value: string | undefined, name: string): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number(hexQuantityToBigInt(value, name)); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`${name} exceeds safe integer range`); + } + return parsed; +} + +function addressFromAbiWord(word: `0x${string}`, name: string): `0x${string}` { + return asAddress(`0x${word.slice(-40)}`, name); +} + +export function parseBridgeDepositLog(log: RpcLog, expectedChainId: BridgeSourceChainId): BridgeDeposit { + if (log.removed) { + throw new Error("removed bridge logs must be handled by a reorg-aware reader"); + } + if (log.topics[0]?.toLowerCase() !== BRIDGE_DEPOSIT_TOPIC0) { + throw new Error("log is not a BaseBridgeLockbox BridgeDeposit event"); + } + const data = hexToBytes(log.data); + if (data.length !== 5 * 32) { + throw new Error(`BridgeDeposit log data must contain 5 ABI words, got ${data.length / 32}`); + } + + const eventChainId = asSourceChainId(Number(BigInt(asHash(log.topics[2] ?? "", "sourceChainId"))), "sourceChainId"); + if (eventChainId !== expectedChainId) { + throw new Error(`BridgeDeposit event chain id mismatch: expected ${expectedChainId}, got ${eventChainId}`); + } + + return { + schema: "flowmemory.bridge_deposit.v0", + depositId: asHash(log.topics[1] ?? "", "depositId"), + sourceChainId: eventChainId, + sourceContract: asAddress(log.address, "sourceContract"), + txHash: asHash(log.transactionHash, "txHash"), + logIndex: Number(hexQuantityToBigInt(log.logIndex, "logIndex")), + sourceBlockNumber: hexQuantityToDecimalString(log.blockNumber, "blockNumber"), + sourceBlockHash: log.blockHash === undefined ? undefined : asHash(log.blockHash, "blockHash"), + transactionIndex: hexQuantityToNumber(log.transactionIndex, "transactionIndex"), + token: addressFromAbiWord(decodeBytes32Word(data, 0), "token"), + amount: decodeUint256Word(data, 1).toString(), + sender: asAddress(decodeAddressTopic(log.topics[3] ?? ""), "sender"), + flowchainRecipient: decodeBytes32Word(data, 2), + nonce: decodeUint256Word(data, 3).toString(), + metadataHash: decodeBytes32Word(data, 4), + status: "observed", + }; +} + +async function rpcCall(rpcUrl: string, method: string, params: JsonValue[]): Promise { const response = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), }); - const payload = await response.json() as { result?: string; error?: unknown }; - if (!response.ok || !payload.result) { - throw new Error("failed to read chain id from explicit RPC URL"); + const payload = await response.json() as { result?: T; error?: { message?: string } }; + if (!response.ok || payload.error !== undefined || payload.result === undefined) { + throw new Error(`RPC ${method} failed: ${payload.error?.message ?? response.statusText}`); } - return Number(BigInt(payload.result)); + return payload.result; } -export async function runBridgeObserver(options: CliOptions): Promise { - if (options.mode === "mock") { - const fixture = JSON.parse(readFileSync(resolve(options.fixturePath ?? ""), "utf8")) as unknown; - return makeObservation(validateDeposit(fixture), "mock"); - } +function blockTag(value: string): string { + return `0x${BigInt(value).toString(16)}`; +} - const expectedChainId = options.mode === "base-sepolia" ? BASE_SEPOLIA_CHAIN_ID : BASE_MAINNET_CHAIN_ID; +async function readChainId(rpcUrl: string): Promise { + const result = await rpcCall(rpcUrl, "eth_chainId", []); + return Number(BigInt(result)); +} + +async function readBridgeDepositLogs(options: CliOptions): Promise { + const expectedChainId = expectedChainIdForMode(options.mode, options.expectedChainId); const actualChainId = await readChainId(options.rpcUrl ?? ""); if (actualChainId !== expectedChainId) { throw new Error(`wrong chain id: expected ${expectedChainId}, got ${actualChainId}`); } - const syntheticDeposit: BridgeDeposit = { - schema: "flowmemory.bridge_deposit.v0", - depositId: keccak256Utf8(canonicalJson({ - chainId: expectedChainId, - lockbox: options.lockboxAddress, - fromBlock: options.fromBlock, - toBlock: options.toBlock, - })) as `0x${string}`, - sourceChainId: expectedChainId, - sourceContract: options.lockboxAddress ?? "0x0000000000000000000000000000000000000000", - txHash: "0x0000000000000000000000000000000000000000000000000000000000000000", - logIndex: 0, - token: "0x0000000000000000000000000000000000000000", - amount: "0", - sender: "0x0000000000000000000000000000000000000000", - flowchainRecipient: "0x0000000000000000000000000000000000000000000000000000000000000000", - nonce: "0", - metadataHash: "0x0000000000000000000000000000000000000000000000000000000000000000", - status: "observed", + const logs = await rpcCall(options.rpcUrl ?? "", "eth_getLogs", [{ + address: options.lockboxAddress, + fromBlock: blockTag(options.fromBlock ?? "0"), + toBlock: blockTag(options.toBlock ?? "0"), + topics: [BRIDGE_DEPOSIT_TOPIC0], + }]); + + return logs + .filter((log) => !log.removed) + .map((log) => parseBridgeDepositLog(log, expectedChainId)); +} + +export async function runBridgePipeline(options: CliOptions): Promise { + const deposits = options.mode === "mock" + ? fixtureDeposits(JSON.parse(readFileSync(resolve(options.fixturePath ?? ""), "utf8")) as unknown) + : await readBridgeDepositLogs(options); + + const observations = deposits.map((deposit) => makeObservation(deposit, options.mode, options.maxUsd)); + const credits = makeCredits(observations, options.applyCredit); + const withdrawalIntents = options.withdrawalIntent + ? credits + .filter((credit) => credit.status === "applied") + .map((credit) => { + const deposit = observations.find((observation) => observation.deposit.depositId === credit.depositId)?.deposit; + if (deposit === undefined) { + throw new Error(`missing deposit for credit ${credit.creditId}`); + } + return makeWithdrawalIntent(credit, deposit, options.withdrawalBaseRecipient); + }) + : []; + const handoff = makeRuntimeHandoff(options.mode, observations, credits, withdrawalIntents, options.handoffOutPath); + + return { + observations, + credits, + withdrawalIntents, + handoff, }; +} - return makeObservation(syntheticDeposit, options.mode, options.maxUsd); +export async function runBridgeObserver(options: CliOptions): Promise { + const result = await runBridgePipeline(options); + const observation = result.observations[0]; + if (observation === undefined) { + throw new Error("no BridgeDeposit events observed"); + } + return observation; } -if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { - const options = parseBridgeArgs(process.argv.slice(2)); - const observation = await runBridgeObserver(options); - const outPath = resolve(options.outPath); +function writeJson(path: string, value: unknown): void { + const outPath = resolve(path); mkdirSync(dirname(outPath), { recursive: true }); - writeFileSync(outPath, `${JSON.stringify(observation, null, 2)}\n`); + writeFileSync(outPath, `${JSON.stringify(value, null, 2)}\n`); console.log(`Wrote ${outPath}`); } + +function artifactForSingleOrSet(values: TSingle[], setValue: TSet): TSingle | TSet { + return values.length === 1 ? values[0] as TSingle : setValue; +} + +function printRunBoundary(options: CliOptions): void { + if (options.mode === "mock") { + console.log("Bridge mode: mock fixture; no chain RPC or private key is used."); + return; + } + + const expectedChainId = expectedChainIdForMode(options.mode, options.expectedChainId); + console.log(`Bridge mode: ${options.mode}`); + console.log(`Chain id: ${expectedChainId}`); + console.log(`Lockbox: ${options.lockboxAddress}`); + console.log(`Block range: ${options.fromBlock}-${options.toBlock}`); + console.log("Broadcast: false; this observer never sends transactions."); + if (options.mode === "base-sepolia") { + console.log("Asset boundary: Base Sepolia test assets only."); + } + if (options.mode === "base-mainnet-canary") { + console.log(`Real-funds guardrail acknowledged for read-only canary. Max USD: ${options.maxUsd}`); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const options = parseBridgeArgs(process.argv.slice(2)); + printRunBoundary(options); + const result = await runBridgePipeline(options); + + writeJson( + options.outPath, + artifactForSingleOrSet(result.observations, makeObservationSet(result.observations, options.mode)), + ); + if (options.creditOutPath !== undefined) { + writeJson( + options.creditOutPath, + artifactForSingleOrSet(result.credits, makeCreditSet(result.credits)), + ); + } + if (options.handoffOutPath !== undefined) { + writeJson(options.handoffOutPath, result.handoff); + } + if (options.withdrawalOutPath !== undefined) { + writeJson( + options.withdrawalOutPath, + artifactForSingleOrSet(result.withdrawalIntents, makeWithdrawalIntentSet(result.withdrawalIntents)), + ); + } + + console.log( + `Bridge run complete: observed=${result.observations.length}, credits=${result.credits.length}, withdrawals=${result.withdrawalIntents.length}`, + ); +} diff --git a/services/bridge-relayer/test/bridge-relayer.test.ts b/services/bridge-relayer/test/bridge-relayer.test.ts index e0ebec91..e66ae1ff 100644 --- a/services/bridge-relayer/test/bridge-relayer.test.ts +++ b/services/bridge-relayer/test/bridge-relayer.test.ts @@ -1,29 +1,213 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; + +import { canonicalJson } from "../../shared/src/index.ts"; import { + BASE_SEPOLIA_CHAIN_ID, + BRIDGE_DEPOSIT_TOPIC0, + FIXED_TEST_OBSERVED_AT, + makeBridgeCredit, makeObservation, + makeRuntimeHandoff, + makeWithdrawalIntent, + parseBridgeDepositLog, parseBridgeArgs, + runBridgePipeline, validateDeposit, } from "../src/observe-base-lockbox.ts"; +const fixtureUrl = new URL("../../../fixtures/bridge/base-sepolia-mock-deposit.json", import.meta.url); + +function readSchema(name: string) { + return JSON.parse(readFileSync(new URL(`../../../schemas/flowmemory/${name}`, import.meta.url), "utf8")) as object; +} + +function bridgeAjv(): Ajv2020 { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + [ + "bridge-deposit.schema.json", + "bridge-observation.schema.json", + "bridge-observation-set.schema.json", + "bridge-credit.schema.json", + "bridge-credit-set.schema.json", + "bridge-withdrawal-intent.schema.json", + "bridge-withdrawal-intent-set.schema.json", + "bridge-runtime-handoff.schema.json", + ].forEach((name) => ajv.addSchema(readSchema(name), name)); + return ajv; +} + +function validateSchema(name: string, value: unknown): void { + const ajv = bridgeAjv(); + const validate = ajv.getSchema(`https://flowmemory.local/schemas/flowmemory/${name}`) ?? ajv.getSchema(name); + assert.ok(validate, `missing schema ${name}`); + assert.equal(validate(value), true, canonicalJson({ errors: validate.errors ?? [] })); +} + +function topic(value: bigint): `0x${string}` { + return `0x${value.toString(16).padStart(64, "0")}`; +} + +function addressTopic(address: string): `0x${string}` { + return `0x${address.slice(2).padStart(64, "0")}`; +} + +function dataWord(value: string | bigint): string { + if (typeof value === "bigint") { + return value.toString(16).padStart(64, "0"); + } + return value.slice(2).padStart(64, "0"); +} + +function sampleBridgeDepositLog() { + const sender = "0x4444444444444444444444444444444444444444"; + const token = "0x3333333333333333333333333333333333333333"; + const recipient = "0x5555555555555555555555555555555555555555555555555555555555555555"; + const metadataHash = "0x6666666666666666666666666666666666666666666666666666666666666666"; + + return { + address: "0x1111111111111111111111111111111111111111", + topics: [ + BRIDGE_DEPOSIT_TOPIC0, + "0x7777777777777777777777777777777777777777777777777777777777777777", + topic(BigInt(BASE_SEPOLIA_CHAIN_ID)), + addressTopic(sender), + ], + data: `0x${[ + dataWord(token), + dataWord(20_000_000n), + dataWord(recipient), + dataWord(7n), + dataWord(metadataHash), + ].join("")}`, + blockNumber: "0x64", + blockHash: "0x9999999999999999999999999999999999999999999999999999999999999999", + transactionHash: "0x2222222222222222222222222222222222222222222222222222222222222222", + transactionIndex: "0x2", + logIndex: "0x5", + }; +} + test("validates the committed mock bridge deposit fixture", () => { - const fixture = JSON.parse(readFileSync(new URL("../../../fixtures/bridge/base-sepolia-mock-deposit.json", import.meta.url), "utf8")); + const fixture = JSON.parse(readFileSync(fixtureUrl, "utf8")); const deposit = validateDeposit(fixture); assert.equal(deposit.schema, "flowmemory.bridge_deposit.v0"); assert.equal(deposit.sourceChainId, 84532); assert.equal(deposit.status, "observed"); + validateSchema("bridge-deposit.schema.json", deposit); }); test("builds a non-production bridge observation", () => { - const fixture = JSON.parse(readFileSync(new URL("../../../fixtures/bridge/base-sepolia-mock-deposit.json", import.meta.url), "utf8")); + const fixture = JSON.parse(readFileSync(fixtureUrl, "utf8")); const observation = makeObservation(validateDeposit(fixture), "mock"); assert.equal(observation.schema, "flowmemory.bridge_deposit_observation.v0"); assert.equal(observation.productionReady, false); assert.equal(observation.guardrails.noSecrets, true); + assert.match(observation.replayKey, /^0x[0-9a-f]{64}$/); + validateSchema("bridge-observation.schema.json", observation); +}); + +test("builds deterministic bridge credit, withdrawal intent, and runtime handoff objects", () => { + const fixture = JSON.parse(readFileSync(fixtureUrl, "utf8")); + const deposit = validateDeposit(fixture); + const observation = makeObservation(deposit, "mock"); + const credit = makeBridgeCredit(observation, "applied"); + const withdrawal = makeWithdrawalIntent(credit, deposit); + const handoff = makeRuntimeHandoff("mock", [observation], [credit], [withdrawal]); + + assert.equal(credit.status, "applied"); + assert.equal(credit.productionReady, false); + assert.equal(withdrawal.status, "requested"); + assert.equal(withdrawal.broadcast, false); + assert.deepEqual( + handoff.workbenchTimeline.map((entry) => entry.phase), + ["deposit_observed", "credit_pending", "credit_applied", "withdrawal_requested"], + ); + assert.equal(handoff.workbenchTimeline[1]?.status, "pending"); + validateSchema("bridge-credit.schema.json", credit); + validateSchema("bridge-withdrawal-intent.schema.json", withdrawal); + validateSchema("bridge-runtime-handoff.schema.json", handoff); +}); + +test("local credit smoke pipeline applies a mock credit and records test withdrawal intent", async () => { + const result = await runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock", + "--fixture", + fileURLToPath(fixtureUrl), + "--apply-credit", + "--withdrawal-intent", + ])); + + assert.equal(result.observations.length, 1); + assert.equal(result.credits[0]?.status, "applied"); + assert.equal(result.withdrawalIntents[0]?.status, "requested"); + assert.equal(result.handoff.generatedAt, FIXED_TEST_OBSERVED_AT); +}); + +test("decodes BaseBridgeLockbox BridgeDeposit logs from RPC log payloads", () => { + const log = sampleBridgeDepositLog(); + const deposit = parseBridgeDepositLog(log, BASE_SEPOLIA_CHAIN_ID); + + assert.equal(deposit.depositId, log.topics[1]); + assert.equal(deposit.sourceChainId, BASE_SEPOLIA_CHAIN_ID); + assert.equal(deposit.sender, "0x4444444444444444444444444444444444444444"); + assert.equal(deposit.token, "0x3333333333333333333333333333333333333333"); + assert.equal(deposit.amount, "20000000"); + assert.equal(deposit.nonce, "7"); + assert.equal(deposit.logIndex, 5); + assert.equal(deposit.sourceBlockNumber, "100"); +}); + +test("observes Base Sepolia deposit logs through read-only RPC calls", async () => { + const calls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { method: string }; + calls.push(body.method); + if (body.method === "eth_chainId") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x14a34" }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_getLogs") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: [sampleBridgeDepositLog()] }), { + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { message: "unexpected method" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await runBridgePipeline(parseBridgeArgs([ + "--mode", + "base-sepolia", + "--rpc-url", + "https://example.invalid/base-sepolia", + "--lockbox-address", + "0x1111111111111111111111111111111111111111", + "--from-block", + "100", + "--to-block", + "100", + ])); + + assert.deepEqual(calls, ["eth_chainId", "eth_getLogs"]); + assert.equal(result.observations.length, 1); + assert.equal(result.credits[0]?.status, "pending"); + assert.equal(result.handoff.productionReady, false); + } finally { + globalThis.fetch = originalFetch; + } }); test("requires explicit Base mainnet real-funds guardrails", () => { diff --git a/services/control-plane/src/index.ts b/services/control-plane/src/index.ts index 6321783a..aa71477a 100644 --- a/services/control-plane/src/index.ts +++ b/services/control-plane/src/index.ts @@ -2,4 +2,6 @@ export * from "./errors.ts"; export * from "./fixture-state.ts"; export * from "./json-rpc.ts"; export * from "./methods.ts"; +export * from "./no-secret.ts"; +export * from "./runtime-intake.ts"; export * from "./types.ts"; diff --git a/services/control-plane/src/no-secret.ts b/services/control-plane/src/no-secret.ts new file mode 100644 index 00000000..a80b1f82 --- /dev/null +++ b/services/control-plane/src/no-secret.ts @@ -0,0 +1,60 @@ +import type { JsonValue } from "./types.ts"; + +const FORBIDDEN_KEY_PATTERN = /^(privateKey|mnemonic|seedPhrase|rpcUrl|apiKey|webhookUrl|secretKey|accessToken|bearerToken|rpcSecret)$/i; +const FORBIDDEN_VALUE_PATTERNS = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /\bseed phrase\b/i, + /\bmnemonic phrase\b/i, + /\brpc secret\b/i, + /\bapi key\b/i, +]; + +export interface SecretScanFinding { + path: string; + reason: string; +} + +function scan(value: JsonValue | undefined, path: string, findings: SecretScanFinding[]): void { + if (value === null || value === undefined) { + return; + } + + if (typeof value === "string") { + for (const pattern of FORBIDDEN_VALUE_PATTERNS) { + if (pattern.test(value)) { + findings.push({ path, reason: "forbidden secret marker in string value" }); + } + } + return; + } + + if (typeof value !== "object") { + return; + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => scan(entry, `${path}[${index}]`, findings)); + return; + } + + for (const [key, entry] of Object.entries(value)) { + const childPath = `${path}.${key}`; + if (FORBIDDEN_KEY_PATTERN.test(key)) { + findings.push({ path: childPath, reason: "forbidden secret-bearing key" }); + } + scan(entry, childPath, findings); + } +} + +export function scanJsonForSecrets(value: JsonValue): SecretScanFinding[] { + const findings: SecretScanFinding[] = []; + scan(value, "$", findings); + return findings; +} + +export function assertNoSecrets(value: JsonValue): void { + const findings = scanJsonForSecrets(value); + if (findings.length > 0) { + throw new Error(`control-plane response secret scan failed: ${JSON.stringify(findings, null, 2)}`); + } +} diff --git a/services/control-plane/src/runtime-intake.ts b/services/control-plane/src/runtime-intake.ts new file mode 100644 index 00000000..98e5e869 --- /dev/null +++ b/services/control-plane/src/runtime-intake.ts @@ -0,0 +1,113 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { repoRoot } from "./fixture-state.ts"; +import type { ControlPlanePaths, JsonObject, JsonValue } from "./types.ts"; + +export interface RuntimeSubmission { + schema: "flowmemory.control_plane.transaction_submission.v0"; + txs: JsonObject[]; + intakePath: string; + runtimeStatePath: string; + queued: string[]; + runtime: { + command: string; + status: number | null; + stderr: string; + }; + localOnly: true; +} + +function asObject(value: JsonValue | undefined, label: string): JsonObject { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as JsonObject; +} + +function txFromSignedEnvelope(value: JsonObject): JsonObject { + const tx = value.tx ?? value.transaction; + if (tx === null || typeof tx !== "object" || Array.isArray(tx)) { + throw new Error("signed transaction envelope must contain tx or transaction object"); + } + return tx as JsonObject; +} + +export function extractSubmittedTransactions(params: JsonObject): JsonObject[] { + if (Array.isArray(params.txs)) { + return params.txs.map((entry, index) => asObject(entry, `txs[${index}]`)); + } + + if (params.tx !== undefined) { + return [asObject(params.tx, "tx")]; + } + + if (Array.isArray(params.signedTransactions)) { + return params.signedTransactions.map((entry, index) => txFromSignedEnvelope(asObject(entry, `signedTransactions[${index}]`))); + } + + if (params.signedTransaction !== undefined) { + return [txFromSignedEnvelope(asObject(params.signedTransaction, "signedTransaction"))]; + } + + throw new Error("transaction_submit requires tx, txs, signedTransaction, or signedTransactions"); +} + +function resolveRepoPath(path: string): string { + return resolve(repoRoot(), path); +} + +export function submitTransactionsToRuntime(paths: ControlPlanePaths, txs: JsonObject[]): RuntimeSubmission { + const intakeDir = resolveRepoPath(paths.runtimeIntakeDir); + mkdirSync(intakeDir, { recursive: true }); + + const fixture = { + schema: "flowmemory.control_plane.transaction_intake_fixture.v0", + txs, + }; + const intakePath = resolve(intakeDir, `${Date.now()}-${process.pid}.json`); + writeFileSync(intakePath, `${JSON.stringify(fixture, null, 2)}\n`); + + const runtimeStatePath = resolveRepoPath(paths.runtimeStatePath); + mkdirSync(dirname(runtimeStatePath), { recursive: true }); + + const args = [ + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + runtimeStatePath, + "submit-fixture", + "--fixture", + intakePath, + ]; + const result = spawnSync("cargo", args, { + cwd: repoRoot(), + encoding: "utf8", + }); + + if (result.error !== undefined) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`runtime transaction intake failed: ${result.stderr || result.stdout}`); + } + + const stdout = JSON.parse(result.stdout) as { queued?: unknown }; + const queued = Array.isArray(stdout.queued) ? stdout.queued.map(String) : []; + return { + schema: "flowmemory.control_plane.transaction_submission.v0", + txs, + intakePath, + runtimeStatePath, + queued, + runtime: { + command: `cargo ${args.join(" ")}`, + status: result.status, + stderr: result.stderr, + }, + localOnly: true, + }; +} diff --git a/tests/FlowChainSettlementSpine.t.sol b/tests/FlowChainSettlementSpine.t.sol new file mode 100644 index 00000000..39e38ac7 --- /dev/null +++ b/tests/FlowChainSettlementSpine.t.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {FlowChainSettlementSpine} from "../contracts/FlowChainSettlementSpine.sol"; + +interface SettlementVm { + struct Log { + bytes32[] topics; + bytes data; + address emitter; + } + + function expectRevert(bytes4 revertData) external; + function expectRevert(bytes calldata revertData) external; + function recordLogs() external; + function getRecordedLogs() external returns (Log[] memory); +} + +contract SettlementSubmitter { + function commitObject( + FlowChainSettlementSpine spine, + bytes32 objectType, + bytes32 objectId, + bytes32 rootfieldId, + bytes32 commitment, + bytes32 parentObjectId, + string calldata evidenceURI + ) external returns (uint64) { + return spine.commitObject(objectType, objectId, rootfieldId, commitment, parentObjectId, evidenceURI); + } + + function setSubmitterAuthorization(FlowChainSettlementSpine spine, address submitter, bool authorized) external { + spine.setSubmitterAuthorization(submitter, authorized); + } +} + +contract FlowChainSettlementSpineTest { + SettlementVm private constant vm = SettlementVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + bytes32 private constant OBJECT_COMMITTED_SIGNATURE = + keccak256("FlowChainObjectCommitted(bytes32,bytes32,bytes32,address,bytes32,bytes32,uint64,uint64,string)"); + + FlowChainSettlementSpine private spine; + SettlementSubmitter private submitter; + + error AssertionFailed(); + + function setUp() public { + spine = new FlowChainSettlementSpine(address(this)); + submitter = new SettlementSubmitter(); + } + + function testConstructorRequiresOwnerAndAuthorizesOwner() public { + vm.expectRevert(FlowChainSettlementSpine.ZeroOwner.selector); + new FlowChainSettlementSpine(address(0)); + + _assertTrue(spine.owner() == address(this)); + _assertTrue(spine.authorizedSubmitters(address(this))); + } + + function testOwnerCanAuthorizeSubmitterAndTransferOwnership() public { + spine.setSubmitterAuthorization(address(submitter), true); + _assertTrue(spine.authorizedSubmitters(address(submitter))); + + spine.transferOwnership(address(submitter)); + _assertTrue(spine.owner() == address(submitter)); + } + + function testNonOwnerCannotAuthorizeSubmitter() public { + vm.expectRevert(abi.encodeWithSelector(FlowChainSettlementSpine.NotOwner.selector, address(submitter))); + submitter.setSubmitterAuthorization(spine, address(submitter), true); + } + + function testCommitBridgeDepositObjectEmitsStableEventAndStoresRecord() public { + bytes32 objectType = spine.BRIDGE_DEPOSIT_OBJECT(); + bytes32 objectId = keccak256("bridge.deposit.1"); + bytes32 rootfieldId = keccak256("rootfield.bridge"); + bytes32 commitment = keccak256("bridge.deposit.commitment"); + bytes32 parentObjectId = keccak256("parent.bridge.deposit"); + + vm.recordLogs(); + uint64 sequence = + spine.commitObject(objectType, objectId, rootfieldId, commitment, parentObjectId, "bridge://evidence/1"); + SettlementVm.Log[] memory logs = vm.getRecordedLogs(); + + _assertTrue(sequence == 1); + _assertTrue(spine.nextSequence() == 2); + _assertTrue(spine.isObjectCommitted(objectId)); + + FlowChainSettlementSpine.ObjectCommitment memory record = spine.getObjectCommitment(objectId); + _assertTrue(record.submitter == address(this)); + _assertTrue(record.objectType == objectType); + _assertTrue(record.rootfieldId == rootfieldId); + _assertTrue(record.commitment == commitment); + _assertTrue(record.parentObjectId == parentObjectId); + _assertTrue(record.sequence == 1); + _assertTrue(record.committedAt > 0); + _assertTrue(record.exists); + + _assertObjectCommittedLog( + logs[logs.length - 1], objectId, rootfieldId, objectType, commitment, parentObjectId, sequence + ); + } + + function testAuthorizedSubmitterCanCommitAndRevocationBlocksFutureCommits() public { + bytes32 bridgeDepositObject = spine.BRIDGE_DEPOSIT_OBJECT(); + bytes32 objectId = keccak256("bridge.deposit.authorized"); + spine.setSubmitterAuthorization(address(submitter), true); + + uint64 sequence = submitter.commitObject( + spine, + bridgeDepositObject, + objectId, + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + _assertTrue(sequence == 1); + + spine.setSubmitterAuthorization(address(submitter), false); + vm.expectRevert( + abi.encodeWithSelector(FlowChainSettlementSpine.SubmitterNotAuthorized.selector, address(submitter)) + ); + submitter.commitObject( + spine, + bridgeDepositObject, + keccak256("bridge.deposit.revoked"), + keccak256("rootfield.bridge"), + keccak256("commitment.2"), + bytes32(0), + "" + ); + } + + function testCommitRejectsUnauthorizedZeroFieldsAndDuplicates() public { + bytes32 bridgeDepositObject = spine.BRIDGE_DEPOSIT_OBJECT(); + + vm.expectRevert( + abi.encodeWithSelector(FlowChainSettlementSpine.SubmitterNotAuthorized.selector, address(submitter)) + ); + submitter.commitObject( + spine, + bridgeDepositObject, + keccak256("bridge.deposit.unauthorized"), + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroObjectType.selector); + spine.commitObject( + bytes32(0), + keccak256("bridge.deposit.zero-type"), + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroObjectId.selector); + spine.commitObject( + bridgeDepositObject, + bytes32(0), + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroRootfieldId.selector); + spine.commitObject( + bridgeDepositObject, + keccak256("bridge.deposit.zero-rootfield"), + bytes32(0), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroCommitment.selector); + spine.commitObject( + bridgeDepositObject, + keccak256("bridge.deposit.zero-commitment"), + keccak256("rootfield.bridge"), + bytes32(0), + bytes32(0), + "" + ); + + bytes32 objectId = keccak256("bridge.deposit.duplicate"); + spine.commitObject( + bridgeDepositObject, + objectId, + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(abi.encodeWithSelector(FlowChainSettlementSpine.ObjectAlreadyCommitted.selector, objectId)); + spine.commitObject( + bridgeDepositObject, + objectId, + keccak256("rootfield.bridge"), + keccak256("commitment.2"), + bytes32(0), + "" + ); + } + + function testMissingObjectLookupReverts() public { + bytes32 objectId = keccak256("missing.object"); + + _assertTrue(!spine.isObjectCommitted(objectId)); + vm.expectRevert(abi.encodeWithSelector(FlowChainSettlementSpine.ObjectNotCommitted.selector, objectId)); + spine.getObjectCommitment(objectId); + } + + function _assertObjectCommittedLog( + SettlementVm.Log memory log, + bytes32 objectId, + bytes32 rootfieldId, + bytes32 objectType, + bytes32 commitment, + bytes32 parentObjectId, + uint64 sequence + ) private view { + _assertTrue(log.emitter == address(spine)); + _assertTrue(log.topics[0] == OBJECT_COMMITTED_SIGNATURE); + _assertTrue(log.topics[1] == objectId); + _assertTrue(log.topics[2] == rootfieldId); + _assertTrue(log.topics[3] == objectType); + + ( + address decodedSubmitter, + bytes32 decodedCommitment, + bytes32 decodedParentObjectId, + uint64 decodedSequence, + uint64 committedAt, + string memory evidenceURI + ) = abi.decode(log.data, (address, bytes32, bytes32, uint64, uint64, string)); + + _assertTrue(decodedSubmitter == address(this)); + _assertTrue(decodedCommitment == commitment); + _assertTrue(decodedParentObjectId == parentObjectId); + _assertTrue(decodedSequence == sequence); + _assertTrue(committedAt > 0); + _assertTrue(keccak256(bytes(evidenceURI)) == keccak256("bridge://evidence/1")); + } + + function _assertTrue(bool value) private pure { + if (!value) { + revert AssertionFailed(); + } + } +} diff --git a/tests/bridge/BaseBridgeLockbox.t.sol b/tests/bridge/BaseBridgeLockbox.t.sol index 3b4329d9..a9a5842c 100644 --- a/tests/bridge/BaseBridgeLockbox.t.sol +++ b/tests/bridge/BaseBridgeLockbox.t.sol @@ -63,6 +63,45 @@ contract BridgeCaller { return lockbox.lockNative{value: msg.value}(recipient, keccak256("metadata")); } + function setPaused(BaseBridgeLockbox lockbox, bool paused) external { + lockbox.setPaused(paused); + } + + function configureToken( + BaseBridgeLockbox lockbox, + address token, + bool allowed, + uint256 perDepositCap, + uint256 totalCap + ) external { + lockbox.configureToken(token, allowed, perDepositCap, totalCap); + } + + function setReleaseAuthority(BaseBridgeLockbox lockbox, address authority) external { + lockbox.setReleaseAuthority(authority); + } + + function releaseERC20( + BaseBridgeLockbox lockbox, + bytes32 depositId, + address recipient, + address token, + uint256 amount, + bytes32 evidenceHash + ) external returns (bytes32) { + return lockbox.releaseERC20(depositId, recipient, token, amount, evidenceHash); + } + + function releaseNative( + BaseBridgeLockbox lockbox, + bytes32 depositId, + address payable recipient, + uint256 amount, + bytes32 evidenceHash + ) external returns (bytes32) { + return lockbox.releaseNative(depositId, recipient, amount, evidenceHash); + } + receive() external payable {} } @@ -71,7 +110,10 @@ contract BaseBridgeLockboxTest { bytes32 private constant BRIDGE_DEPOSIT_SIGNATURE = keccak256("BridgeDeposit(bytes32,uint256,address,address,uint256,bytes32,uint256,bytes32)"); + bytes32 private constant BRIDGE_RELEASE_SIGNATURE = + keccak256("BridgeRelease(bytes32,bytes32,address,address,uint256,bytes32)"); bytes32 private constant RECIPIENT = keccak256("flowchain.recipient.alice"); + bytes32 private constant EVIDENCE_HASH = keccak256("flowchain.local.acceptance"); BaseBridgeLockbox private lockbox; MockToken private token; @@ -80,7 +122,7 @@ contract BaseBridgeLockboxTest { error AssertionFailed(); function setUp() public { - lockbox = new BaseBridgeLockbox(address(this)); + lockbox = new BaseBridgeLockbox(address(this), address(this)); token = new MockToken(); caller = new BridgeCaller(); token.mint(address(caller), 1_000 ether); @@ -88,7 +130,15 @@ contract BaseBridgeLockboxTest { lockbox.configureToken(address(0), true, 1 ether, 2 ether); } - function testOwnerCanConfigureAllowlistedToken() public { + function testConstructorRequiresExplicitOwnerAndReleaseAuthority() public { + vm.expectRevert(BaseBridgeLockbox.ZeroOwner.selector); + new BaseBridgeLockbox(address(0), address(this)); + + vm.expectRevert(BaseBridgeLockbox.ZeroReleaseAuthority.selector); + new BaseBridgeLockbox(address(this), address(0)); + } + + function testOwnerCanConfigureAllowlistedTokenAndReleaseAuthority() public { (bool allowed, uint256 perDepositCap, uint256 totalCap, uint256 totalLocked) = lockbox.tokenConfigs(address(token)); @@ -96,35 +146,68 @@ contract BaseBridgeLockboxTest { _assertTrue(perDepositCap == 25 ether); _assertTrue(totalCap == 100 ether); _assertTrue(totalLocked == 0); + + lockbox.setReleaseAuthority(address(caller)); + _assertTrue(lockbox.releaseAuthority() == address(caller)); + } + + function testNonOwnerCannotConfigurePauseOrSetReleaseAuthority() public { + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); + caller.setPaused(lockbox, true); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); + caller.configureToken(lockbox, address(token), true, 1 ether, 1 ether); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); + caller.setReleaseAuthority(lockbox, address(caller)); } - function testLockERC20EmitsDeterministicDepositEvent() public { + function testLockERC20EmitsStableDeterministicDepositEventAndRecord() public { vm.recordLogs(); bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); BridgeVm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 expectedDepositId = keccak256( + abi.encode( + lockbox.BRIDGE_DEPOSIT_SCHEMA_ID(), + block.chainid, + address(lockbox), + address(caller), + address(token), + 10 ether, + RECIPIENT, + uint256(1), + keccak256("metadata") + ) + ); + _assertTrue(depositId == expectedDepositId); _assertTrue(lockbox.deposits(depositId)); _assertTrue(token.balanceOf(address(lockbox)) == 10 ether); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 10 ether); + + BaseBridgeLockbox.DepositRecord memory record = lockbox.getDepositRecord(depositId); + _assertTrue(record.sender == address(caller)); + _assertTrue(record.token == address(token)); + _assertTrue(record.amount == 10 ether); + _assertTrue(record.released == 0); + _assertTrue(record.flowchainRecipient == RECIPIENT); + _assertTrue(record.nonce == 1); + _assertTrue(record.metadataHash == keccak256("metadata")); + _assertTrue(record.exists); (,,, uint256 totalLocked) = lockbox.tokenConfigs(address(token)); _assertTrue(totalLocked == 10 ether); - _assertTrue(logs.length >= 1); - - BridgeVm.Log memory log = logs[logs.length - 1]; - _assertTrue(log.emitter == address(lockbox)); - _assertTrue(log.topics[0] == BRIDGE_DEPOSIT_SIGNATURE); - _assertTrue(log.topics[1] == depositId); - _assertTrue(uint256(log.topics[2]) == block.chainid); - _assertTrue(address(uint160(uint256(log.topics[3]))) == address(caller)); + _assertBridgeDepositLog(logs[logs.length - 1], depositId, address(token), 10 ether, 1); + } - (address eventToken, uint256 amount, bytes32 recipient, uint256 nonce, bytes32 metadataHash) = - abi.decode(log.data, (address, uint256, bytes32, uint256, bytes32)); + function testRepeatedDepositsUseNonceReplayProtection() public { + bytes32 firstDeposit = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + bytes32 secondDeposit = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); - _assertTrue(eventToken == address(token)); - _assertTrue(amount == 10 ether); - _assertTrue(recipient == RECIPIENT); - _assertTrue(nonce == 1); - _assertTrue(metadataHash == keccak256("metadata")); + _assertTrue(firstDeposit != secondDeposit); + _assertTrue(lockbox.deposits(firstDeposit)); + _assertTrue(lockbox.deposits(secondDeposit)); + _assertTrue(lockbox.nextNonce() == 3); } function testLockNativeWorksWhenExplicitlyAllowlisted() public { @@ -134,50 +217,190 @@ contract BaseBridgeLockboxTest { _assertTrue(lockbox.deposits(depositId)); _assertTrue(address(lockbox).balance == 0.2 ether); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 0.2 ether); } - function testRejectsUnallowlistedToken() public { + function testRejectsUnallowlistedDisabledAndZeroTokenDeposits() public { MockToken other = new MockToken(); other.mint(address(caller), 10 ether); vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.TokenNotAllowed.selector, address(other))); caller.lockERC20(lockbox, address(other), 1 ether, RECIPIENT); + + lockbox.configureToken(address(token), false, 0, 0); + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.TokenNotAllowed.selector, address(token))); + caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); + + vm.expectRevert(BaseBridgeLockbox.ZeroToken.selector); + lockbox.lockERC20(address(0), 1 ether, RECIPIENT, keccak256("metadata")); + } + + function testRejectsZeroAmountRecipientAndDirectNativeTransfers() public { + vm.expectRevert(BaseBridgeLockbox.ZeroAmount.selector); + caller.lockERC20(lockbox, address(token), 0, RECIPIENT); + + vm.expectRevert(BaseBridgeLockbox.ZeroRecipient.selector); + caller.lockERC20(lockbox, address(token), 1 ether, bytes32(0)); + + (bool ok,) = address(lockbox).call{value: 1 wei}(""); + _assertTrue(!ok); } - function testRejectsPerDepositCapExceeded() public { + function testRejectsPerDepositAndTotalCapExceeded() public { vm.expectRevert( abi.encodeWithSelector(BaseBridgeLockbox.PerDepositCapExceeded.selector, address(token), 30 ether, 25 ether) ); caller.lockERC20(lockbox, address(token), 30 ether, RECIPIENT); + + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + + vm.expectRevert( + abi.encodeWithSelector(BaseBridgeLockbox.TotalCapExceeded.selector, address(token), 101 ether, 100 ether) + ); + caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); } - function testPauseBlocksDeposits() public { + function testCannotLowerTotalCapBelowCurrentlyLockedAmount() public { + caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + + vm.expectRevert( + abi.encodeWithSelector(BaseBridgeLockbox.TotalCapExceeded.selector, address(token), 10 ether, 9 ether) + ); + lockbox.configureToken(address(token), true, 25 ether, 9 ether); + } + + function testPauseBlocksDepositsButNotAuthorizedRelease() public { + bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); lockbox.setPaused(true); vm.expectRevert(BaseBridgeLockbox.Paused.selector); caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); + + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 9 ether); } - function testOnlyOwnerCanReleaseAndReplayIsBlocked() public { + function testReleaseERC20RequiresExplicitAuthorityAndKnownDeposit() public { bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); - bytes32 evidenceHash = keccak256("flowchain.local.acceptance"); - bytes32 releaseId = lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, evidenceHash); + lockbox.setReleaseAuthority(address(caller)); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotReleaseAuthority.selector, address(this))); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.DepositNotRecorded.selector, keccak256("missing"))); + caller.releaseERC20(lockbox, keccak256("missing"), address(caller), address(token), 1 ether, EVIDENCE_HASH); + bytes32 releaseId = + caller.releaseERC20(lockbox, depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); _assertTrue(lockbox.releases(releaseId)); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 9 ether); _assertTrue(token.balanceOf(address(caller)) == 991 ether); + } + + function testReleaseERC20EmitsStableSchemaAndBlocksReplay() public { + bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + + vm.recordLogs(); + bytes32 releaseId = lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + BridgeVm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 expectedReleaseId = keccak256( + abi.encode( + lockbox.BRIDGE_RELEASE_SCHEMA_ID(), + block.chainid, + address(lockbox), + depositId, + address(caller), + address(token), + 1 ether, + EVIDENCE_HASH + ) + ); + _assertTrue(releaseId == expectedReleaseId); + _assertBridgeReleaseLog(logs[logs.length - 1], releaseId, depositId, address(caller), address(token), 1 ether); vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.ReleaseAlreadyProcessed.selector, releaseId)); - lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, evidenceHash); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + } + + function testReleaseBlocksTokenMismatchOverReleaseAndZeroEvidence() public { + bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + + vm.expectRevert( + abi.encodeWithSelector( + BaseBridgeLockbox.ReleaseTokenMismatch.selector, depositId, address(token), address(0) + ) + ); + lockbox.releaseNative(depositId, payable(address(caller)), 1 ether, EVIDENCE_HASH); + + vm.expectRevert( + abi.encodeWithSelector(BaseBridgeLockbox.ReleaseAmountExceeded.selector, depositId, 11 ether, 10 ether) + ); + lockbox.releaseERC20(depositId, address(caller), address(token), 11 ether, EVIDENCE_HASH); + + vm.expectRevert(BaseBridgeLockbox.ZeroEvidenceHash.selector); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, bytes32(0)); + } + + function testReleaseNativeWorksThroughExplicitAuthorityWhilePaused() public { + vm.deal(address(caller), 0); + bytes32 depositId = caller.lockNative{value: 0.5 ether}(lockbox, RECIPIENT); + lockbox.setReleaseAuthority(address(caller)); + lockbox.setPaused(true); + + bytes32 releaseId = caller.releaseNative(lockbox, depositId, payable(address(caller)), 0.2 ether, EVIDENCE_HASH); + + _assertTrue(lockbox.releases(releaseId)); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 0.3 ether); + _assertTrue(address(lockbox).balance == 0.3 ether); + _assertTrue(address(caller).balance == 0.2 ether); + } + + function _assertBridgeDepositLog( + BridgeVm.Log memory log, + bytes32 depositId, + address expectedToken, + uint256 expectedAmount, + uint256 expectedNonce + ) private view { + _assertTrue(log.emitter == address(lockbox)); + _assertTrue(log.topics[0] == BRIDGE_DEPOSIT_SIGNATURE); + _assertTrue(log.topics[1] == depositId); + _assertTrue(uint256(log.topics[2]) == block.chainid); + _assertTrue(address(uint160(uint256(log.topics[3]))) == address(caller)); + + (address eventToken, uint256 amount, bytes32 recipient, uint256 nonce, bytes32 metadataHash) = + abi.decode(log.data, (address, uint256, bytes32, uint256, bytes32)); + + _assertTrue(eventToken == expectedToken); + _assertTrue(amount == expectedAmount); + _assertTrue(recipient == RECIPIENT); + _assertTrue(nonce == expectedNonce); + _assertTrue(metadataHash == keccak256("metadata")); } - function testNonOwnerCannotConfigurePauseOrRelease() public { - BaseBridgeLockbox otherOwnerLockbox = new BaseBridgeLockbox(address(caller)); + function _assertBridgeReleaseLog( + BridgeVm.Log memory log, + bytes32 releaseId, + bytes32 depositId, + address recipient, + address expectedToken, + uint256 expectedAmount + ) private view { + _assertTrue(log.emitter == address(lockbox)); + _assertTrue(log.topics[0] == BRIDGE_RELEASE_SIGNATURE); + _assertTrue(log.topics[1] == releaseId); + _assertTrue(log.topics[2] == depositId); + _assertTrue(address(uint160(uint256(log.topics[3]))) == recipient); - vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(this))); - otherOwnerLockbox.setPaused(true); + (address eventToken, uint256 amount, bytes32 evidenceHash) = abi.decode(log.data, (address, uint256, bytes32)); - vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(this))); - otherOwnerLockbox.configureToken(address(token), true, 1 ether, 1 ether); + _assertTrue(eventToken == expectedToken); + _assertTrue(amount == expectedAmount); + _assertTrue(evidenceHash == EVIDENCE_HASH); } function _assertTrue(bool value) private pure {