From 497c3b13d1f2859c70a43dc311a5289a6dbbc128 Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 17:58:12 -0500 Subject: [PATCH 01/10] Harden bridge lockbox settlement spine --- contracts/FlowChainSettlementSpine.sol | 164 +++++++++++++ contracts/bridge/BaseBridgeLockbox.sol | 168 +++++++++++-- docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md | 128 +++++++++- script/DeployBridgeSpine.s.sol | 120 ++++++++++ tests/FlowChainSettlementSpine.t.sol | 257 ++++++++++++++++++++ tests/bridge/BaseBridgeLockbox.t.sol | 285 ++++++++++++++++++++--- 6 files changed, 1061 insertions(+), 61 deletions(-) create mode 100644 contracts/FlowChainSettlementSpine.sol create mode 100644 script/DeployBridgeSpine.s.sol create mode 100644 tests/FlowChainSettlementSpine.t.sol 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/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md index 1d86f6f2..3cd06040 100644 --- a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md +++ b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md @@ -9,11 +9,15 @@ 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. +- `tests/FlowChainSettlementSpine.t.sol`: Foundry coverage for authorized object + commitments and stable settlement event shape. - `services/bridge-relayer/`: fixture-first observer that converts explicit bridge deposit records into FlowChain bridge observation JSON. - `fixtures/bridge/base-sepolia-mock-deposit.json`: deterministic test deposit. @@ -28,8 +32,9 @@ 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 + -> optional FlowChainSettlementSpine.commitObject bridge-deposit commitment -> FlowChain bridge deposit observation fixture -> local control plane / workbench / devnet handoff ``` @@ -42,8 +47,9 @@ bridge deposit objects. - 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. @@ -79,6 +85,117 @@ 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. +## 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 +); +``` + +`releaseId` is: + +```text +keccak256(abi.encode( + BRIDGE_RELEASE_SCHEMA_ID, + block.chainid, + lockboxAddress, + depositId, + recipient, + token, + amount, + 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 Only after review, and only for a tiny capped canary: @@ -100,6 +217,7 @@ The script checks Base mainnet chain id `8453` and refuses a canary above ```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 git diff --check 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/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 { From d132f4e64ac17f5bc547921c4e584571762f738c Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 18:03:40 -0500 Subject: [PATCH 02/10] Build bridge relayer credit handoff smoke --- docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md | 71 +- .../bridge/local-runtime-bridge-handoff.json | 213 +++++ .../bridge-base-mainnet-canary-read.ps1 | 19 +- infra/scripts/bridge-base-sepolia-observe.ps1 | 48 + infra/scripts/bridge-base-sepolia-smoke.ps1 | 23 +- infra/scripts/bridge-local-anvil-observe.ps1 | 51 ++ package.json | 4 + .../flowmemory/bridge-credit-set.schema.json | 19 + schemas/flowmemory/bridge-credit.schema.json | 48 + schemas/flowmemory/bridge-deposit.schema.json | 5 +- .../bridge-observation-set.schema.json | 28 + .../flowmemory/bridge-observation.schema.json | 4 +- .../bridge-runtime-handoff.schema.json | 123 +++ .../bridge-withdrawal-intent-set.schema.json | 26 + .../bridge-withdrawal-intent.schema.json | 43 + services/bridge-relayer/README.md | 40 +- services/bridge-relayer/package.json | 3 +- .../src/observe-base-lockbox.ts | 830 ++++++++++++++++-- .../test/bridge-relayer.test.ts | 188 +++- 19 files changed, 1703 insertions(+), 83 deletions(-) create mode 100644 fixtures/bridge/local-runtime-bridge-handoff.json create mode 100644 infra/scripts/bridge-base-sepolia-observe.ps1 create mode 100644 infra/scripts/bridge-local-anvil-observe.ps1 create mode 100644 schemas/flowmemory/bridge-credit-set.schema.json create mode 100644 schemas/flowmemory/bridge-credit.schema.json create mode 100644 schemas/flowmemory/bridge-observation-set.schema.json create mode 100644 schemas/flowmemory/bridge-runtime-handoff.schema.json create mode 100644 schemas/flowmemory/bridge-withdrawal-intent-set.schema.json create mode 100644 schemas/flowmemory/bridge-withdrawal-intent.schema.json diff --git a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md index 1d86f6f2..30bbd8a9 100644 --- a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md +++ b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md @@ -14,12 +14,24 @@ public bridge, and not approved for broad mainnet use. - `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. +- `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. +- `fixtures/bridge/local-runtime-bridge-handoff.json`: deterministic local + bridge handoff consumed by the runtime/control-plane agent until a direct + intake endpoint is merged. - `schemas/flowmemory/bridge-deposit.schema.json` and `schemas/flowmemory/bridge-observation.schema.json`: bridge object contracts. +- `schemas/flowmemory/bridge-credit.schema.json`, + `schemas/flowmemory/bridge-withdrawal-intent.schema.json`, and + `schemas/flowmemory/bridge-runtime-handoff.schema.json`: canonical local + credit, test withdrawal-intent, and 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. @@ -30,14 +42,25 @@ Base Sepolia user/test wallet -> BaseBridgeLockbox.lockERC20 or lockNative -> BridgeDeposit event -> 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 + -> 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 +``` + +The current workbench/control-plane packages are outside this bridge-agent +scope. Until their bridge intake lands, `fixtures/bridge/local-runtime-bridge-handoff.json` +is the exact file for the runtime/control-plane agent to consume. + ## Risk Model - Base mainnet uses real funds. Mainnet canary reads require @@ -56,12 +79,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 @@ -79,6 +106,36 @@ 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. +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`. + ## Base Mainnet Canary Read Only after review, and only for a tiny capped canary: @@ -94,7 +151,8 @@ 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 @@ -102,6 +160,9 @@ The script checks Base mainnet chain id `8453` and refuses a canary above forge test --match-path tests/bridge/BaseBridgeLockbox.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/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/package.json b/package.json index d8cb0136..4123b248 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "flowchain:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-smoke.ps1", "flowchain:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-export.ps1", "flowchain:import": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-import.ps1", + "flowchain:full-smoke": "npm run flowchain:smoke && npm run bridge:local-credit:smoke", "workbench:dev": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-workbench.ps1", "e2e": "npm run index:fixtures && npm run verify:fixtures && npm run flowmemory:generate", "demo:indexer": "npm run demo --prefix services/indexer", @@ -49,6 +50,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 new file mode 100644 index 00000000..8fdf5480 --- /dev/null +++ b/schemas/flowmemory/bridge-credit.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-credit.schema.json", + "title": "FlowChainBridgeCredit", + "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": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "observationId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "depositId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "replayKey": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["chainId", "contract", "txHash", "logIndex"], + "properties": { + "chainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "contract": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "txHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "logIndex": { "type": "integer", "minimum": 0 } + } + }, + "token": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "amount": { "type": "string", "pattern": "^[0-9]+$" }, + "flowchainRecipient": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "status": { "type": "string", "enum": ["pending", "applied", "rejected"] }, + "pendingReason": { "type": "string" }, + "appliedAt": { "type": "string" }, + "rejectionReason": { "type": "string" }, + "localOnly": { "const": true }, + "productionReady": { "const": false } + } +} 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/services/bridge-relayer/README.md b/services/bridge-relayer/README.md index f8a9b5a1..970ed5d1 100644 --- a/services/bridge-relayer/README.md +++ b/services/bridge-relayer/README.md @@ -3,8 +3,9 @@ Status: fixture-first bridge observer for local/Base Sepolia testing. This package converts explicit `BaseBridgeLockbox` deposit records into -FlowChain bridge observation JSON. It does not custody funds, sign releases, run -a production relayer, or prove finality. +FlowChain bridge observation, credit, withdrawal-intent, and local runtime +handoff JSON. It does not custody funds, sign releases, run a production +relayer, or prove finality. Local mock: @@ -12,6 +13,18 @@ Local mock: npm run bridge:mock ``` +Local credit smoke: + +```powershell +npm run bridge:local-credit:smoke +``` + +This writes the current runtime-agent handoff file: + +```text +fixtures/bridge/local-runtime-bridge-handoff.json +``` + Base Sepolia guarded smoke: ```powershell @@ -22,6 +35,29 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-se -ToBlock ``` +Base Sepolia observation from root package env vars: + +```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 +``` + +No private key is required. The command reads `BridgeDeposit` logs over an +explicit block range and writes observation, credit, and handoff JSON under +`services/bridge-relayer/out/`. + +Local Anvil observation uses the same log decoder with chain id `31337`: + +```powershell +$env:ANVIL_BRIDGE_LOCKBOX_ADDRESS="" +$env:ANVIL_BRIDGE_FROM_BLOCK="" +$env:ANVIL_BRIDGE_TO_BLOCK="" +npm run bridge:anvil:observe +``` + Base mainnet canary reads are disabled unless the operator explicitly passes the real-funds acknowledgement and keeps the requested cap at or below 25 USD. 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", () => { From 19eb0ef79d4f0be06188fe1ee9856b5a9c5441cd Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 18:03:44 -0500 Subject: [PATCH 03/10] Expand FlowChain workbench live console --- apps/dashboard/README.md | 12 +- .../data/flowchain-bridge-test-deposit.json | 15 + apps/dashboard/scripts/sync-fixtures.mjs | 5 + apps/dashboard/src/App.tsx | 2 +- apps/dashboard/src/data/workbench.ts | 636 +++++++++++++++++- apps/dashboard/src/styles.css | 52 +- apps/dashboard/src/test/dashboardData.test.ts | 35 + apps/dashboard/src/views/WorkbenchView.tsx | 114 +++- docs/DASHBOARD_MVP.md | 24 +- 9 files changed, 851 insertions(+), 44 deletions(-) create mode 100644 apps/dashboard/public/data/flowchain-bridge-test-deposit.json diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 5395d4f5..4d61f515 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -45,7 +45,7 @@ $env:VITE_FLOWCHAIN_CONTROL_PLANE_URL="http://127.0.0.1:8787" npm run dev ``` -If the API is not running, the workbench marks the control-plane as offline, shows stale fixture fallback where appropriate, and keeps rendering deterministic local data. This app is for private/local validation and canary review only; it does not initiate value-bearing wallet flows. +If the API is running, the workbench verifies `/health`, `/state`, and a read-only `/rpc` batch for blocks, transactions, object lifecycle rows, provenance, and raw local JSON. If the API is not running, the workbench marks the control-plane as offline, shows stale fixture fallback where appropriate, and keeps rendering deterministic local data. This app is for private/local validation and canary review only; it does not initiate value-bearing wallet flows. ## Data Boundary @@ -75,6 +75,7 @@ Workbench fixture fallback paths: ```text 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 ``` Generated local source outputs land under the fixture boundary first: @@ -105,14 +106,17 @@ fixtures/dashboard/generated/hardware-heartbeats.json Every displayed record carries source subsystem, fixture/local origin, chain context, ID/hash, status, and last-updated metadata when available. -The workbench adds local setup/API status plus object views for blocks, transactions, agents, models, receipts, memory cells, artifacts, verifier reports, challenges, finality, provenance, and raw JSON. When a current fixture does not yet contain a private-testnet object type, the view stays empty and names the expected control-plane endpoint. +The workbench adds local setup/API status plus object views for blocks, peers, transactions, mempool, accounts, balances, faucet events, wallet public accounts, agents, models, receipts, memory cells, artifacts, verifier modules, verifier reports, challenges, finality, bridge test-lane rows, hardware signals, provenance, and raw JSON. When a current fixture does not yet contain a private-testnet object type, the view stays empty and names the expected control-plane endpoint. + +The action cards are API-gated. Refresh is enabled when the local API responds. Faucet, sample transaction, and bridge test-deposit actions stay disabled unless the control-plane advertises matching local-only methods; private keys and seed phrases never enter the browser. Workbench object coverage: ```text -node/chain status, blocks, transactions, rootfields, agents, models, work receipts, +node/chain status, peers, blocks, transactions, mempool, accounts, balances, +faucet events, wallet public accounts, rootfields, agents, models, work receipts, memory cells, artifacts, verifier modules, verifier reports, challenges, finality, -provenance/source, hardware signals, raw JSON +bridge deposits/credits/withdrawals, provenance/source, hardware signals, raw JSON ``` ## Status Vocabulary 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/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 b1b1792b..f4dbab67 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -107,7 +107,7 @@ export default function App() { return ( - } /> + setVersion((current) => current + 1)} />} /> } /> } /> } /> diff --git a/apps/dashboard/src/data/workbench.ts b/apps/dashboard/src/data/workbench.ts index 0a678dd3..5e549837 100644 --- a/apps/dashboard/src/data/workbench.ts +++ b/apps/dashboard/src/data/workbench.ts @@ -3,6 +3,7 @@ import type { DashboardData, DashboardStatus, Provenance, SourceSubsystem } from export const DEFAULT_CONTROL_PLANE_URL = "http://127.0.0.1:8787"; export const WORKBENCH_DEVNET_STATE_PATH = "/data/flowchain-local-devnet-state.json"; export const WORKBENCH_DEVNET_DASHBOARD_STATE_PATH = "/data/flowchain-local-devnet-dashboard-state.json"; +export const WORKBENCH_BRIDGE_DEPOSIT_PATH = "/data/flowchain-bridge-test-deposit.json"; const FIXTURE_CHAIN_CONTEXT = "flowchain-private-local-testnet"; const CONTROL_PLANE_TIMEOUT_MS = 900; @@ -10,7 +11,13 @@ const CONTROL_PLANE_TIMEOUT_MS = 900; export type WorkbenchSource = "control-plane" | "fixture-fallback"; export type WorkbenchSectionKey = | "blocks" + | "peers" | "transactions" + | "mempool" + | "accounts" + | "balances" + | "faucetEvents" + | "wallets" | "rootfields" | "agents" | "models" @@ -21,6 +28,7 @@ export type WorkbenchSectionKey = | "verifierReports" | "challenges" | "finality" + | "bridge" | "provenance" | "hardwareSignals" | "rawJson"; @@ -56,6 +64,7 @@ export interface ControlPlaneProbe { error?: string; health?: unknown; state?: unknown; + rpc?: Record; } export interface WorkbenchNodeStatus { @@ -72,20 +81,32 @@ export interface WorkbenchSetupStep { detail: string; } +export interface WorkbenchAction { + key: "refresh" | "faucet" | "sampleTransaction" | "bridgeDeposit"; + label: string; + method: string; + state: "available" | "missing"; + detail: string; + params: UnknownRecord; +} + export interface WorkbenchSnapshot { source: WorkbenchSource; generatedAt: string; controlPlane: ControlPlaneProbe; node: WorkbenchNodeStatus; setupSteps: WorkbenchSetupStep[]; + actions: WorkbenchAction[]; sections: Record; loadIssues: string[]; raw: { dashboard: DashboardData; devnetState: unknown | null; devnetDashboardState: unknown | null; + bridgeDeposit: unknown | null; controlPlaneHealth: unknown | null; controlPlaneState: unknown | null; + controlPlaneRpc: Record | null; }; } @@ -96,91 +117,133 @@ export const WORKBENCH_SECTIONS: WorkbenchSectionDefinition[] = [ key: "blocks", label: "Blocks", detail: "Private/local chain blocks, state roots, parent hashes, and receipt counts.", - expectedEndpoint: "GET /blocks", + expectedEndpoint: "POST /rpc block_list", + }, + { + key: "peers", + label: "Peers", + detail: "Local node peer rows when the runtime exports peer or LAN node state.", + expectedEndpoint: "POST /rpc peer_list", }, { key: "transactions", label: "Transactions", detail: "Smoke-flow transaction ids and receipt application status.", - expectedEndpoint: "GET /transactions", + expectedEndpoint: "POST /rpc transaction_list", + }, + { + key: "mempool", + label: "Mempool", + detail: "Pending local transactions waiting for deterministic block production.", + expectedEndpoint: "POST /rpc mempool_list", + }, + { + key: "accounts", + label: "Accounts", + detail: "Local operator and agent account records. Browser output never includes private keys.", + expectedEndpoint: "POST /rpc account_list", + }, + { + key: "balances", + label: "Balances", + detail: "No-value local balance or credit rows when explicitly exported by the runtime.", + expectedEndpoint: "POST /rpc balance_list", + }, + { + key: "faucetEvents", + label: "Faucet Events", + detail: "Local faucet request history when a no-value faucet endpoint exists.", + expectedEndpoint: "POST /rpc faucet_event_list", + }, + { + key: "wallets", + label: "Wallet Public Accounts", + detail: "Public wallet/operator references only. Signing material stays outside the browser.", + expectedEndpoint: "POST /rpc wallet_account_list", }, { key: "rootfields", label: "Rootfields", detail: "Rootfield namespaces, owners, compact roots, schema hashes, and active state.", - expectedEndpoint: "GET /rootfields", + expectedEndpoint: "POST /rpc rootfield_list", }, { key: "agents", label: "Agents", detail: "Operators, workers, verifier identities, and observed contract actors.", - expectedEndpoint: "GET /agents", + expectedEndpoint: "POST /rpc agent_list", }, { key: "models", label: "Models", detail: "ModelPassport objects when the private testnet runtime exports them.", - expectedEndpoint: "GET /models", + expectedEndpoint: "POST /rpc model_list", }, { key: "receipts", label: "Work Receipts", detail: "Work receipts from the launch fixture and local devnet handoff.", - expectedEndpoint: "GET /receipts", + expectedEndpoint: "POST /rpc work_receipt_list", }, { key: "memoryCells", label: "Memory Cells", detail: "Native MemoryCell records or rootfield-bundle projections while the API is pending.", - expectedEndpoint: "GET /memory-cells", + expectedEndpoint: "POST /rpc memory_cell_list", }, { key: "artifacts", label: "Artifacts", detail: "Artifact availability commitments and receipt-linked artifact URIs.", - expectedEndpoint: "GET /artifacts", + expectedEndpoint: "POST /rpc artifact_availability_list", }, { key: "verifierModules", label: "Verifier Modules", detail: "Verifier module identities or derived module projections from local reports.", - expectedEndpoint: "GET /verifier-modules", + expectedEndpoint: "POST /rpc verifier_module_list", }, { key: "verifierReports", label: "Verifier Reports", detail: "Verifier reports, report digests, policies, checks, and reason codes.", - expectedEndpoint: "GET /verifier-reports", + expectedEndpoint: "POST /rpc verifier_report_list", }, { key: "challenges", label: "Challenges", detail: "Challenge lifecycle objects once the runtime/control-plane exports them.", - expectedEndpoint: "GET /challenges", + expectedEndpoint: "POST /rpc challenge_list", }, { key: "finality", label: "Finality", detail: "Local finality distance, anchor placeholders, and latest finalized state.", - expectedEndpoint: "GET /finality", + expectedEndpoint: "POST /rpc finality_list", + }, + { + key: "bridge", + label: "Bridge Test Lane", + detail: "Test-only bridge deposit, credit, and withdrawal rows. This is not a production bridge surface.", + expectedEndpoint: "POST /rpc bridge_deposit_list", }, { key: "provenance", label: "Provenance / Source", detail: "Source paths, API probe result, and fixture fallback boundary.", - expectedEndpoint: "GET /raw", + expectedEndpoint: "POST /rpc provenance_get", }, { key: "hardwareSignals", label: "Hardware Signals", detail: "FlowRouter, gateway, and low-bandwidth sidecar heartbeat/control-signal records.", - expectedEndpoint: "GET /hardware-signals", + expectedEndpoint: "POST /rpc hardware_signal_list", }, { key: "rawJson", label: "Raw JSON", detail: "Loaded dashboard, devnet, and control-plane payloads for direct inspection.", - expectedEndpoint: "GET /raw", + expectedEndpoint: "POST /rpc raw_json_get", }, ]; @@ -215,6 +278,39 @@ function collectionFrom(root: unknown, keys: string[]): UnknownRecord[] { return []; } +function collectionFromRoots(roots: unknown[], keys: string[]): UnknownRecord[] { + for (const root of roots) { + const values = collectionFrom(root, keys); + if (values.length > 0) { + return values; + } + } + + return []; +} + +function resultRecord(value: unknown): UnknownRecord | null { + if (isRecord(value) && isRecord(value.result)) { + return value.result; + } + + return isRecord(value) ? value : null; +} + +function rpcResult(controlPlane: ControlPlaneProbe, id: string): UnknownRecord | null { + return resultRecord(controlPlane.rpc?.[id]); +} + +function rpcCollection(controlPlane: ControlPlaneProbe, id: string, keys: string[]): UnknownRecord[] { + const result = rpcResult(controlPlane, id); + return result ? collectionFrom(result, keys) : []; +} + +function rpcRaw(controlPlane: ControlPlaneProbe, id: string): unknown | null { + const result = rpcResult(controlPlane, id); + return result?.raw ?? result?.data ?? null; +} + function text(value: unknown, fallback = "not recorded"): string { if (value === null || value === undefined || value === "") { return fallback; @@ -246,16 +342,16 @@ function stringArray(value: unknown): string[] { function statusFrom(value: unknown, fallback: DashboardStatus = "observed"): DashboardStatus { const normalized = text(value, fallback).toLowerCase(); - if (normalized === "applied" || normalized === "success" || normalized === "active") { + if (normalized === "applied" || normalized === "success" || normalized === "active" || normalized === "available") { return "verified"; } - if (normalized === "finalized") { + if (normalized === "finalized" || normalized === "local-finalized") { return "finalized"; } - if (normalized === "failed" || normalized === "invalid" || normalized === "reverted") { + if (normalized === "failed" || normalized === "invalid" || normalized === "reverted" || normalized === "local-rejected") { return "failed"; } - if (normalized === "pending" || normalized === "local-placeholder") { + if (normalized === "pending" || normalized === "local-placeholder" || normalized === "local-pending" || normalized === "not-opened" || normalized === "not_opened") { return "pending"; } if (normalized === "stale" || normalized === "not-detected") { @@ -381,6 +477,27 @@ async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`.trim()); + } + return response.json(); + } finally { + globalThis.clearTimeout(timeout); + } +} + async function fetchOptionalJson(path: string): Promise<{ value: unknown | null; error?: string }> { try { return { value: await fetchJsonWithTimeout(path, CONTROL_PLANE_TIMEOUT_MS) }; @@ -392,27 +509,69 @@ async function fetchOptionalJson(path: string): Promise<{ value: unknown | null; } } +async function fetchControlPlaneRpc(url: string): Promise> { + const requests = [ + { jsonrpc: "2.0", id: "chainStatus", method: "chain_status" }, + { jsonrpc: "2.0", id: "devnetState", method: "devnet_state", params: { includeBlocks: true } }, + { jsonrpc: "2.0", id: "blocks", method: "block_list", params: { includeTransactions: true, limit: 100 } }, + { jsonrpc: "2.0", id: "transactions", method: "transaction_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "rootfields", method: "rootfield_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "agents", method: "agent_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "models", method: "model_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "workReceipts", method: "work_receipt_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "receipts", method: "receipt_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "artifacts", method: "artifact_availability_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "verifierModules", method: "verifier_module_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "verifierReports", method: "verifier_report_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "memoryCells", method: "memory_cell_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "challenges", method: "challenge_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "finality", method: "finality_list", params: { limit: 100 } }, + { jsonrpc: "2.0", id: "rawDevnet", method: "raw_json_get", params: { source: "devnet" } }, + { jsonrpc: "2.0", id: "rawTxFixtures", method: "raw_json_get", params: { source: "txFixtures" } }, + ]; + const response = await postJsonWithTimeout(`${url}/rpc`, requests, CONTROL_PLANE_TIMEOUT_MS); + if (!Array.isArray(response)) { + throw new Error("control-plane RPC batch did not return an array"); + } + + return Object.fromEntries( + response + .filter((entry): entry is UnknownRecord => isRecord(entry) && (typeof entry.id === "string" || typeof entry.id === "number")) + .map((entry) => [String(entry.id), entry]), + ); +} + async function probeControlPlane(): Promise { const url = getControlPlaneUrl(); const checkedAt = new Date().toISOString(); - const endpoints = ["GET /health", "GET /state"]; + const endpoints = ["GET /health", "GET /state", "POST /rpc"]; try { const health = await fetchJsonWithTimeout(`${url}/health`, CONTROL_PLANE_TIMEOUT_MS); let state: unknown | undefined; + let rpc: Record | undefined; + const errors: string[] = []; try { state = await fetchJsonWithTimeout(`${url}/state`, CONTROL_PLANE_TIMEOUT_MS); } catch (error) { + errors.push(`state endpoint was not loaded: ${error instanceof Error ? error.message : "unknown state error"}`); + } + + try { + rpc = await fetchControlPlaneRpc(url); + } catch (error) { + errors.push(`RPC batch was not loaded: ${error instanceof Error ? error.message : "unknown RPC error"}`); + } + + if (state === undefined && rpc === undefined) { return { url, status: "available", checkedAt, endpoints, health, - error: `Health endpoint responded, but state endpoint was not loaded: ${ - error instanceof Error ? error.message : "unknown state error" - }`, + error: `Health endpoint responded, but no state payload was loaded: ${errors.join(" / ")}`, }; } @@ -423,6 +582,8 @@ async function probeControlPlane(): Promise { endpoints, health, state, + rpc, + error: errors.length > 0 ? errors.join(" / ") : undefined, }; } catch (error) { return { @@ -1001,6 +1162,375 @@ function buildHardwareSignalRecords(data: DashboardData, devnetState: unknown): return [...nativeSignals, ...dashboardSignals]; } +function scalarFacts(record: UnknownRecord, preferred: string[] = []): WorkbenchFact[] { + const facts: WorkbenchFact[] = []; + const seen = new Set(); + const add = (key: string, value: unknown) => { + if (seen.has(key) || value === undefined || value === null || typeof value === "object") { + return; + } + facts.push({ label: key.replace(/([A-Z])/g, " $1").toLowerCase(), value: text(value) }); + seen.add(key); + }; + + preferred.forEach((key) => add(key, record[key])); + Object.entries(record).forEach(([key, value]) => add(key, value)); + return facts.slice(0, 6); +} + +function titleFromRecord(record: UnknownRecord, fallback: string, keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + } + return fallback; +} + +function preferRpcRecords(fallback: WorkbenchRecord[], rpcRecords: WorkbenchRecord[]): WorkbenchRecord[] { + return rpcRecords.length > 0 ? rpcRecords : fallback; +} + +function buildRpcGenericRecords( + controlPlane: ControlPlaneProbe, + id: string, + keys: string[], + kind: string, + primaryIdKey: string, +): WorkbenchRecord[] { + return rpcCollection(controlPlane, id, keys).map((record, index) => { + const title = titleFromRecord(record, `${kind.toLowerCase()}:${index + 1}`, [ + primaryIdKey, + "id", + "objectId", + "receiptId", + "reportId", + "rootfieldId", + "transactionId", + "txHash", + ]); + + return makeLocalRecord( + "devnet", + controlPlane.url, + { + id: title, + kind, + title, + summary: text(record.extensionPoint ?? record.summary ?? record.schema, `Loaded from ${id} control-plane RPC response.`), + status: statusFrom(record.status ?? record.sourceStatus ?? record.finalityStatus, "observed"), + facts: scalarFacts(record, [primaryIdKey, "rootfieldId", "status", "source", "localOnly", "schema"]), + raw: record, + }, + controlPlane.checkedAt, + ); + }); +} + +function buildRpcBlockRecords(controlPlane: ControlPlaneProbe): WorkbenchRecord[] { + return rpcCollection(controlPlane, "blocks", ["blocks"]).map((block, index) => { + const blockNumber = text(block.blockNumber, `${index + 1}`); + return makeLocalRecord( + "devnet", + controlPlane.url, + { + id: text(block.blockHash, `block:${blockNumber}`), + kind: "API Block", + title: `Block ${blockNumber}`, + summary: `${stringArray(block.txIds).length} transactions and ${text(block.receiptCount, "0")} receipts from control-plane block_list.`, + status: "finalized", + facts: [ + { label: "block hash", value: text(block.blockHash) }, + { label: "parent hash", value: text(block.parentHash) }, + { label: "state root", value: text(block.stateRoot) }, + { label: "source", value: text(block.source) }, + { label: "transactions", value: stringArray(block.txIds).length.toString() }, + { label: "receipts", value: text(block.receiptCount, "0") }, + ], + raw: block, + }, + controlPlane.checkedAt, + ); + }); +} + +function buildRpcTransactionRecords(controlPlane: ControlPlaneProbe): WorkbenchRecord[] { + return rpcCollection(controlPlane, "transactions", ["transactions"]).map((transaction) => + makeLocalRecord( + "devnet", + controlPlane.url, + { + id: text(transaction.transactionId ?? transaction.txHash), + kind: "API Transaction", + title: text(transaction.txHash ?? transaction.transactionId), + summary: `${text(transaction.type, "local")} transaction is ${text(transaction.status, "unknown")} from ${text(transaction.source, "control-plane")}.`, + status: statusFrom(transaction.status, "observed"), + facts: [ + { label: "block", value: text(transaction.blockNumber) }, + { label: "tx index", value: text(transaction.transactionIndex) }, + { label: "type", value: text(transaction.type) }, + { label: "source", value: text(transaction.source) }, + { label: "local only", value: text(transaction.localOnly) }, + ], + raw: transaction, + }, + controlPlane.checkedAt, + ), + ); +} + +function buildPeerRecords(controlPlane: ControlPlaneProbe, devnetState: unknown): WorkbenchRecord[] { + const peers = [ + ...rpcCollection(controlPlane, "peers", ["peers", "nodes"]), + ...collectionFromRoots([devnetState, controlPlane.state], ["peers", "peerState", "networkPeers", "nodes"]), + ]; + + return peers.map((peer, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(peer.peerId ?? peer.nodeId ?? peer.id, `peer:${index + 1}`), + kind: "Peer", + title: text(peer.peerId ?? peer.nodeId ?? peer.id, `Peer ${index + 1}`), + summary: text(peer.summary ?? peer.address ?? peer.transport, "Peer exported by local runtime state."), + status: statusFrom(peer.status ?? peer.state, "observed"), + facts: scalarFacts(peer, ["peerId", "nodeId", "address", "transport", "lastSeenAt", "status"]), + raw: peer, + }), + ); +} + +function buildMempoolRecords(devnetState: unknown): WorkbenchRecord[] { + return collectionFrom(devnetState, ["pendingTxs", "mempool", "pendingTransactions"]).map((transaction, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(transaction.txId ?? transaction.transactionId ?? transaction.txHash, `pending:${index + 1}`), + kind: "Mempool transaction", + title: text(transaction.txHash ?? transaction.txId ?? transaction.transactionId, `Pending transaction ${index + 1}`), + summary: text(transaction.summary ?? transaction.type, "Pending local transaction waiting for block production."), + status: statusFrom(transaction.status, "pending"), + facts: scalarFacts(transaction, ["type", "from", "to", "rootfieldId", "createdAt", "status"]), + raw: transaction, + }), + ); +} + +function buildAccountRecords(devnetState: unknown): WorkbenchRecord[] { + const agentAccounts = collectionFrom(devnetState, ["agentAccounts", "accounts", "publicAccounts"]).map((account, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(account.agentId ?? account.accountId ?? account.id, `account:${index + 1}`), + kind: "AgentAccount", + title: text(account.agentId ?? account.accountId ?? account.id, `Account ${index + 1}`), + summary: `Controller ${text(account.controller ?? account.owner)}; private signing material is not present in browser state.`, + status: account.active === false ? "stale" : statusFrom(account.status, "verified"), + facts: scalarFacts(account, ["controller", "modelPassportId", "memoryRoot", "rootfieldId", "active"]), + raw: account, + }), + ); + + const operatorRefs = collectionFrom(devnetState, ["operatorKeyReferences"]).map((reference, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(reference.operatorId ?? reference.keyReferenceId, `operator:${index + 1}`), + kind: "Operator public reference", + title: text(reference.keyReferenceId ?? reference.operatorId, `Operator reference ${index + 1}`), + summary: text(reference.secretMaterialBoundary, "Secret material is not stored in dashboard or handoff output."), + status: "verified", + facts: scalarFacts(reference, ["operatorId", "workerKeyId", "verifierKeyId", "signatureScheme", "publicKeyHint"]), + raw: reference, + }), + ); + + return [...agentAccounts, ...operatorRefs]; +} + +function buildBalanceRecords(devnetState: unknown): WorkbenchRecord[] { + const balances = collectionFrom(devnetState, ["balances", "accountBalances", "ledgerBalances", "credits"]).map((balance, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(balance.accountId ?? balance.owner ?? balance.id, `balance:${index + 1}`), + kind: "Local balance row", + title: text(balance.accountId ?? balance.owner ?? balance.id, `Balance row ${index + 1}`), + summary: text(balance.summary ?? balance.asset, "No-value local balance or credit row."), + status: statusFrom(balance.status, "observed"), + facts: scalarFacts(balance, ["accountId", "asset", "amount", "credit", "status", "source"]), + raw: balance, + }), + ); + + if (balances.length > 0) { + return balances; + } + + const config = isRecord(devnetState) && isRecord(devnetState.config) ? devnetState.config : null; + if (config?.noValue === true) { + return [ + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: "no-value-balance-boundary", + kind: "Balance boundary", + title: "No value-bearing balances", + summary: "The current private/local devnet state is marked no-value; no real funds, token balances, gas, rewards, or staking ledger is exposed.", + status: "unsupported", + facts: [ + { label: "chain id", value: text(devnetState && isRecord(devnetState) ? devnetState.chainId : null) }, + { label: "no value", value: "true" }, + { label: "source", value: "local devnet config" }, + ], + raw: config, + }), + ]; + } + + return []; +} + +function buildFaucetEventRecords(devnetState: unknown): WorkbenchRecord[] { + return collectionFrom(devnetState, ["faucetEvents", "faucetRequests", "faucetClaims"]).map((event, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(event.eventId ?? event.requestId ?? event.id, `faucet:${index + 1}`), + kind: "Faucet event", + title: text(event.requestId ?? event.eventId ?? event.id, `Faucet event ${index + 1}`), + summary: text(event.summary ?? event.reason, "Local no-value faucet event."), + status: statusFrom(event.status, "observed"), + facts: scalarFacts(event, ["accountId", "amount", "asset", "createdAt", "status"]), + raw: event, + }), + ); +} + +function buildWalletRecords(devnetState: unknown): WorkbenchRecord[] { + const walletRows = collectionFrom(devnetState, ["walletPublicAccounts", "wallets", "publicWallets"]).map((wallet, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(wallet.address ?? wallet.accountId ?? wallet.id, `wallet:${index + 1}`), + kind: "Wallet public account", + title: text(wallet.address ?? wallet.accountId ?? wallet.id, `Wallet ${index + 1}`), + summary: "Public account metadata only; signing and private-key handling stay outside this browser app.", + status: statusFrom(wallet.status, "observed"), + facts: scalarFacts(wallet, ["address", "accountId", "role", "keyReferenceId", "status"]), + raw: wallet, + }), + ); + + const keyRefs = collectionFrom(devnetState, ["operatorKeyReferences"]).map((reference, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(reference.keyReferenceId, `wallet-reference:${index + 1}`), + kind: "Operator key reference", + title: text(reference.operatorId ?? reference.keyReferenceId, `Operator public key ${index + 1}`), + summary: text(reference.publicKeyHint, "Public key hint only; no private key or seed phrase is present."), + status: "verified", + facts: scalarFacts(reference, ["operatorId", "workerKeyId", "verifierKeyId", "signatureScheme", "secretMaterialBoundary"]), + raw: reference, + }), + ); + + return [...walletRows, ...keyRefs]; +} + +function buildBridgeRecords(devnetState: unknown, bridgeDeposit: unknown | null): WorkbenchRecord[] { + const bridgeRows = collectionFrom(devnetState, [ + "bridgeDeposits", + "bridgeCredits", + "bridgeWithdrawals", + "bridgeEvents", + "bridgeObservations", + ]).map((bridgeObject, index) => + makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id: text(bridgeObject.depositId ?? bridgeObject.creditId ?? bridgeObject.withdrawalId ?? bridgeObject.id, `bridge:${index + 1}`), + kind: text(bridgeObject.kind ?? bridgeObject.type, "Bridge lifecycle object"), + title: text(bridgeObject.depositId ?? bridgeObject.creditId ?? bridgeObject.withdrawalId ?? bridgeObject.id, `Bridge object ${index + 1}`), + summary: text(bridgeObject.summary ?? bridgeObject.status, "Local/test bridge lifecycle row from runtime state."), + status: statusFrom(bridgeObject.status, "observed"), + facts: scalarFacts(bridgeObject, ["sourceChainId", "txHash", "amount", "sender", "flowchainRecipient", "status"]), + raw: bridgeObject, + }), + ); + + if (isRecord(bridgeDeposit)) { + bridgeRows.push( + makeRecord("devnet", WORKBENCH_BRIDGE_DEPOSIT_PATH, { + id: text(bridgeDeposit.depositId), + kind: "Test bridge deposit", + title: text(bridgeDeposit.depositId), + summary: "Deterministic Base Sepolia mock deposit for local bridge inspection only; not a production bridge or real-funds workflow.", + status: statusFrom(bridgeDeposit.status, "observed"), + facts: [ + { label: "source chain", value: text(bridgeDeposit.sourceChainId) }, + { label: "tx hash", value: text(bridgeDeposit.txHash) }, + { label: "token", value: text(bridgeDeposit.token) }, + { label: "amount", value: text(bridgeDeposit.amount) }, + { label: "sender", value: text(bridgeDeposit.sender) }, + { label: "recipient", value: text(bridgeDeposit.flowchainRecipient) }, + ], + raw: bridgeDeposit, + }), + ); + } + + return bridgeRows; +} + +function advertisedText(controlPlane: ControlPlaneProbe): string { + return JSON.stringify({ + health: controlPlane.health ?? null, + state: controlPlane.state ?? null, + rpc: controlPlane.rpc ?? null, + }).toLowerCase(); +} + +function advertisedMethod(controlPlane: ControlPlaneProbe, candidates: string[]): string | null { + if (controlPlane.status !== "available") { + return null; + } + const haystack = advertisedText(controlPlane); + return candidates.find((candidate) => haystack.includes(candidate.toLowerCase())) ?? null; +} + +function buildWorkbenchActions(controlPlane: ControlPlaneProbe): WorkbenchAction[] { + const faucetMethod = advertisedMethod(controlPlane, ["faucet_request", "local_faucet_request", "faucet_submit"]); + const txMethod = advertisedMethod(controlPlane, ["transaction_submit", "sample_transaction_submit", "submit_sample_transaction"]); + const bridgeMethod = advertisedMethod(controlPlane, ["bridge_deposit_get", "bridge_deposit_inspect", "bridge_test_deposit_get"]); + const refreshAvailable = controlPlane.status === "available"; + + return [ + { + key: "refresh", + label: "Refresh state", + method: "devnet_state", + state: refreshAvailable ? "available" : "missing", + detail: refreshAvailable + ? "Reloads dashboard data and re-probes /health, /state, and /rpc." + : "Start the API with npm run control-plane:serve before live refresh can verify state.", + params: { includeBlocks: true }, + }, + { + key: "faucet", + label: "Submit faucet request", + method: faucetMethod ?? "faucet_request", + state: faucetMethod ? "available" : "missing", + detail: faucetMethod + ? "Uses the advertised local no-value faucet JSON-RPC method. No private keys are handled in the browser." + : "No local faucet method is advertised by the current control-plane API.", + params: { localOnly: true }, + }, + { + key: "sampleTransaction", + label: "Submit sample transaction", + method: txMethod ?? "transaction_submit", + state: txMethod ? "available" : "missing", + detail: txMethod + ? "Submits a local sample transaction through the advertised control-plane method." + : "No transaction submit method is advertised. Run npm run flowchain:demo or npm run flowchain:smoke to populate deterministic transactions.", + params: { sample: true, localOnly: true }, + }, + { + key: "bridgeDeposit", + label: "Inspect bridge test deposit", + method: bridgeMethod ?? "bridge_deposit_get", + state: bridgeMethod ? "available" : "missing", + detail: bridgeMethod + ? "Reads the advertised test bridge deposit method. This remains a local/test bridge lane." + : "No bridge deposit inspection method is advertised; the workbench can still show the copied deterministic mock deposit fixture.", + params: { localOnly: true }, + }, + ]; +} + function topLevelKeys(value: unknown): string { return isRecord(value) ? Object.keys(value).sort().join(", ") : "not loaded"; } @@ -1010,6 +1540,7 @@ function buildRawJsonRecords( controlPlane: ControlPlaneProbe, devnetState: unknown | null, devnetDashboardState: unknown | null, + bridgeDeposit: unknown | null, ): WorkbenchRecord[] { return [ makeRecord("indexer", data.metadata.fixturePath, { @@ -1067,16 +1598,32 @@ function buildRawJsonRecords( { label: "status", value: controlPlane.status }, { label: "health keys", value: topLevelKeys(controlPlane.health) }, { label: "state keys", value: topLevelKeys(controlPlane.state) }, + { label: "rpc result ids", value: controlPlane.rpc ? Object.keys(controlPlane.rpc).sort().join(", ") : "not loaded" }, { label: "error", value: text(controlPlane.error, "none") }, ], raw: { health: controlPlane.health ?? null, state: controlPlane.state ?? null, + rpc: controlPlane.rpc ?? null, error: controlPlane.error ?? null, }, }, controlPlane.checkedAt, ), + makeRecord("devnet", WORKBENCH_BRIDGE_DEPOSIT_PATH, { + id: "raw-bridge-test-deposit", + kind: "Raw JSON", + title: WORKBENCH_BRIDGE_DEPOSIT_PATH, + summary: bridgeDeposit + ? "Copied deterministic bridge test deposit fixture loaded for local inspection." + : "Bridge test deposit fixture was not loaded.", + status: bridgeDeposit ? "observed" : "unresolved", + facts: [ + { label: "schema", value: isRecord(bridgeDeposit) ? text(bridgeDeposit.schema) : "missing" }, + { label: "keys", value: topLevelKeys(bridgeDeposit) }, + ], + raw: bridgeDeposit, + }), ]; } @@ -1223,6 +1770,7 @@ export function buildWorkbenchSnapshot( controlPlane?: ControlPlaneProbe; devnetState?: unknown | null; devnetDashboardState?: unknown | null; + bridgeDeposit?: unknown | null; loadIssues?: string[]; } = {}, ): WorkbenchSnapshot { @@ -1232,16 +1780,24 @@ export function buildWorkbenchSnapshot( url: DEFAULT_CONTROL_PLANE_URL, status: "not-detected", checkedAt: new Date().toISOString(), - endpoints: ["GET /health", "GET /state"], + endpoints: ["GET /health", "GET /state", "POST /rpc"], error: "not probed", } satisfies ControlPlaneProbe); - const controlPlaneState = extractControlPlaneState(controlPlane.state); + const rpcDevnetState = rpcRaw(controlPlane, "rawDevnet"); + const rpcDevnetSummary = rpcResult(controlPlane, "devnetState"); + const controlPlaneState = rpcDevnetState ?? extractControlPlaneState(controlPlane.state) ?? rpcDevnetSummary; const activeDevnetState = controlPlaneState ?? options.devnetState ?? null; - const source: WorkbenchSource = controlPlane.status === "available" && controlPlaneState ? "control-plane" : "fixture-fallback"; + const source: WorkbenchSource = controlPlane.status === "available" && (controlPlaneState !== null || controlPlane.rpc) ? "control-plane" : "fixture-fallback"; const sections: Record = { blocks: buildBlockRecords(data, activeDevnetState), + peers: buildPeerRecords(controlPlane, activeDevnetState), transactions: buildTransactionRecords(data, activeDevnetState), + mempool: buildMempoolRecords(activeDevnetState), + accounts: buildAccountRecords(activeDevnetState), + balances: buildBalanceRecords(activeDevnetState), + faucetEvents: buildFaucetEventRecords(activeDevnetState), + wallets: buildWalletRecords(activeDevnetState), rootfields: buildRootfieldRecords(data, activeDevnetState), agents: buildAgentRecords(data, activeDevnetState), models: buildModelRecords(activeDevnetState), @@ -1252,13 +1808,30 @@ export function buildWorkbenchSnapshot( verifierReports: buildVerifierRecords(data, activeDevnetState), challenges: buildChallengeRecords(activeDevnetState), finality: buildFinalityRecords(data, activeDevnetState), + bridge: buildBridgeRecords(activeDevnetState, options.bridgeDeposit ?? null), provenance: [], hardwareSignals: buildHardwareSignalRecords(data, activeDevnetState), rawJson: [], }; + sections.blocks = preferRpcRecords(sections.blocks, buildRpcBlockRecords(controlPlane)); + sections.transactions = preferRpcRecords(sections.transactions, buildRpcTransactionRecords(controlPlane)); + sections.rootfields = preferRpcRecords(sections.rootfields, buildRpcGenericRecords(controlPlane, "rootfields", ["rootfields"], "Rootfield", "rootfieldId")); + sections.agents = preferRpcRecords(sections.agents, buildRpcGenericRecords(controlPlane, "agents", ["agents"], "Agent", "agentId")); + sections.models = preferRpcRecords(sections.models, buildRpcGenericRecords(controlPlane, "models", ["models"], "ModelPassport", "modelId")); + sections.receipts = preferRpcRecords(sections.receipts, buildRpcGenericRecords(controlPlane, "workReceipts", ["workReceipts"], "WorkReceipt", "receiptId")); + sections.artifacts = preferRpcRecords(sections.artifacts, buildRpcGenericRecords(controlPlane, "artifacts", ["artifacts"], "Artifact availability", "availabilityId")); + sections.verifierModules = preferRpcRecords( + sections.verifierModules, + buildRpcGenericRecords(controlPlane, "verifierModules", ["verifierModules"], "VerifierModule", "moduleId"), + ); + sections.verifierReports = preferRpcRecords(sections.verifierReports, buildRpcGenericRecords(controlPlane, "verifierReports", ["reports"], "VerifierReport", "reportId")); + sections.memoryCells = preferRpcRecords(sections.memoryCells, buildRpcGenericRecords(controlPlane, "memoryCells", ["memoryCells"], "MemoryCell", "memoryCellId")); + sections.challenges = preferRpcRecords(sections.challenges, buildRpcGenericRecords(controlPlane, "challenges", ["challenges"], "Challenge", "challengeId")); + sections.finality = preferRpcRecords(sections.finality, buildRpcGenericRecords(controlPlane, "finality", ["finality"], "Finality receipt", "finalityReceiptId")); + sections.provenance = buildProvenanceRecords(data, controlPlane, options.devnetState ?? null, options.devnetDashboardState ?? null); - sections.rawJson = buildRawJsonRecords(data, controlPlane, options.devnetState ?? null, options.devnetDashboardState ?? null); + sections.rawJson = buildRawJsonRecords(data, controlPlane, options.devnetState ?? null, options.devnetDashboardState ?? null, options.bridgeDeposit ?? null); const displayedSections = source === "control-plane" ? relabelDevnetRecordsAsControlPlane(sections, controlPlane) : sections; return { @@ -1267,25 +1840,29 @@ export function buildWorkbenchSnapshot( controlPlane, node: buildNodeStatus(data, activeDevnetState, controlPlane), setupSteps: buildSetupSteps(controlPlane), + actions: buildWorkbenchActions(controlPlane), sections: displayedSections, loadIssues: options.loadIssues ?? [], raw: { dashboard: data, devnetState: options.devnetState ?? null, devnetDashboardState: options.devnetDashboardState ?? null, + bridgeDeposit: options.bridgeDeposit ?? null, controlPlaneHealth: controlPlane.health ?? null, controlPlaneState: controlPlane.state ?? null, + controlPlaneRpc: controlPlane.rpc ?? null, }, }; } export async function fetchWorkbenchSnapshot(data: DashboardData): Promise { - const [controlPlane, devnetStateResult, devnetDashboardStateResult] = await Promise.all([ + const [controlPlane, devnetStateResult, devnetDashboardStateResult, bridgeDepositResult] = await Promise.all([ probeControlPlane(), fetchOptionalJson(WORKBENCH_DEVNET_STATE_PATH), fetchOptionalJson(WORKBENCH_DEVNET_DASHBOARD_STATE_PATH), + fetchOptionalJson(WORKBENCH_BRIDGE_DEPOSIT_PATH), ]); - const loadIssues = [devnetStateResult.error, devnetDashboardStateResult.error].filter( + const loadIssues = [devnetStateResult.error, devnetDashboardStateResult.error, bridgeDepositResult.error].filter( (issue): issue is string => typeof issue === "string" && issue.length > 0, ); @@ -1293,6 +1870,7 @@ export async function fetchWorkbenchSnapshot(data: DashboardData): Promise div { + display: grid; + gap: 7px; +} + +.workbench-actions-grid strong, +.workbench-actions-grid small { + display: block; + overflow-wrap: anywhere; +} + +.workbench-actions-grid .button { + justify-content: center; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.workbench-action-result { + margin: 0; + overflow-wrap: anywhere; + color: #465047; + line-height: 1.45; +} + .workbench-layout { display: grid; grid-template-columns: 222px minmax(0, 1fr); @@ -1214,6 +1263,7 @@ code { .hardware-grid, .lane-grid, .workbench-command-center, + .workbench-actions-grid, .workbench-record-grid { grid-template-columns: 1fr; } diff --git a/apps/dashboard/src/test/dashboardData.test.ts b/apps/dashboard/src/test/dashboardData.test.ts index 1802b264..122c7edd 100644 --- a/apps/dashboard/src/test/dashboardData.test.ts +++ b/apps/dashboard/src/test/dashboardData.test.ts @@ -3,6 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import canaryFixture from "../../../../fixtures/dashboard/flowmemory-dashboard-base-canary-v0.json"; import fixture from "../../../../fixtures/dashboard/flowmemory-dashboard-v0.json"; +import bridgeDeposit from "../../../../fixtures/bridge/base-sepolia-mock-deposit.json"; import devnetDashboardState from "../../../../fixtures/launch-core/generated/devnet/dashboard-state.json"; import devnetState from "../../../../fixtures/launch-core/generated/devnet/state.json"; import { validateDashboardData } from "../data/loadDashboardData"; @@ -11,6 +12,7 @@ import { computeOverviewMetrics, searchRecords } from "../data/selectors"; import type { DashboardData, ProvenancedRecord } from "../data/types"; import { DEFAULT_CONTROL_PLANE_URL, + WORKBENCH_BRIDGE_DEPOSIT_PATH, WORKBENCH_DEVNET_DASHBOARD_STATE_PATH, WORKBENCH_DEVNET_STATE_PATH, WORKBENCH_SECTIONS, @@ -116,6 +118,9 @@ describe("dashboard fixture", () => { expect(workbench.sections.blocks).toHaveLength(2); expect(workbench.sections.transactions.length).toBeGreaterThanOrEqual(6); expect(workbench.sections.transactions.every((transaction) => transaction.status === "finalized")).toBe(true); + expect(workbench.sections.accounts.length).toBeGreaterThan(0); + expect(workbench.sections.wallets.length).toBeGreaterThan(0); + expect(workbench.sections.balances.map((record) => record.id)).toContain("no-value-balance-boundary"); expect(workbench.sections.rootfields.length).toBeGreaterThan(0); expect(workbench.sections.agents.length).toBeGreaterThan(0); expect(workbench.sections.receipts.length).toBeGreaterThan(data.workReceipts.length); @@ -166,12 +171,36 @@ describe("dashboard fixture", () => { if (url.endsWith("/state")) { return Response.json({ state: devnetState }); } + if (url.endsWith("/rpc")) { + return Response.json([ + { jsonrpc: "2.0", id: "chainStatus", result: { schema: "flowmemory.control_plane.chain_status.v0", capabilities: ["raw_json_reads"] } }, + { jsonrpc: "2.0", id: "devnetState", result: { schema: "flowmemory.control_plane.devnet_state.v0", blocks: devnetState.blocks } }, + { jsonrpc: "2.0", id: "blocks", result: { blocks: devnetState.blocks } }, + { jsonrpc: "2.0", id: "transactions", result: { transactions: [] } }, + { jsonrpc: "2.0", id: "rootfields", result: { rootfields: [] } }, + { jsonrpc: "2.0", id: "agents", result: { agents: [] } }, + { jsonrpc: "2.0", id: "models", result: { models: [] } }, + { jsonrpc: "2.0", id: "workReceipts", result: { workReceipts: [] } }, + { jsonrpc: "2.0", id: "receipts", result: { receipts: [] } }, + { jsonrpc: "2.0", id: "artifacts", result: { artifacts: [] } }, + { jsonrpc: "2.0", id: "verifierModules", result: { verifierModules: [] } }, + { jsonrpc: "2.0", id: "verifierReports", result: { reports: [] } }, + { jsonrpc: "2.0", id: "memoryCells", result: { memoryCells: [] } }, + { jsonrpc: "2.0", id: "challenges", result: { challenges: [] } }, + { jsonrpc: "2.0", id: "finality", result: { finality: [] } }, + { jsonrpc: "2.0", id: "rawDevnet", result: { raw: devnetState } }, + { jsonrpc: "2.0", id: "rawTxFixtures", result: { raw: { txs: [] } } }, + ]); + } if (url === WORKBENCH_DEVNET_STATE_PATH) { return Response.json(devnetState); } if (url === WORKBENCH_DEVNET_DASHBOARD_STATE_PATH) { return Response.json(devnetDashboardState); } + if (url === WORKBENCH_BRIDGE_DEPOSIT_PATH) { + return Response.json(bridgeDeposit); + } return new Response("not found", { status: 404 }); }); @@ -182,9 +211,12 @@ describe("dashboard fixture", () => { expect(workbench.source).toBe("control-plane"); expect(workbench.raw.controlPlaneHealth).toEqual({ status: "ok" }); expect(workbench.raw.controlPlaneState).toEqual({ state: devnetState }); + expect(workbench.raw.controlPlaneRpc?.rawDevnet).toBeDefined(); expect(workbench.raw.devnetState).toEqual(devnetState); + expect(workbench.raw.bridgeDeposit).toEqual(bridgeDeposit); expect(workbench.loadIssues).toEqual([]); expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:8787/health", expect.any(Object)); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:8787/rpc", expect.any(Object)); expect(fetchMock).toHaveBeenCalledWith(WORKBENCH_DEVNET_STATE_PATH, expect.any(Object)); }); @@ -197,7 +229,10 @@ describe("dashboard fixture", () => { expect(html).toContain("Local explorer workbench"); expect(html).toContain("Node and API status"); + expect(html).toContain("Local actions"); expect(html).toContain("Control-plane offline"); + expect(html).toContain("Wallet Public Accounts"); + expect(html).toContain("Bridge Test Lane"); expect(html).toContain("Rootfields"); expect(html).toContain("Verifier Modules"); expect(html).toContain("Hardware Signals"); diff --git a/apps/dashboard/src/views/WorkbenchView.tsx b/apps/dashboard/src/views/WorkbenchView.tsx index a05f4aa5..4933455a 100644 --- a/apps/dashboard/src/views/WorkbenchView.tsx +++ b/apps/dashboard/src/views/WorkbenchView.tsx @@ -1,12 +1,18 @@ import { useMemo, useState } from "react"; -import { Activity, Database, Network, Search, Server, Terminal } from "lucide-react"; +import { Activity, Database, Network, Play, RefreshCw, Search, Server, Terminal } from "lucide-react"; import { EmptyState } from "../components/EmptyState"; import { HashValue } from "../components/HashValue"; import { ProvenanceLine } from "../components/ProvenanceLine"; import { SectionHeader } from "../components/SectionHeader"; import { StatusBadge } from "../components/StatusBadge"; import type { DashboardData, DashboardStatus } from "../data/types"; -import { WORKBENCH_SECTIONS, type WorkbenchRecord, type WorkbenchSectionKey, type WorkbenchSnapshot } from "../data/workbench"; +import { + WORKBENCH_SECTIONS, + type WorkbenchAction, + type WorkbenchRecord, + type WorkbenchSectionKey, + type WorkbenchSnapshot, +} from "../data/workbench"; const DEFAULT_SECTION: WorkbenchSectionKey = "blocks"; @@ -31,9 +37,41 @@ function recordMatches(record: WorkbenchRecord, query: string): boolean { return JSON.stringify(record).toLowerCase().includes(normalized); } -export function WorkbenchView({ data, workbench }: { data: DashboardData; workbench: WorkbenchSnapshot }) { +async function runRpcAction(workbench: WorkbenchSnapshot, action: WorkbenchAction): Promise { + const response = await fetch(`${workbench.controlPlane.url}/rpc`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: action.key, + method: action.method, + params: action.params, + }), + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`.trim()); + } + const payload = (await response.json()) as unknown; + if (payload && typeof payload === "object" && "error" in payload) { + const error = payload as { error?: { message?: string } }; + throw new Error(error.error?.message ?? "Control-plane action failed."); + } + return JSON.stringify(payload); +} + +export function WorkbenchView({ + data, + workbench, + onRefresh, +}: { + data: DashboardData; + workbench: WorkbenchSnapshot; + onRefresh?: () => void; +}) { const [activeSection, setActiveSection] = useState(DEFAULT_SECTION); const [query, setQuery] = useState(""); + const [actionResult, setActionResult] = useState(null); + const [runningAction, setRunningAction] = useState(null); const activeDefinition = WORKBENCH_SECTIONS.find((section) => section.key === activeSection) ?? WORKBENCH_SECTIONS[0]; const activeRecords = workbench.sections[activeSection] ?? []; const filteredRecords = useMemo( @@ -41,6 +79,28 @@ export function WorkbenchView({ data, workbench }: { data: DashboardData; workbe [activeRecords, query], ); const sourceStatus: DashboardStatus = workbench.source === "control-plane" ? "verified" : "stale"; + const handleAction = async (action: WorkbenchAction) => { + if (action.state !== "available") { + return; + } + + if (action.key === "refresh") { + setActionResult("Refresh requested. Re-probing local API and synced fixtures."); + onRefresh?.(); + return; + } + + setRunningAction(action.key); + setActionResult(null); + try { + const result = await runRpcAction(workbench, action); + setActionResult(`${action.label} returned ${result}`); + } catch (error) { + setActionResult(error instanceof Error ? error.message : "Control-plane action failed."); + } finally { + setRunningAction(null); + } + }; return (
@@ -115,6 +175,38 @@ export function WorkbenchView({ data, workbench }: { data: DashboardData; workbe ) : null} +
+
+
+
+ API-gated +
+
+ {workbench.actions.map((action) => ( +
+
+ + {action.label} + {action.detail} + {action.method} +
+ +
+ ))} +
+ {actionResult ?

{actionResult}

: null} +
+
Data source @@ -154,11 +246,19 @@ export function WorkbenchView({ data, workbench }: { data: DashboardData; workbe
- Open challenges - {workbench.sections.challenges.length} + Accounts + {workbench.sections.accounts.length + workbench.sections.wallets.length} +
+ 0 ? "verified" : "pending"} compact /> + public records +
+
+
+ Bridge lane + {workbench.sections.bridge.length}
- 0 ? "pending" : "observed"} compact /> - API-ready view + 0 ? "observed" : "pending"} compact /> + test-only
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 From 55cbbc8e137ebe225001273093b343198ecc7ba6 Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 18:04:22 -0500 Subject: [PATCH 04/10] Extend local control plane API --- docs/FLOWCHAIN_CONTROL_PLANE_API.md | 181 +++- docs/INDEXER_VERIFIER_MVP.md | 4 +- package.json | 1 + services/control-plane/README.md | 45 +- services/control-plane/src/fixture-state.ts | 54 +- services/control-plane/src/index.ts | 2 + services/control-plane/src/methods.ts | 926 +++++++++++++++++- services/control-plane/src/no-secret.ts | 60 ++ services/control-plane/src/runtime-intake.ts | 113 +++ services/control-plane/src/server.ts | 70 +- services/control-plane/src/smoke.ts | 185 ++-- services/control-plane/src/types.ts | 38 + .../control-plane/test/control-plane.test.ts | 55 +- 13 files changed, 1595 insertions(+), 139 deletions(-) create mode 100644 services/control-plane/src/no-secret.ts create mode 100644 services/control-plane/src/runtime-intake.ts diff --git a/docs/FLOWCHAIN_CONTROL_PLANE_API.md b/docs/FLOWCHAIN_CONTROL_PLANE_API.md index e0b3a84c..daf31cca 100644 --- a/docs/FLOWCHAIN_CONTROL_PLANE_API.md +++ b/docs/FLOWCHAIN_CONTROL_PLANE_API.md @@ -1,10 +1,10 @@ # FlowChain Local Control Plane API -Status: local fixture-backed V0 contract. +Status: local runtime-backed V0 contract with deterministic fixture fallback. -This document defines the local JSON-RPC 2.0 API for the FlowChain / FlowMemory control-plane. It gives dashboard, agent, verifier, and devnet tooling one deterministic read surface for FlowMemory objects. +This document defines the local JSON-RPC 2.0 API for the FlowChain / FlowMemory control-plane. It gives dashboard, agent, verifier, bridge, and devnet tooling one deterministic local surface for FlowMemory objects. -It is not a production RPC endpoint, public L1 API, hosted service, wallet API, bridge API, token API, or verifier economics surface. +It is not a production RPC endpoint, public L1 API, hosted service, production wallet API, production bridge API, token API, or verifier economics surface. ## Runtime Boundary @@ -21,13 +21,17 @@ npm run control-plane:test npm run control-plane:demo npm run control-plane:smoke npm run control-plane:serve +npm run flowchain:full-smoke ``` -The service uses deterministic local files only. It does not require secrets, wallets, RPC URLs, private keys, API keys, or production services. +The service reads ignored local runtime files first and falls back to committed deterministic fixtures. It does not require secrets, RPC URLs, private keys, API keys, or production services. Primary data sources: ```text +devnet/local/state.json +devnet/local/launch-v0-state.json +devnet/local/handoff/generated/*.json fixtures/launch-core/flowmemory-launch-v0.json fixtures/launch-core/generated/devnet/state.json fixtures/launch-core/generated/devnet/indexer-handoff.json @@ -37,9 +41,12 @@ services/indexer/out/indexer-state.json services/verifier/out/reports.json services/verifier/fixtures/artifacts.json fixtures/handoff/sample-txs.json +services/bridge-relayer/out/bridge-observation.json +services/bridge-relayer/out/control-plane-observations.json +fixtures/bridge/base-sepolia-mock-deposit.json ``` -If the generated launch-core fixture is missing, the service rebuilds the in-memory view from indexer/verifier outputs or raw fixture receipts and artifact fixtures. This recovery path is local and read-only from the API caller perspective. +If the generated launch-core fixture is missing, the service rebuilds the in-memory view from indexer/verifier outputs or raw fixture receipts and artifact fixtures. This recovery path is local. Transaction and bridge observation write endpoints forward only into the existing local runtime or bridge-agent intake files. ## JSON-RPC Envelope @@ -113,22 +120,51 @@ GET /health Params: none. -Returns local stack status, fixture source status, block counters, object counters, capabilities, and limitations. +Returns local stack status, live/fixture source status, block counters, object counters, capabilities, and limitations. Key result fields: ```json { "schema": "flowmemory.control_plane.chain_status.v0", - "chainId": "flowmemory-local-alpha", - "environment": "local-devnet-fixture", - "source": "fixture", + "chainId": "flowmemory-local-devnet-v0", + "environment": "private-local-devnet", + "source": "local-runtime-file", "currentBlock": "123461", "finalizedBlock": "123457", "localOnly": true } ``` +### `node_status` + +Params: none. + +Returns bounded local runtime status, current block, state root, pending +transaction count, runtime mode, and source file. The current runtime reports +`longRunningNode: false` because it is still the deterministic Rust CLI, not a +daemon. + +### `peer_list` + +Params: none. + +Returns the local self peer for the single-process runtime. LAN peer discovery +is not implemented in this package. + +### `mempool_list` + +Params: + +```json +{ + "limit": 50 +} +``` + +Returns pending transaction envelopes from the local devnet state or +control-plane handoff. + ### `devnet_state` Params: @@ -195,6 +231,55 @@ Params: one of: { "txHash": "0x..." } ``` +### `transaction_submit` + +Params: one of: + +```json +{ "tx": { "type": "RegisterRootfield" } } +``` + +```json +{ "txs": [{ "type": "RegisterRootfield" }] } +``` + +```json +{ "signedTransaction": { "tx": { "type": "RegisterRootfield" }, "signature": "0x..." } } +``` + +The control-plane writes a local fixture under +`devnet/local/control-plane-intake/` and forwards it to the existing runtime +intake path: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json submit-fixture --fixture +``` + +The endpoint queues transactions only. It does not produce a block by itself. +Payloads containing secret-bearing fields such as `privateKey`, `mnemonic`, +`seedPhrase`, `rpcUrl`, `apiKey`, or webhook credentials are rejected. + +### `account_list` / `account_get` + +Return local public account rows for operator key references and `AgentAccount` +objects. Balances are no-value local devnet balances. + +### `balance_list` / `balance_get` + +Return explicit zero/no-value balances. The private/local runtime has no token +value, gas accounting, staking, rewards, or faucet funds. + +### `faucet_event_list` / `faucet_event_get` + +Return a stable disabled faucet event explaining that the local devnet has no +token value or faucet funds. + +### `wallet_metadata_list` / `wallet_metadata_get` + +Return public operator key-reference metadata only: key reference ids, operator +ids, signature scheme labels, and verifier-set roots. No signing secret +material is returned. + ### `rootfield_get` Params: @@ -443,6 +528,11 @@ Params: one of: Returns an `AgentMemoryView` and linked `RootfieldBundle`. +### `agent_account_list` / `agent_account_get` + +Return native private/local `AgentAccount` objects from the devnet runtime state +or handoff files. + ### `agent_list` Params: @@ -483,6 +573,11 @@ Params: one of: { "rootfieldId": "0x..." } ``` +### `model_passport_list` / `model_passport_get` + +Return native private/local `ModelPassport` objects from the devnet runtime +state or handoff files. + ### `challenge_get` Params: one of: @@ -556,6 +651,42 @@ Params: All params are optional. Returns native finality receipts when present and projected local finality rows for launch-core receipts. +### `bridge_observation_submit` + +Params: one of: + +```json +{ "observation": { "schema": "flowmemory.bridge_deposit_observation.v0" } } +``` + +```json +{ "deposit": { "schema": "flowmemory.bridge_deposit.v0" } } +``` + +Stores local bridge-agent observations in +`services/bridge-relayer/out/control-plane-observations.json`. This is local +observation intake only; it does not custody funds, sign releases, or implement +production bridge security. + +### `bridge_observation_list` / `bridge_observation_get` + +Return bridge observations from bridge-relayer output, control-plane intake, or +the committed mock deposit fixture. + +### `bridge_deposit_list` / `bridge_deposit_get` + +Return bridge deposit rows derived from bridge observations. + +### `bridge_credit_list` / `bridge_credit_get` + +Return local bridge-credit projections for observed deposits. Pending credits do +not imply production bridge accounting. + +### `withdrawal_list` / `withdrawal_get` + +Return local withdrawal handoff rows when present. Without native withdrawal +handoff data, `withdrawal_get` returns a stable `not_opened` placeholder. + ### `provenance_get` Params: one of: @@ -603,6 +734,9 @@ Allowed `source` values: - `devnetVerifierHandoff` - `devnetControlPlaneHandoff` - `txFixtures` +- `bridgeObservation` +- `bridgeObservationIntake` +- `bridgeDepositFixture` Returns the raw loaded local JSON object for dashboard/workbench debug views. It does not accept arbitrary filesystem paths. @@ -610,13 +744,22 @@ Returns the raw loaded local JSON object for dashboard/workbench debug views. It Dashboard agents should prefer: -1. `health` and `chain_status` for source health and global counters. -2. `block_list` and `transaction_list` for chain/devnet tables. -3. `rootfield_list` and `rootfield_get` for Rootfield detail. -4. `work_receipt_list`, `receipt_list`, `verifier_module_list`, and `verifier_report_list` for lifecycle tables. -5. `receipt_get`, `work_receipt_get`, `verifier_report_get`, and `provenance_get` for detail drawers. -6. `artifact_availability_list`, `memory_cell_list`, `agent_list`, and `model_list` for dashboard/workbench panels. -7. `challenge_get`, `challenge_list`, `finality_get`, and `finality_list` for local fixture challenge/finality labels. -8. `raw_json_get` for raw JSON inspection. - -The API is intentionally read-only for V0. Submit, challenge, wallet, live indexing, and production settlement methods require separate scoped work. +1. `health`, `chain_status`, and `node_status` for source health and global counters. +2. `block_list`, `transaction_list`, and `mempool_list` for chain/devnet tables. +3. `account_list`, `wallet_metadata_list`, `balance_list`, and `faucet_event_list` for local account panels. +4. `rootfield_list` and `rootfield_get` for Rootfield detail. +5. `agent_account_list`, `model_passport_list`, `work_receipt_list`, `receipt_list`, `verifier_module_list`, and `verifier_report_list` for lifecycle tables. +6. `receipt_get`, `work_receipt_get`, `verifier_report_get`, and `provenance_get` for detail drawers. +7. `artifact_availability_list`, `memory_cell_list`, `agent_list`, and `model_list` for dashboard/workbench panels. +8. `challenge_get`, `challenge_list`, `finality_get`, and `finality_list` for local challenge/finality labels. +9. `bridge_observation_list`, `bridge_deposit_list`, `bridge_credit_list`, and `withdrawal_list` for bridge-agent inspection. +10. `raw_json_get` for raw JSON inspection. + +HTTP helpers are available for browser workbench reads at `/node/status`, +`/peers`, `/mempool`, `/blocks`, `/transactions`, `/accounts`, `/balances`, +`/faucet/events`, `/wallets`, `/agents`, `/models`, `/work-receipts`, +`/artifacts/availability`, `/verifier-modules`, `/verifier-reports`, +`/memory-cells`, `/challenges`, `/finality`, `/bridge/observations`, +`/bridge/deposits`, `/bridge/credits`, and `/withdrawals`. Write helpers are +available at `POST /transactions` and `POST /bridge/observations`; JSON-RPC +remains the canonical API envelope. 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/package.json b/package.json index d8cb0136..2690c263 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "flowchain:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-stop.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": "npm run flowchain:smoke && npm run control-plane:smoke", "flowchain:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-export.ps1", "flowchain:import": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-import.ps1", "workbench:dev": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-workbench.ps1", diff --git a/services/control-plane/README.md b/services/control-plane/README.md index f9888891..0d43d143 100644 --- a/services/control-plane/README.md +++ b/services/control-plane/README.md @@ -1,8 +1,8 @@ # FlowChain Control Plane V0 -This package exposes a local JSON-RPC 2.0 control-plane for FlowMemory and FlowChain fixture data. It is fixture-first, deterministic, and read-only. +This package exposes a local JSON-RPC 2.0 control-plane for FlowMemory and FlowChain runtime/fixture data. It reads ignored `devnet/local/` state first, falls back to committed deterministic fixtures, and forwards local write intake to the existing devnet and bridge-relayer paths. -It is not a production RPC endpoint, hosted service, wallet, sequencer, verifier network, token system, or production chain API. +It is not a production RPC endpoint, hosted service, production wallet, sequencer, verifier network, token system, production bridge, or production chain API. ## Commands @@ -13,6 +13,7 @@ npm run control-plane:demo npm run control-plane:test npm run control-plane:smoke npm run control-plane:serve +npm run flowchain:full-smoke ``` The demo and tests require no secrets, RPC URLs, wallets, or production services. @@ -25,10 +26,22 @@ The dispatcher supports: - `health` - `chain_status` - `devnet_state` +- `node_status` +- `peer_list` +- `mempool_list` - `block_get` - `block_list` +- `account_get` +- `account_list` +- `balance_get` +- `balance_list` +- `faucet_event_get` +- `faucet_event_list` +- `wallet_metadata_get` +- `wallet_metadata_list` - `transaction_get` - `transaction_list` +- `transaction_submit` - `rootfield_get` - `rootfield_list` - `artifact_get` @@ -46,12 +59,25 @@ The dispatcher supports: - `memory_cell_list` - `agent_get` - `agent_list` +- `agent_account_get` +- `agent_account_list` - `model_get` - `model_list` +- `model_passport_get` +- `model_passport_list` - `challenge_get` - `challenge_list` - `finality_get` - `finality_list` +- `bridge_observation_submit` +- `bridge_observation_get` +- `bridge_observation_list` +- `bridge_deposit_get` +- `bridge_deposit_list` +- `bridge_credit_get` +- `bridge_credit_list` +- `withdrawal_get` +- `withdrawal_list` - `provenance_get` - `raw_json_get` @@ -59,7 +85,13 @@ The API contract is documented in [docs/FLOWCHAIN_CONTROL_PLANE_API.md](../../do ## Data Sources -The loader reads committed deterministic outputs first: +The loader reads ignored local runtime outputs first when they exist: + +- `devnet/local/state.json` +- `devnet/local/launch-v0-state.json` +- `devnet/local/handoff/generated/*.json` + +It then falls back to committed deterministic outputs: - `fixtures/launch-core/flowmemory-launch-v0.json` - `fixtures/launch-core/generated/devnet/state.json` @@ -70,7 +102,10 @@ The loader reads committed deterministic outputs first: - `services/verifier/out/reports.json` - `services/verifier/fixtures/artifacts.json` - `fixtures/handoff/sample-txs.json` +- `fixtures/bridge/base-sepolia-mock-deposit.json` + +Bridge relayer output is read from `services/bridge-relayer/out/bridge-observation.json` and control-plane bridge intake is stored in `services/bridge-relayer/out/control-plane-observations.json`. -If the launch-core fixture is missing, the loader rebuilds the in-memory view from indexer/verifier outputs or the raw fixture receipts and artifact resolver. It does not fetch from live RPC or write production state. +If the launch-core fixture is missing, the loader rebuilds the in-memory view from indexer/verifier outputs or the raw fixture receipts and artifact resolver. It does not fetch from production RPC or write production state. -`npm run control-plane:smoke` runs an in-process JSON-RPC client over the complete local lifecycle surface: health, chain status, blocks, transactions, rootfields, agents, models, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, provenance, and raw JSON. +`npm run control-plane:smoke` runs an in-process JSON-RPC client over the complete local lifecycle surface: 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, raw JSON, and no-secret response scanning. diff --git a/services/control-plane/src/fixture-state.ts b/services/control-plane/src/fixture-state.ts index 751f0669..b2329a74 100644 --- a/services/control-plane/src/fixture-state.ts +++ b/services/control-plane/src/fixture-state.ts @@ -31,11 +31,21 @@ export const DEFAULT_CONTROL_PLANE_PATHS: ControlPlanePaths = { indexerPath: "services/indexer/out/indexer-state.json", verifierPath: "services/verifier/out/reports.json", artifactsPath: "services/verifier/fixtures/artifacts.json", + devnetLocalStatePath: "devnet/local/state.json", + devnetLocalLaunchStatePath: "devnet/local/launch-v0-state.json", + devnetLocalIndexerHandoffPath: "devnet/local/handoff/generated/indexer-handoff.json", + devnetLocalVerifierHandoffPath: "devnet/local/handoff/generated/verifier-handoff.json", + devnetLocalControlPlaneHandoffPath: "devnet/local/handoff/generated/control-plane-handoff.json", devnetPath: "fixtures/launch-core/generated/devnet/state.json", devnetIndexerHandoffPath: "fixtures/launch-core/generated/devnet/indexer-handoff.json", devnetVerifierHandoffPath: "fixtures/launch-core/generated/devnet/verifier-handoff.json", devnetControlPlaneHandoffPath: "fixtures/launch-core/generated/devnet/control-plane-handoff.json", txFixturesPath: "fixtures/handoff/sample-txs.json", + runtimeStatePath: "devnet/local/state.json", + runtimeIntakeDir: "devnet/local/control-plane-intake", + bridgeObservationPath: "services/bridge-relayer/out/bridge-observation.json", + bridgeObservationIntakePath: "services/bridge-relayer/out/control-plane-observations.json", + bridgeDepositFixturePath: "fixtures/bridge/base-sepolia-mock-deposit.json", }; function resolveRepoPath(path: string): string { @@ -145,6 +155,23 @@ function loadOptionalSource( return value; } +function loadFirstOptionalSource( + name: string, + paths: string[], + sources: Record, +): JsonObject | null { + for (const path of paths) { + const value = maybeReadJson(path); + if (value !== null) { + sources[name] = sourceRecord(name, path, "loaded"); + return value; + } + } + + sources[name] = sourceRecord(name, paths[0] ?? "", "missing", paths.slice(1).join(", ")); + return null; +} + export function controlPlanePaths(overrides: Partial = {}): ControlPlanePaths { return { ...DEFAULT_CONTROL_PLANE_PATHS, @@ -159,11 +186,27 @@ export function loadControlPlaneState(overrides: Partial = {} const indexer = loadOrBuildIndexer(paths.indexerPath, sources); const verifier = loadOrBuildVerifier(paths.verifierPath, indexer, artifacts, sources); const launchCore = loadOrBuildLaunchCore(paths, indexer, verifier, sources); - const devnet = loadOptionalSource("devnet", paths.devnetPath, sources); - const devnetIndexerHandoff = loadOptionalSource("devnetIndexerHandoff", paths.devnetIndexerHandoffPath, sources); - const devnetVerifierHandoff = loadOptionalSource("devnetVerifierHandoff", paths.devnetVerifierHandoffPath, sources); - const devnetControlPlaneHandoff = loadOptionalSource("devnetControlPlaneHandoff", paths.devnetControlPlaneHandoffPath, sources); + const devnet = loadFirstOptionalSource("devnet", [ + paths.devnetLocalStatePath, + paths.devnetLocalLaunchStatePath, + paths.devnetPath, + ], sources); + const devnetIndexerHandoff = loadFirstOptionalSource("devnetIndexerHandoff", [ + paths.devnetLocalIndexerHandoffPath, + paths.devnetIndexerHandoffPath, + ], sources); + const devnetVerifierHandoff = loadFirstOptionalSource("devnetVerifierHandoff", [ + paths.devnetLocalVerifierHandoffPath, + paths.devnetVerifierHandoffPath, + ], sources); + const devnetControlPlaneHandoff = loadFirstOptionalSource("devnetControlPlaneHandoff", [ + paths.devnetLocalControlPlaneHandoffPath, + paths.devnetControlPlaneHandoffPath, + ], sources); const txFixtures = loadOptionalSource("txFixtures", paths.txFixturesPath, sources); + const bridgeObservation = loadOptionalSource("bridgeObservation", paths.bridgeObservationPath, sources); + const bridgeObservationIntake = loadOptionalSource("bridgeObservationIntake", paths.bridgeObservationIntakePath, sources); + const bridgeDepositFixture = loadOptionalSource("bridgeDepositFixture", paths.bridgeDepositFixturePath, sources); return { schema: "flowmemory.control_plane.state.v0", @@ -176,6 +219,9 @@ export function loadControlPlaneState(overrides: Partial = {} devnetVerifierHandoff, devnetControlPlaneHandoff, txFixtures, + bridgeObservation, + bridgeObservationIntake, + bridgeDepositFixture, sources, }; } 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/methods.ts b/services/control-plane/src/methods.ts index dc19c3d8..23fef7dc 100644 --- a/services/control-plane/src/methods.ts +++ b/services/control-plane/src/methods.ts @@ -1,8 +1,14 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + import { canonicalJson, keccak256Hex } from "../../shared/src/index.ts"; import { invalidParams, methodNotFound, objectNotFound } from "./errors.ts"; -import { loadControlPlaneState } from "./fixture-state.ts"; +import { controlPlanePaths, loadControlPlaneState, repoRoot } from "./fixture-state.ts"; +import { scanJsonForSecrets } from "./no-secret.ts"; +import { extractSubmittedTransactions, submitTransactionsToRuntime } from "./runtime-intake.ts"; import type { ControlPlaneContext, + ControlPlanePaths, ControlPlaneMethod, JsonObject, JsonValue, @@ -17,6 +23,14 @@ function stateFor(context: ControlPlaneContext): LoadedControlPlaneState { return context.state ?? loadControlPlaneState(context.paths); } +function pathsFor(context: ControlPlaneContext): ControlPlanePaths { + return controlPlanePaths(context.paths); +} + +function repoPath(path: string): string { + return resolve(repoRoot(), path); +} + function asObjectParams(params: JsonValue | undefined, method: string): JsonObject { if (params === undefined) { return {}; @@ -100,7 +114,29 @@ function stableId(schema: string, value: JsonValue): string { return keccak256Hex(new TextEncoder().encode(canonicalJson({ schema, value }))); } +function devnetSourceKind(state: LoadedControlPlaneState): string { + const path = state.sources.devnet?.path ?? ""; + return path.startsWith("devnet/local/") ? "local-runtime-file" : "committed-fixture"; +} + +function devnetSourcePath(state: LoadedControlPlaneState): string { + return state.sources.devnet?.path ?? "fixtures/launch-core/generated/devnet/state.json"; +} + +function handoffSourcePath(state: LoadedControlPlaneState, name: "devnetIndexerHandoff" | "devnetVerifierHandoff" | "devnetControlPlaneHandoff"): string { + return state.sources[name]?.path ?? "fixtures/launch-core/generated/devnet/state.json"; +} + function latestBlock(state: LoadedControlPlaneState): { blockNumber: string; blockHash: string } { + const devnetBlocks = devnetBlocksArray(state); + const devnetLatest = devnetBlocks[devnetBlocks.length - 1]; + if (devnetLatest !== undefined) { + return { + blockNumber: stringValue(devnetLatest.blockNumber) ?? "0", + blockHash: stringValue(devnetLatest.blockHash) ?? ZERO_ROOT, + }; + } + const latest = [...state.indexer.state.observations].sort((left, right) => { const block = BigInt(right.blockNumber) - BigInt(left.blockNumber); if (block !== 0n) { @@ -120,6 +156,12 @@ function latestBlock(state: LoadedControlPlaneState): { blockNumber: string; blo } function finalizedBlock(state: LoadedControlPlaneState): string { + const devnetBlocks = devnetBlocksArray(state); + const devnetLatest = devnetBlocks[devnetBlocks.length - 1]; + if (devnetLatest !== undefined) { + return stringValue(devnetLatest.blockNumber) ?? "0"; + } + const finalized = state.indexer.state.observations .filter((observation) => observation.lifecycleState === "finalized") .map((observation) => BigInt(observation.blockNumber)); @@ -250,6 +292,244 @@ function devnetFinalityReceipts(state: LoadedControlPlaneState): Record { + return devnetMap(state, "operatorKeyReferences"); +} + +function devnetBaseAnchors(state: LoadedControlPlaneState): Record { + return devnetMap(state, "baseAnchors"); +} + +function pendingTxRows(state: LoadedControlPlaneState): JsonObject[] { + const pending = asJsonArray(state.devnet?.pendingTxs).length > 0 + ? asJsonArray(state.devnet?.pendingTxs) + : asJsonArray(state.devnetControlPlaneHandoff?.pendingTxs); + return pending + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null) + .map((entry) => ({ + schema: "flowmemory.control_plane.mempool_tx.v0", + txId: stringValue(entry.txId) ?? stableId("flowmemory.control_plane.mempool_tx.v0", entry), + transactionId: stringValue(entry.txId) ?? stableId("flowmemory.control_plane.mempool_tx.v0", entry), + tx: asJsonObject(entry.tx) ?? entry, + source: "local-devnet-pending", + localOnly: true, + })); +} + +function publicWalletRows(state: LoadedControlPlaneState): JsonObject[] { + const rows: JsonObject[] = []; + for (const [keyReferenceId, value] of Object.entries(devnetOperatorKeyReferences(state))) { + const reference = asJsonObject(value) ?? {}; + rows.push({ + schema: "flowmemory.control_plane.wallet_public_metadata.v0", + walletId: keyReferenceId, + accountId: stringValue(reference.operatorId) ?? keyReferenceId, + keyReferenceId, + signatureScheme: stringValue(reference.signatureScheme) ?? "local-fixture", + verifierSetRoot: stringValue(reference.verifierSetRoot) ?? null, + publicKeyHint: stringValue(reference.publicKeyHint) ?? null, + secretMaterialBoundary: stringValue(reference.secretMaterialBoundary) ?? "no signing secret material is exposed by the control plane", + source: "local-devnet", + localOnly: true, + }); + } + return rows.sort((left, right) => String(left.walletId).localeCompare(String(right.walletId))); +} + +function accountRows(state: LoadedControlPlaneState): JsonObject[] { + const rows: JsonObject[] = []; + for (const wallet of publicWalletRows(state)) { + rows.push({ + schema: "flowmemory.control_plane.account.v0", + accountId: wallet.accountId, + accountType: "operator", + walletPublicMetadata: wallet, + balance: "0", + asset: "FLOWCHAIN_NO_VALUE", + spendable: false, + source: "local-devnet", + localOnly: true, + }); + } + for (const [agentId, value] of Object.entries(devnetAgentAccounts(state))) { + const agent = asJsonObject(value) ?? {}; + rows.push({ + schema: "flowmemory.control_plane.account.v0", + accountId: agentId, + accountType: "AgentAccount", + controller: stringValue(agent.controller) ?? null, + agentAccount: agent, + balance: "0", + asset: "FLOWCHAIN_NO_VALUE", + spendable: false, + source: "local-devnet", + localOnly: true, + }); + } + return rows.sort((left, right) => String(left.accountId).localeCompare(String(right.accountId))); +} + +function balanceRows(state: LoadedControlPlaneState): JsonObject[] { + return accountRows(state).map((account) => ({ + schema: "flowmemory.control_plane.balance.v0", + accountId: account.accountId, + accountType: account.accountType, + asset: "FLOWCHAIN_NO_VALUE", + balance: "0", + spendable: false, + noValue: true, + limitations: [ + "The private/local devnet has no token value, gas accounting, staking, faucet funds, or bridge asset balances.", + ], + localOnly: true, + })); +} + +function faucetEventRows(_state: LoadedControlPlaneState): JsonObject[] { + return [{ + schema: "flowmemory.control_plane.faucet_event.v0", + eventId: "faucet:disabled:no-value-local-devnet", + status: "disabled_no_value", + amount: "0", + asset: "FLOWCHAIN_NO_VALUE", + recipient: null, + reason: "The private/local devnet has no token value and no faucet funds.", + localOnly: true, + }]; +} + +function bridgeObservationFromDeposit(deposit: JsonObject): JsonObject { + return { + schema: "flowmemory.bridge_deposit_observation.v0", + observationId: stableId("flowmemory.bridge_deposit_observation.mock_projection.v0", deposit), + observedAt: "2026-05-13T00:00:00.000Z", + mode: "mock", + productionReady: false, + deposit, + guardrails: { + explicitChainId: true, + explicitContract: true, + explicitBlockRange: false, + noSecrets: true, + }, + source: "bridge-fixture-projection", + localOnly: true, + }; +} + +function bridgeObservationRows(state: LoadedControlPlaneState): JsonObject[] { + const rows: JsonObject[] = []; + const loaded = asJsonObject(state.bridgeObservation); + if (loaded !== null && loaded.schema === "flowmemory.bridge_deposit_observation.v0") { + rows.push({ + ...loaded, + source: "bridge-relayer-output", + localOnly: true, + }); + } + + const intake = asJsonArray(state.bridgeObservationIntake?.observations) + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null); + rows.push(...intake.map((entry) => ({ + ...entry, + source: "control-plane-bridge-intake", + localOnly: true, + }))); + + const fixtureDeposit = asJsonObject(state.bridgeDepositFixture); + if (fixtureDeposit !== null) { + const projected = bridgeObservationFromDeposit(fixtureDeposit); + if (!rows.some((row) => row.observationId === projected.observationId)) { + rows.push(projected); + } + } + + return rows.sort((left, right) => String(left.observationId).localeCompare(String(right.observationId))); +} + +function bridgeDepositRows(state: LoadedControlPlaneState): JsonObject[] { + return bridgeObservationRows(state).map((observation) => { + const deposit = asJsonObject(observation.deposit) ?? {}; + return { + schema: "flowmemory.control_plane.bridge_deposit.v0", + depositId: stringValue(deposit.depositId) ?? stableId("flowmemory.control_plane.bridge_deposit.v0", deposit), + observationId: observation.observationId, + status: stringValue(deposit.status) ?? "observed", + sourceChainId: deposit.sourceChainId ?? null, + sourceContract: stringValue(deposit.sourceContract) ?? null, + txHash: stringValue(deposit.txHash) ?? null, + logIndex: deposit.logIndex ?? null, + token: stringValue(deposit.token) ?? null, + amount: stringValue(deposit.amount) ?? "0", + sender: stringValue(deposit.sender) ?? null, + flowchainRecipient: stringValue(deposit.flowchainRecipient) ?? null, + nonce: stringValue(deposit.nonce) ?? null, + deposit, + observation, + productionReady: false, + localOnly: true, + }; + }); +} + +function bridgeCreditRows(state: LoadedControlPlaneState): JsonObject[] { + return bridgeDepositRows(state).map((deposit) => ({ + schema: "flowmemory.control_plane.bridge_credit.v0", + creditId: stableId("flowmemory.control_plane.bridge_credit.v0", stringValue(deposit.depositId) ?? ""), + depositId: deposit.depositId, + recipient: deposit.flowchainRecipient, + amount: deposit.amount, + token: deposit.token, + status: deposit.status === "accepted_local" ? "credited_local" : "pending_deposit_observation", + noValueAccounting: true, + limitations: [ + "Bridge credits are local observation records only; no production bridge custody or withdrawal finality is implied.", + ], + localOnly: true, + })); +} + +function withdrawalRows(state: LoadedControlPlaneState): JsonObject[] { + return Object.entries(firstDevnetMap(state, ["withdrawals", "bridgeWithdrawals"])).map(([withdrawalId, value]) => { + const withdrawal = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.withdrawal.v0", + withdrawalId, + status: stringValue(withdrawal.status) ?? "local", + withdrawal, + source: "local-devnet", + productionReady: false, + localOnly: true, + }; + }).sort((left, right) => String(left.withdrawalId).localeCompare(String(right.withdrawalId))); +} + +function writeBridgeObservationIntake(paths: ControlPlanePaths, observation: JsonObject): JsonObject { + const path = repoPath(paths.bridgeObservationIntakePath); + const existing = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as JsonObject : null; + const observations = asJsonArray(existing?.observations) + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null); + const normalized = { + ...observation, + schema: "flowmemory.bridge_deposit_observation.v0", + observationId: stringValue(observation.observationId) ?? stableId("flowmemory.bridge_deposit_observation.intake.v0", observation), + productionReady: false, + localOnly: true, + }; + const next = observations.filter((entry) => entry.observationId !== normalized.observationId); + next.push(normalized); + const payload = { + schema: "flowmemory.control_plane.bridge_observation_intake.v0", + observations: next.sort((left, right) => String(left.observationId).localeCompare(String(right.observationId))), + }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`); + return normalized; +} + function transactionRows(state: LoadedControlPlaneState): JsonObject[] { const rows: JsonObject[] = []; const txFixtures = txFixtureRows(state); @@ -283,6 +563,24 @@ function transactionRows(state: LoadedControlPlaneState): JsonObject[] { }); } + for (const pending of pendingTxRows(state)) { + const tx = asJsonObject(pending.tx); + rows.push({ + schema: "flowmemory.control_plane.transaction.v0", + transactionId: stringValue(pending.txId) ?? stringValue(pending.transactionId) ?? stableId("flowmemory.control_plane.pending_transaction.v0", pending), + txHash: stringValue(pending.txId) ?? stringValue(pending.transactionId) ?? stableId("flowmemory.control_plane.pending_transaction.v0", pending), + blockNumber: null, + blockHash: null, + transactionIndex: null, + status: "pending", + type: stringValue(tx?.type) ?? "unknown", + payload: tx, + receipt: null, + source: "local-devnet-mempool", + localOnly: true, + }); + } + const byHash = new Map(); for (const observation of state.indexer.state.observations) { const existing = byHash.get(observation.txHash) ?? { @@ -707,10 +1005,12 @@ function finalityRows(state: LoadedControlPlaneState): JsonObject[] { function health(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const missing = Object.values(state.sources).filter((source) => source.status === "missing").map((source) => source.name); + const required = new Set(["launchCore", "indexer", "verifier", "artifacts", "devnet", "txFixtures"]); + const missingRequired = missing.filter((name) => required.has(name)); return { schema: "flowmemory.control_plane.health.v0", service: "flowmemory-control-plane-v0", - status: missing.length === 0 ? "ok" : "degraded", + status: missingRequired.length === 0 ? "ok" : "degraded", localOnly: true, checks: { launchCore: state.sources.launchCore.status, @@ -720,6 +1020,9 @@ function health(_params: JsonValue | undefined, context: ControlPlaneContext): J devnet: state.sources.devnet.status, devnetControlPlaneHandoff: state.sources.devnetControlPlaneHandoff.status, txFixtures: state.sources.txFixtures.status, + bridgeObservation: state.sources.bridgeObservation.status, + bridgeObservationIntake: state.sources.bridgeObservationIntake.status, + bridgeDepositFixture: state.sources.bridgeDepositFixture.status, }, counts: { observations: state.indexer.state.observations.length, @@ -727,8 +1030,70 @@ function health(_params: JsonValue | undefined, context: ControlPlaneContext): J rootfields: rootfieldRows(state).length, blocks: blockRows(state).length, transactions: transactionRows(state).length, + pendingTransactions: pendingTxRows(state).length, + bridgeDeposits: bridgeDepositRows(state).length, }, - missingOptionalSources: missing, + missingRequiredSources: missingRequired, + missingOptionalSources: missing.filter((name) => !required.has(name)), + }; +} + +function nodeStatus(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const latest = latestBlock(state); + return { + schema: "flowmemory.control_plane.node_status.v0", + chainId: typeof state.devnet?.chainId === "string" ? state.devnet.chainId : "flowmemory-local-devnet-v0", + networkId: stringValue(asJsonObject(state.devnet?.config)?.networkId) ?? "flowmemory-private-local", + runtimeMode: "bounded-local-cli", + longRunningNode: false, + source: devnetSourceKind(state), + stateSource: state.sources.devnet, + currentBlock: latest.blockNumber, + currentBlockHash: latest.blockHash, + stateRoot: stringValue(state.devnetControlPlaneHandoff?.stateRoot) + ?? stringValue(state.devnetIndexerHandoff?.stateRoot) + ?? stringValue(state.devnet?.parentHash) + ?? ZERO_ROOT, + pendingTransactions: pendingTxRows(state).length, + peerCount: 0, + noValue: true, + localOnly: true, + }; +} + +function peerList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + asObjectParams(params, "peer_list"); + return { + schema: "flowmemory.control_plane.peer_list.v0", + count: 1, + peers: [{ + schema: "flowmemory.control_plane.peer.v0", + peerId: "local-single-process", + status: "self", + transport: "local-cli", + latestBlock: latestBlock(state).blockNumber, + localOnly: true, + }], + limitations: [ + "The current private/local runtime is a single-process deterministic CLI; LAN peer discovery is not implemented.", + ], + localOnly: true, + }; +} + +function mempoolList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "mempool_list"); + const limit = pageLimit(objectParams); + const rows = pendingTxRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.mempool_list.v0", + count: rows.length, + nextCursor: null, + transactions: rows, + localOnly: true, }; } @@ -738,10 +1103,10 @@ function chainStatus(_params: JsonValue | undefined, context: ControlPlaneContex return { schema: "flowmemory.control_plane.chain_status.v0", - chainId: "flowmemory-local-alpha", - settlementContext: "local fixture stack over FlowPulse and local no-value devnet handoff", - environment: "local-devnet-fixture", - source: "fixture", + chainId: typeof state.devnet?.chainId === "string" ? state.devnet.chainId : "flowmemory-local-alpha", + settlementContext: "local fixture stack over FlowPulse and local no-value devnet runtime state", + environment: "private-local-devnet", + source: devnetSourceKind(state), currentBlock: latest.blockNumber, currentBlockHash: latest.blockHash, finalizedBlock: finalizedBlock(state), @@ -765,18 +1130,40 @@ function chainStatus(_params: JsonValue | undefined, context: ControlPlaneContex finalityRows: finalityRows(state).length, blocks: blockRows(state).length, transactions: transactionRows(state).length, + pendingTransactions: pendingTxRows(state).length, devnetBlocks: devnetBlocksArray(state).length, + accounts: accountRows(state).length, + balances: balanceRows(state).length, + faucetEvents: faucetEventRows(state).length, + walletPublicMetadata: publicWalletRows(state).length, + bridgeObservations: bridgeObservationRows(state).length, + bridgeDeposits: bridgeDepositRows(state).length, + bridgeCredits: bridgeCreditRows(state).length, + withdrawals: withdrawalRows(state).length, }, capabilities: [ "health_reads", - "fixture_status_reads", + "live_local_state_reads", + "fixture_fallback_reads", + "node_status_reads", + "peer_reads", + "mempool_reads", "block_reads", "transaction_reads", + "transaction_submission", + "account_reads", + "balance_reads", + "faucet_event_reads", + "wallet_public_metadata_reads", "receipt_lookup", "verifier_report_lookup", "memory_lineage_lookup", "artifact_fixture_lookup", "devnet_handoff_reads", + "bridge_observation_intake", + "bridge_deposit_reads", + "bridge_credit_reads", + "withdrawal_reads", "raw_json_reads", ], limitations: [ @@ -816,10 +1203,12 @@ function devnetState(params: JsonValue | undefined, context: ControlPlaneContext memoryCellCount: Object.keys(devnetMemoryCells(state)).length, challengeCount: Object.keys(devnetChallenges(state)).length, finalityReceiptCount: Object.keys(devnetFinalityReceipts(state)).length, - baseAnchorCount: state.devnet?.baseAnchors && typeof state.devnet.baseAnchors === "object" && !Array.isArray(state.devnet.baseAnchors) - ? Object.keys(state.devnet.baseAnchors).length - : 0, + pendingTransactionCount: pendingTxRows(state).length, + baseAnchorCount: Object.keys(devnetBaseAnchors(state)).length, blocks: includeBlocks ? blocks : undefined, + pendingTransactions: includeBlocks ? pendingTxRows(state) : undefined, + mapRoots: state.devnetControlPlaneHandoff?.mapRoots ?? state.devnetIndexerHandoff?.mapRoots ?? null, + sourceKind: devnetSourceKind(state), source: state.sources.devnet, indexerHandoff: state.devnetIndexerHandoff === null ? null : { schema: state.devnetIndexerHandoff.schema, @@ -877,8 +1266,8 @@ function blockGet(params: JsonValue | undefined, context: ControlPlaneContext): provenance: { sources: [ block.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.block.v0") - : provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0"), + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.block.v0") + : provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0"), ], }, localOnly: true, @@ -924,14 +1313,161 @@ function transactionGet(params: JsonValue | undefined, context: ControlPlaneCont provenance: { sources: [ transaction.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.block.v0") - : provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0"), + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.block.v0") + : provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0"), ], }, localOnly: true, }; } +function transactionSubmit(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const objectParams = asObjectParams(params, "transaction_submit"); + const txs = extractSubmittedTransactions(objectParams); + const findings = scanJsonForSecrets(txs as JsonValue); + if (findings.length > 0) { + throw invalidParams("transaction_submit payload contains forbidden secret-bearing fields", { findings }); + } + try { + const submission = submitTransactionsToRuntime(pathsFor(context), txs); + return { + ...submission, + submissionId: stableId("flowmemory.control_plane.transaction_submission.v0", txs), + }; + } catch (error) { + throw invalidParams(error instanceof Error ? error.message : "transaction_submit failed"); + } +} + +function accountList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "account_list"); + const limit = pageLimit(objectParams); + const accountType = optionalString(objectParams, "accountType"); + const rows = accountRows(state) + .filter((account) => accountType === undefined || account.accountType === accountType) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.account_list.v0", + count: rows.length, + nextCursor: null, + accounts: rows, + localOnly: true, + }; +} + +function accountGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "account_get"); + const accountId = requiredString(objectParams, ["accountId", "agentId", "operatorId"], "account_get"); + const account = accountRows(state).find((candidate) => { + return candidate.accountId === accountId + || asJsonObject(candidate.agentAccount)?.agentId === accountId + || asJsonObject(candidate.walletPublicMetadata)?.accountId === accountId; + }); + if (account === undefined) { + throw objectNotFound(`account not found: ${accountId}`, { accountId }); + } + return { + schema: "flowmemory.control_plane.account_detail.v0", + account, + balance: balanceRows(state).find((candidate) => candidate.accountId === account.accountId) ?? null, + localOnly: true, + }; +} + +function balanceList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "balance_list"); + const limit = pageLimit(objectParams); + const accountId = optionalString(objectParams, "accountId"); + const rows = balanceRows(state) + .filter((balance) => accountId === undefined || balance.accountId === accountId) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.balance_list.v0", + count: rows.length, + nextCursor: null, + balances: rows, + localOnly: true, + }; +} + +function balanceGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "balance_get"); + const accountId = requiredString(objectParams, ["accountId", "agentId", "operatorId"], "balance_get"); + const balance = balanceRows(state).find((candidate) => candidate.accountId === accountId); + if (balance === undefined) { + throw objectNotFound(`balance not found: ${accountId}`, { accountId }); + } + return { + schema: "flowmemory.control_plane.balance_detail.v0", + balance, + localOnly: true, + }; +} + +function faucetEventList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "faucet_event_list"); + const limit = pageLimit(objectParams); + const rows = faucetEventRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.faucet_event_list.v0", + count: rows.length, + nextCursor: null, + faucetEvents: rows, + localOnly: true, + }; +} + +function faucetEventGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "faucet_event_get"); + const eventId = requiredString(objectParams, ["eventId"], "faucet_event_get"); + const event = faucetEventRows(state).find((candidate) => candidate.eventId === eventId); + if (event === undefined) { + throw objectNotFound(`faucet event not found: ${eventId}`, { eventId }); + } + return { + schema: "flowmemory.control_plane.faucet_event_detail.v0", + faucetEvent: event, + localOnly: true, + }; +} + +function walletMetadataList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "wallet_metadata_list"); + const limit = pageLimit(objectParams); + const rows = publicWalletRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.wallet_metadata_list.v0", + count: rows.length, + nextCursor: null, + wallets: rows, + localOnly: true, + }; +} + +function walletMetadataGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "wallet_metadata_get"); + const walletId = requiredString(objectParams, ["walletId", "accountId", "keyReferenceId"], "wallet_metadata_get"); + const wallet = publicWalletRows(state).find((candidate) => { + return candidate.walletId === walletId || candidate.accountId === walletId || candidate.keyReferenceId === walletId; + }); + if (wallet === undefined) { + throw objectNotFound(`wallet metadata not found: ${walletId}`, { walletId }); + } + return { + schema: "flowmemory.control_plane.wallet_metadata_detail.v0", + wallet, + localOnly: true, + }; +} + function rootfieldGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "rootfield_get"); @@ -953,7 +1489,7 @@ function rootfieldGet(params: JsonValue | undefined, context: ControlPlaneContex provenance: { sources: [ bundle ? provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", bundle.schema) : null, - devnetRootfield ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.rootfield.v0") : null, + devnetRootfield ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.rootfield.v0") : null, ].filter((entry): entry is JsonObject => entry !== null), }, localOnly: true, @@ -1015,7 +1551,7 @@ function artifactGet(params: JsonValue | undefined, context: ControlPlaneContext artifact: entry, resolverPolicyId: "flowmemory.local_devnet.artifact_commitment.v0", provenance: { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.artifact_commitment.v0")], + sources: [provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.artifact_commitment.v0")], }, localOnly: true, }; @@ -1065,7 +1601,7 @@ function artifactAvailabilityGet(params: JsonValue | undefined, context: Control sources: [provenanceSource("verifier", "services/verifier/fixtures/artifacts.json", "flowmemory.verifier.artifact_fixture.v0")], } : { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.artifact_commitment.v0")], + sources: [provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.artifact_commitment.v0")], }, localOnly: true, }; @@ -1100,7 +1636,7 @@ function receiptGet(params: JsonValue | undefined, context: ControlPlaneContext) transition: null, verifierReport: null, provenance: { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/verifier-handoff.json", "flowmemory.local_devnet.work_receipt.v0")], + sources: [provenanceSource("devnet", handoffSourcePath(state, "devnetVerifierHandoff"), "flowmemory.local_devnet.work_receipt.v0")], }, localOnly: true, }; @@ -1204,8 +1740,8 @@ function verifierModuleGet(params: JsonValue | undefined, context: ControlPlaneC provenance: { sources: [ verifierModule.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.verifier_module.v0") - : provenanceSource("verifier", "services/verifier/out/reports.json", "flowmemory.verifier.persistence.v0"), + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.verifier_module.v0") + : provenanceSource("verifier", state.sources.verifier.path, "flowmemory.verifier.persistence.v0"), ], }, localOnly: true, @@ -1235,7 +1771,7 @@ function verifierReportGet(params: JsonValue | undefined, context: ControlPlaneC report: devnetReport, memoryReceipt: null, provenance: { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/verifier-handoff.json", "flowmemory.local_devnet.verifier_report.v0")], + sources: [provenanceSource("devnet", handoffSourcePath(state, "devnetVerifierHandoff"), "flowmemory.local_devnet.verifier_report.v0")], }, localOnly: true, }; @@ -1250,9 +1786,31 @@ function verifierReportList(params: JsonValue | undefined, context: ControlPlane const rootfieldId = optionalString(objectParams, "rootfieldId"); const status = optionalString(objectParams, "status"); const limit = pageLimit(objectParams); - const reports = state.verifier.reports - .filter((report) => rootfieldId === undefined || report.reportCore.observation.rootfieldId === rootfieldId) - .filter((report) => status === undefined || report.reportCore.status === status) + const reports = [ + ...Object.entries(devnetReports(state)).map(([reportId, value]) => { + const report = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.verifier_report_row.v0", + reportId, + rootfieldId: stringValue(report.rootfieldId) ?? null, + status: stringValue(report.status) ?? "local", + report, + source: "local-devnet", + localOnly: true, + }; + }), + ...state.verifier.reports.map((report) => ({ + schema: "flowmemory.control_plane.verifier_report_row.v0", + reportId: report.reportId, + rootfieldId: report.reportCore.observation.rootfieldId, + status: report.reportCore.status, + report, + source: "verifier-fixture", + localOnly: true, + })), + ] + .filter((report) => rootfieldId === undefined || report.rootfieldId === rootfieldId) + .filter((report) => status === undefined || report.status === status) .slice(0, limit); return { @@ -1364,6 +1922,53 @@ function agentList(params: JsonValue | undefined, context: ControlPlaneContext): }; } +function agentAccountList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "agent_account_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = Object.entries(devnetAgentAccounts(state)) + .map(([agentId, value]) => { + const agent = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.agent_account.v0", + agentId, + status: stringValue(agent.status) ?? (agent.active === false ? "inactive" : "active"), + agentAccount: agent, + account: accountRows(state).find((account) => account.accountId === agentId) ?? null, + source: "local-devnet", + localOnly: true, + }; + }) + .filter((agent) => status === undefined || agent.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.agent_account_list.v0", + count: rows.length, + nextCursor: null, + agentAccounts: rows, + localOnly: true, + }; +} + +function agentAccountGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "agent_account_get"); + const agentId = requiredString(objectParams, ["agentId", "accountId"], "agent_account_get"); + const agent = Object.entries(devnetAgentAccounts(state)).find(([id, value]) => id === agentId || asJsonObject(value)?.agentId === agentId); + if (agent === undefined) { + throw objectNotFound(`agent account not found: ${agentId}`, { agentId }); + } + return { + schema: "flowmemory.control_plane.agent_account_detail.v0", + agentId: agent[0], + agentAccount: agent[1] as JsonObject, + account: accountRows(state).find((candidate) => candidate.accountId === agent[0]) ?? null, + provenance: provenanceForObject(state, agent[0]), + localOnly: true, + }; +} + function modelList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "model_list"); @@ -1383,6 +1988,53 @@ function modelList(params: JsonValue | undefined, context: ControlPlaneContext): }; } +function modelPassportList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "model_passport_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = Object.entries(devnetModels(state)) + .map(([modelId, value]) => { + const model = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.model_passport.v0", + modelId, + status: stringValue(model.status) ?? (model.active === false ? "inactive" : "active"), + modelPassport: model, + source: "local-devnet", + localOnly: true, + }; + }) + .filter((model) => status === undefined || model.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.model_passport_list.v0", + count: rows.length, + nextCursor: null, + modelPassports: rows, + localOnly: true, + }; +} + +function modelPassportGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "model_passport_get"); + const modelId = requiredString(objectParams, ["modelId", "modelPassportId"], "model_passport_get"); + const model = Object.entries(devnetModels(state)).find(([id, value]) => { + const passport = asJsonObject(value); + return id === modelId || passport?.modelPassportId === modelId; + }); + if (model === undefined) { + throw objectNotFound(`model passport not found: ${modelId}`, { modelId }); + } + return { + schema: "flowmemory.control_plane.model_passport_detail.v0", + modelId: model[0], + modelPassport: model[1] as JsonObject, + localOnly: true, + }; +} + function modelGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "model_get"); @@ -1397,7 +2049,7 @@ function modelGet(params: JsonValue | undefined, context: ControlPlaneContext): provenance: { sources: [ model.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.model_passport.v0") + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.model_passport.v0") : provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", "flowmemory.agent_memory_view.v0", "Projected model row; no ModelPassport fixture exists yet."), ], }, @@ -1550,6 +2202,170 @@ function finalityList(params: JsonValue | undefined, context: ControlPlaneContex }; } +function bridgeObservationSubmit(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const objectParams = asObjectParams(params, "bridge_observation_submit"); + const findings = scanJsonForSecrets(objectParams); + if (findings.length > 0) { + throw invalidParams("bridge_observation_submit payload contains forbidden secret-bearing fields", { findings }); + } + const observationParam = asJsonObject(objectParams.observation); + const depositParam = asJsonObject(objectParams.deposit); + if (observationParam === null && depositParam === null) { + throw invalidParams("bridge_observation_submit requires observation or deposit"); + } + const observation = observationParam ?? bridgeObservationFromDeposit(depositParam ?? {}); + const stored = writeBridgeObservationIntake(pathsFor(context), observation); + return { + schema: "flowmemory.control_plane.bridge_observation_submission.v0", + observation: stored, + accepted: true, + productionReady: false, + localOnly: true, + }; +} + +function bridgeObservationList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_observation_list"); + const limit = pageLimit(objectParams); + const mode = optionalString(objectParams, "mode"); + const rows = bridgeObservationRows(state) + .filter((observation) => mode === undefined || observation.mode === mode) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.bridge_observation_list.v0", + count: rows.length, + nextCursor: null, + observations: rows, + localOnly: true, + }; +} + +function bridgeObservationGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_observation_get"); + const observationId = requiredString(objectParams, ["observationId", "depositId"], "bridge_observation_get"); + const observation = bridgeObservationRows(state).find((candidate) => { + return candidate.observationId === observationId || asJsonObject(candidate.deposit)?.depositId === observationId; + }); + if (observation === undefined) { + throw objectNotFound(`bridge observation not found: ${observationId}`, { observationId }); + } + return { + schema: "flowmemory.control_plane.bridge_observation.v0", + observation, + localOnly: true, + }; +} + +function bridgeDepositList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_deposit_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = bridgeDepositRows(state) + .filter((deposit) => status === undefined || deposit.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.bridge_deposit_list.v0", + count: rows.length, + nextCursor: null, + deposits: rows, + localOnly: true, + }; +} + +function bridgeDepositGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_deposit_get"); + const depositId = requiredString(objectParams, ["depositId", "observationId", "txHash"], "bridge_deposit_get"); + const deposit = bridgeDepositRows(state).find((candidate) => { + return candidate.depositId === depositId || candidate.observationId === depositId || candidate.txHash === depositId; + }); + if (deposit === undefined) { + throw objectNotFound(`bridge deposit not found: ${depositId}`, { depositId }); + } + return { + schema: "flowmemory.control_plane.bridge_deposit_detail.v0", + deposit, + localOnly: true, + }; +} + +function bridgeCreditList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_credit_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = bridgeCreditRows(state) + .filter((credit) => status === undefined || credit.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.bridge_credit_list.v0", + count: rows.length, + nextCursor: null, + credits: rows, + localOnly: true, + }; +} + +function bridgeCreditGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_credit_get"); + const creditId = requiredString(objectParams, ["creditId", "depositId"], "bridge_credit_get"); + const credit = bridgeCreditRows(state).find((candidate) => candidate.creditId === creditId || candidate.depositId === creditId); + if (credit === undefined) { + throw objectNotFound(`bridge credit not found: ${creditId}`, { creditId }); + } + return { + schema: "flowmemory.control_plane.bridge_credit_detail.v0", + credit, + localOnly: true, + }; +} + +function withdrawalList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "withdrawal_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = withdrawalRows(state) + .filter((withdrawal) => status === undefined || withdrawal.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.withdrawal_list.v0", + count: rows.length, + nextCursor: null, + withdrawals: rows, + extensionPoint: rows.length === 0 ? "No local withdrawal handoff objects exist yet; production bridge withdrawals remain out of scope." : undefined, + localOnly: true, + }; +} + +function withdrawalGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "withdrawal_get"); + const withdrawalId = requiredString(objectParams, ["withdrawalId", "depositId", "creditId"], "withdrawal_get"); + const withdrawal = withdrawalRows(state).find((candidate) => candidate.withdrawalId === withdrawalId); + if (withdrawal === undefined) { + return { + schema: "flowmemory.control_plane.withdrawal_detail.v0", + withdrawalId, + status: "not_opened", + withdrawal: null, + extensionPoint: "No local withdrawal object exists for this id. Production bridge withdrawal handling is not implemented.", + productionReady: false, + localOnly: true, + }; + } + return { + schema: "flowmemory.control_plane.withdrawal_detail.v0", + withdrawal, + productionReady: false, + localOnly: true, + }; +} + function findObject(state: LoadedControlPlaneState, key: string): { type: string; object: JsonObject } { const receipt = receiptByAnyId(state, key); if (receipt !== undefined) { @@ -1623,6 +2439,18 @@ function findObject(state: LoadedControlPlaneState, key: string): { type: string if (devnetFinality !== undefined) { return { type: "devnet_finality_receipt", object: devnetFinality as JsonObject }; } + const account = accountRows(state).find((candidate) => candidate.accountId === key); + if (account !== undefined) { + return { type: "account", object: account }; + } + const bridgeDeposit = bridgeDepositRows(state).find((candidate) => candidate.depositId === key || candidate.observationId === key || candidate.txHash === key); + if (bridgeDeposit !== undefined) { + return { type: "bridge_deposit", object: bridgeDeposit }; + } + const bridgeCredit = bridgeCreditRows(state).find((candidate) => candidate.creditId === key || candidate.depositId === key); + if (bridgeCredit !== undefined) { + return { type: "bridge_credit", object: bridgeCredit }; + } throw objectNotFound(`object not found: ${key}`, { id: key }); } @@ -1649,31 +2477,31 @@ function provenanceForObject(state: LoadedControlPlaneState, key: string): JsonO sources.push(provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", "flowmemory.launch_core.v0")); } if (selectedReceipt !== undefined || report !== undefined) { - sources.push(provenanceSource("verifier", "services/verifier/out/reports.json", "flowmemory.verifier.persistence.v0")); + sources.push(provenanceSource("verifier", state.sources.verifier.path, "flowmemory.verifier.persistence.v0")); } if (selectedSignal !== undefined) { - sources.push(provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0")); + sources.push(provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0")); } if (block !== undefined || transaction !== undefined) { const source = block?.source ?? transaction?.source; sources.push(source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.state.v0") - : provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0")); + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.state.v0") + : provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0")); } if (model !== undefined) { sources.push(model.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.model_passport.v0") + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.model_passport.v0") : provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", "flowmemory.agent_memory_view.v0", "Projected model row.")); } if (verifierModule !== undefined) { sources.push(verifierModule.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.verifier_module.v0") - : provenanceSource("verifier", "services/verifier/out/reports.json", "flowmemory.verifier.persistence.v0")); + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.verifier_module.v0") + : provenanceSource("verifier", state.sources.verifier.path, "flowmemory.verifier.persistence.v0")); } if (artifactAvailability !== undefined) { sources.push(artifactAvailability.source === "verifier-fixture" ? provenanceSource("verifier", "services/verifier/fixtures/artifacts.json", "flowmemory.verifier.artifact_fixture.v0") - : provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.artifact_commitment.v0")); + : provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.artifact_commitment.v0")); } links.receiptId = selectedReceipt?.receiptId; @@ -1701,7 +2529,7 @@ function provenanceForObject(state: LoadedControlPlaneState, key: string): JsonO ?? devnetChallenges(state)[key] ?? devnetFinalityReceipts(state)[key]; if (devnetTarget !== undefined) { - sources.push(provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.state.v0")); + sources.push(provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.state.v0")); } } @@ -1760,6 +2588,9 @@ function rawJsonGet(params: JsonValue | undefined, context: ControlPlaneContext) devnetVerifierHandoff: state.devnetVerifierHandoff, devnetControlPlaneHandoff: state.devnetControlPlaneHandoff, txFixtures: state.txFixtures, + bridgeObservation: state.bridgeObservation, + bridgeObservationIntake: state.bridgeObservationIntake, + bridgeDepositFixture: state.bridgeDepositFixture, }; if (!Object.prototype.hasOwnProperty.call(allowed, source)) { @@ -1786,10 +2617,22 @@ export const CONTROL_PLANE_METHODS: Record = health, chain_status: chainStatus, devnet_state: devnetState, + node_status: nodeStatus, + peer_list: peerList, + mempool_list: mempoolList, block_get: blockGet, block_list: blockList, + account_get: accountGet, + account_list: accountList, + balance_get: balanceGet, + balance_list: balanceList, + faucet_event_get: faucetEventGet, + faucet_event_list: faucetEventList, + wallet_metadata_get: walletMetadataGet, + wallet_metadata_list: walletMetadataList, transaction_get: transactionGet, transaction_list: transactionList, + transaction_submit: transactionSubmit, rootfield_get: rootfieldGet, rootfield_list: rootfieldList, artifact_get: artifactGet, @@ -1807,12 +2650,25 @@ export const CONTROL_PLANE_METHODS: Record = memory_cell_list: memoryCellList, agent_get: agentGet, agent_list: agentList, + agent_account_get: agentAccountGet, + agent_account_list: agentAccountList, model_get: modelGet, model_list: modelList, + model_passport_get: modelPassportGet, + model_passport_list: modelPassportList, challenge_get: challengeGet, challenge_list: challengeList, finality_get: finalityGet, finality_list: finalityList, + bridge_observation_submit: bridgeObservationSubmit, + bridge_observation_get: bridgeObservationGet, + bridge_observation_list: bridgeObservationList, + bridge_deposit_get: bridgeDepositGet, + bridge_deposit_list: bridgeDepositList, + bridge_credit_get: bridgeCreditGet, + bridge_credit_list: bridgeCreditList, + withdrawal_get: withdrawalGet, + withdrawal_list: withdrawalList, provenance_get: provenanceGet, raw_json_get: rawJsonGet, }; 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/services/control-plane/src/server.ts b/services/control-plane/src/server.ts index 84f4a9b2..04a9772d 100644 --- a/services/control-plane/src/server.ts +++ b/services/control-plane/src/server.ts @@ -25,6 +25,47 @@ function writeJson(res: ServerResponse, statusCode: number, body: unknown): void res.end(`${JSON.stringify(body)}\n`); } +const getRoutes: Record = { + "/health": "health", + "/state": "devnet_state", + "/node/status": "node_status", + "/peers": "peer_list", + "/mempool": "mempool_list", + "/blocks": "block_list", + "/transactions": "transaction_list", + "/accounts": "account_list", + "/balances": "balance_list", + "/faucet/events": "faucet_event_list", + "/wallets": "wallet_metadata_list", + "/agents": "agent_account_list", + "/models": "model_passport_list", + "/work-receipts": "work_receipt_list", + "/artifacts/availability": "artifact_availability_list", + "/verifier-modules": "verifier_module_list", + "/verifier-reports": "verifier_report_list", + "/memory-cells": "memory_cell_list", + "/challenges": "challenge_list", + "/finality": "finality_list", + "/bridge/observations": "bridge_observation_list", + "/bridge/deposits": "bridge_deposit_list", + "/bridge/credits": "bridge_credit_list", + "/withdrawals": "withdrawal_list", +}; + +function paramsFromSearch(searchParams: URLSearchParams): Record { + const params: Record = {}; + for (const [key, value] of searchParams.entries()) { + if (/^\d+$/.test(value) && key === "limit") { + params[key] = Number(value); + } else if (value === "true" || value === "false") { + params[key] = value === "true"; + } else { + params[key] = value; + } + } + return params; +} + function parseArgs(args: string[]): ServerOptions { const options: ServerOptions = { host: "127.0.0.1", @@ -58,7 +99,6 @@ function parseArgs(args: string[]): ServerOptions { } export function startControlPlaneServer(options: ServerOptions): ReturnType { - const state = loadControlPlaneState(); const server = createServer((req, res) => { if (req.method === "OPTIONS") { res.writeHead(204, jsonHeaders); @@ -66,19 +106,21 @@ export function startControlPlaneServer(options: ServerOptions): ReturnType { try { const payload = JSON.parse(body) as unknown; - const response = dispatchJsonRpc(payload, { state }); + const state = loadControlPlaneState(); + const rpcPayload = url.pathname === "/transactions" + ? { jsonrpc: "2.0", id: "transaction_submit", method: "transaction_submit", params: payload } + : url.pathname === "/bridge/observations" + ? { jsonrpc: "2.0", id: "bridge_observation_submit", method: "bridge_observation_submit", params: payload } + : payload; + const response = dispatchJsonRpc(rpcPayload, { state }); if (response === undefined) { res.writeHead(204, jsonHeaders); res.end(); diff --git a/services/control-plane/src/smoke.ts b/services/control-plane/src/smoke.ts index 482a23e9..137e323c 100644 --- a/services/control-plane/src/smoke.ts +++ b/services/control-plane/src/smoke.ts @@ -1,7 +1,11 @@ import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { dispatchJsonRpc } from "./json-rpc.ts"; import { loadControlPlaneState } from "./fixture-state.ts"; +import { assertNoSecrets } from "./no-secret.ts"; import type { JsonObject, RpcErrorResponse, RpcSuccessResponse } from "./types.ts"; function firstDevnetBlock(state: ReturnType): JsonObject { @@ -22,6 +26,7 @@ function stringField(value: unknown, name: string): string { export function runControlPlaneSmoke(): JsonObject { const state = loadControlPlaneState(); + const tempDir = mkdtempSync(join(tmpdir(), "flowmemory-control-plane-smoke-")); const rootfieldId = state.launchCore.rootfieldBundles[0]?.rootfieldId; const receipt = state.launchCore.memoryReceipts[0]; const reportId = receipt?.reportId; @@ -29,71 +34,135 @@ export function runControlPlaneSmoke(): JsonObject { const block = firstDevnetBlock(state); const txIds = Array.isArray(block.txIds) ? block.txIds : []; const txId = stringField(txIds[0], "devnet txId"); + const walletId = Object.keys((state.devnet?.operatorKeyReferences ?? {}) as Record)[0]; + const agentId = Object.keys((state.devnet?.agentAccounts ?? {}) as Record)[0]; + const modelId = Object.keys((state.devnet?.modelPassports ?? {}) as Record)[0]; + const memoryCellId = Object.keys((state.devnet?.memoryCells ?? {}) as Record)[0]; + const challengeId = Object.keys((state.devnet?.challenges ?? {}) as Record)[0]; + const finalityReceiptId = Object.keys((state.devnet?.finalityReceipts ?? {}) as Record)[0]; + const bridgeDepositId = typeof state.bridgeDepositFixture?.depositId === "string" ? state.bridgeDepositFixture.depositId : undefined; - if (rootfieldId === undefined || receipt === undefined || reportId === undefined || artifactUri === undefined) { + if ( + rootfieldId === undefined + || receipt === undefined + || reportId === undefined + || artifactUri === undefined + || walletId === undefined + || agentId === undefined + || modelId === undefined + || memoryCellId === undefined + || challengeId === undefined + || finalityReceiptId === undefined + || bridgeDepositId === undefined + ) { throw new Error("control-plane smoke requires launch-core rootfield, receipt, report, and artifact fixture data"); } - const requests = [ - { jsonrpc: "2.0", id: "health", method: "health" }, - { jsonrpc: "2.0", id: "chain", method: "chain_status" }, - { jsonrpc: "2.0", id: "devnet", method: "devnet_state", params: { includeBlocks: true } }, - { jsonrpc: "2.0", id: "blocks", method: "block_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "block", method: "block_get", params: { blockNumber: stringField(block.blockNumber, "blockNumber"), includeTransactions: true } }, - { jsonrpc: "2.0", id: "transactions", method: "transaction_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "transaction", method: "transaction_get", params: { txId } }, - { jsonrpc: "2.0", id: "rootfields", method: "rootfield_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "rootfield", method: "rootfield_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "agents", method: "agent_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "agent", method: "agent_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "models", method: "model_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "model", method: "model_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "workReceipts", method: "work_receipt_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "workReceipt", method: "work_receipt_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "artifactAvailability", method: "artifact_availability_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "artifact", method: "artifact_availability_get", params: { uri: artifactUri } }, - { jsonrpc: "2.0", id: "modules", method: "verifier_module_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "module", method: "verifier_module_get", params: { resolverPolicyId: receipt.resolverPolicyId } }, - { jsonrpc: "2.0", id: "reports", method: "verifier_report_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "report", method: "verifier_report_get", params: { reportId } }, - { jsonrpc: "2.0", id: "receipts", method: "receipt_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "receipt", method: "receipt_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "memoryCells", method: "memory_cell_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "memoryCell", method: "memory_cell_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "challenges", method: "challenge_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "challenge", method: "challenge_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "finalityList", method: "finality_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "finality", method: "finality_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "provenance", method: "provenance_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "raw", method: "raw_json_get", params: { source: "launchCore" } }, - ] as const; + try { + const requests = [ + { jsonrpc: "2.0", id: "health", method: "health" }, + { jsonrpc: "2.0", id: "chain", method: "chain_status" }, + { jsonrpc: "2.0", id: "node", method: "node_status" }, + { jsonrpc: "2.0", id: "peers", method: "peer_list" }, + { jsonrpc: "2.0", id: "mempool", method: "mempool_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "devnet", method: "devnet_state", params: { includeBlocks: true } }, + { jsonrpc: "2.0", id: "blocks", method: "block_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "block", method: "block_get", params: { blockNumber: stringField(block.blockNumber, "blockNumber"), includeTransactions: true } }, + { jsonrpc: "2.0", id: "transactions", method: "transaction_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "transaction", method: "transaction_get", params: { txId } }, + { jsonrpc: "2.0", id: "transactionSubmit", method: "transaction_submit", params: { tx: { type: "RegisterRootfield", rootfieldId: "rootfield:smoke:queued-only", owner: "operator:smoke", schemaHash: "0x0d05a0ad7f9c8650e1f9b6f92a9714d7e9b7c29fcd067a8e3d48ccf8a84d1e7a", metadataHash: "0x2b49f44f3d7f2a97970cc7ee3cb3cb9e5db4c4ab65f9fd797f0c703275c9eabc" } } }, + { jsonrpc: "2.0", id: "accounts", method: "account_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "account", method: "account_get", params: { accountId: agentId } }, + { jsonrpc: "2.0", id: "balances", method: "balance_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "balance", method: "balance_get", params: { accountId: agentId } }, + { jsonrpc: "2.0", id: "faucetEvents", method: "faucet_event_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "faucetEvent", method: "faucet_event_get", params: { eventId: "faucet:disabled:no-value-local-devnet" } }, + { jsonrpc: "2.0", id: "wallets", method: "wallet_metadata_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "wallet", method: "wallet_metadata_get", params: { walletId } }, + { jsonrpc: "2.0", id: "rootfields", method: "rootfield_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "rootfield", method: "rootfield_get", params: { rootfieldId } }, + { jsonrpc: "2.0", id: "agents", method: "agent_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "agent", method: "agent_get", params: { rootfieldId } }, + { jsonrpc: "2.0", id: "agentAccounts", method: "agent_account_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "agentAccount", method: "agent_account_get", params: { agentId } }, + { jsonrpc: "2.0", id: "models", method: "model_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "model", method: "model_get", params: { rootfieldId } }, + { jsonrpc: "2.0", id: "modelPassports", method: "model_passport_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "modelPassport", method: "model_passport_get", params: { modelId } }, + { jsonrpc: "2.0", id: "workReceipts", method: "work_receipt_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "workReceipt", method: "work_receipt_get", params: { receiptId: receipt.receiptId } }, + { jsonrpc: "2.0", id: "artifactAvailability", method: "artifact_availability_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "artifact", method: "artifact_availability_get", params: { uri: artifactUri } }, + { jsonrpc: "2.0", id: "modules", method: "verifier_module_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "module", method: "verifier_module_get", params: { resolverPolicyId: receipt.resolverPolicyId } }, + { jsonrpc: "2.0", id: "reports", method: "verifier_report_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "report", method: "verifier_report_get", params: { reportId } }, + { jsonrpc: "2.0", id: "receipts", method: "receipt_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "receipt", method: "receipt_get", params: { receiptId: receipt.receiptId } }, + { jsonrpc: "2.0", id: "memoryCells", method: "memory_cell_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "memoryCell", method: "memory_cell_get", params: { memoryCellId } }, + { jsonrpc: "2.0", id: "challenges", method: "challenge_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "challenge", method: "challenge_get", params: { challengeId } }, + { jsonrpc: "2.0", id: "finalityList", method: "finality_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "finality", method: "finality_get", params: { objectId: finalityReceiptId } }, + { jsonrpc: "2.0", id: "bridgeObservationSubmit", method: "bridge_observation_submit", params: { deposit: state.bridgeDepositFixture } }, + { jsonrpc: "2.0", id: "bridgeObservations", method: "bridge_observation_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "bridgeObservation", method: "bridge_observation_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "bridgeDeposits", method: "bridge_deposit_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "bridgeDeposit", method: "bridge_deposit_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "bridgeCredits", method: "bridge_credit_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "bridgeCredit", method: "bridge_credit_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "withdrawals", method: "withdrawal_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "withdrawal", method: "withdrawal_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "provenance", method: "provenance_get", params: { receiptId: receipt.receiptId } }, + { jsonrpc: "2.0", id: "raw", method: "raw_json_get", params: { source: "launchCore" } }, + { jsonrpc: "2.0", id: "rawBridge", method: "raw_json_get", params: { source: "bridgeDepositFixture" } }, + ] as const; - const response = dispatchJsonRpc([...requests], { state }); - if (!Array.isArray(response)) { - throw new Error("control-plane smoke expected batch JSON-RPC response"); - } + const response = dispatchJsonRpc([...requests], { + state, + paths: { + runtimeStatePath: join(tempDir, "state.json"), + runtimeIntakeDir: join(tempDir, "intake"), + bridgeObservationIntakePath: join(tempDir, "bridge-observations.json"), + }, + }); + if (!Array.isArray(response)) { + throw new Error("control-plane smoke expected batch JSON-RPC response"); + } - const errors = response.filter((entry): entry is RpcErrorResponse => "error" in entry); - if (errors.length > 0) { - throw new Error(`control-plane smoke failed: ${JSON.stringify(errors, null, 2)}`); - } + const errors = response.filter((entry): entry is RpcErrorResponse => "error" in entry); + if (errors.length > 0) { + throw new Error(`control-plane smoke failed: ${JSON.stringify(errors, null, 2)}`); + } - const successes = response as RpcSuccessResponse[]; - return { - schema: "flowmemory.control_plane.smoke.v0", - ok: true, - methodCount: requests.length, - responseSchemas: successes.map((entry) => (entry.result as JsonObject).schema), - queried: { - rootfieldId, - receiptId: receipt.receiptId, - reportId, - artifactUri, - blockNumber: stringField(block.blockNumber, "blockNumber"), - txId, - }, - localOnly: true, - }; + const successes = response as RpcSuccessResponse[]; + successes.forEach((entry) => assertNoSecrets(entry.result)); + return { + schema: "flowmemory.control_plane.smoke.v0", + ok: true, + methodCount: requests.length, + responseSchemas: successes.map((entry) => (entry.result as JsonObject).schema), + noSecretResponseScan: "passed", + queried: { + rootfieldId, + receiptId: receipt.receiptId, + reportId, + artifactUri, + blockNumber: stringField(block.blockNumber, "blockNumber"), + txId, + agentId, + modelId, + memoryCellId, + challengeId, + finalityReceiptId, + bridgeDepositId, + }, + localOnly: true, + }; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } } if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/services/control-plane/src/types.ts b/services/control-plane/src/types.ts index d0a0b948..49f27316 100644 --- a/services/control-plane/src/types.ts +++ b/services/control-plane/src/types.ts @@ -16,10 +16,22 @@ export type ControlPlaneMethod = | "health" | "chain_status" | "devnet_state" + | "node_status" + | "peer_list" + | "mempool_list" | "block_get" | "block_list" + | "account_get" + | "account_list" + | "balance_get" + | "balance_list" + | "faucet_event_get" + | "faucet_event_list" + | "wallet_metadata_get" + | "wallet_metadata_list" | "transaction_get" | "transaction_list" + | "transaction_submit" | "rootfield_get" | "rootfield_list" | "artifact_get" @@ -37,12 +49,25 @@ export type ControlPlaneMethod = | "memory_cell_list" | "agent_get" | "agent_list" + | "agent_account_get" + | "agent_account_list" | "model_get" | "model_list" + | "model_passport_get" + | "model_passport_list" | "challenge_get" | "challenge_list" | "finality_get" | "finality_list" + | "bridge_observation_submit" + | "bridge_observation_get" + | "bridge_observation_list" + | "bridge_deposit_get" + | "bridge_deposit_list" + | "bridge_credit_get" + | "bridge_credit_list" + | "withdrawal_get" + | "withdrawal_list" | "provenance_get" | "raw_json_get"; @@ -51,11 +76,21 @@ export interface ControlPlanePaths { indexerPath: string; verifierPath: string; artifactsPath: string; + devnetLocalStatePath: string; + devnetLocalLaunchStatePath: string; + devnetLocalIndexerHandoffPath: string; + devnetLocalVerifierHandoffPath: string; + devnetLocalControlPlaneHandoffPath: string; devnetPath: string; devnetIndexerHandoffPath: string; devnetVerifierHandoffPath: string; devnetControlPlaneHandoffPath: string; txFixturesPath: string; + runtimeStatePath: string; + runtimeIntakeDir: string; + bridgeObservationPath: string; + bridgeObservationIntakePath: string; + bridgeDepositFixturePath: string; } export interface DataSourceRecord { @@ -77,6 +112,9 @@ export interface LoadedControlPlaneState { devnetVerifierHandoff: JsonObject | null; devnetControlPlaneHandoff: JsonObject | null; txFixtures: JsonObject | null; + bridgeObservation: JsonObject | null; + bridgeObservationIntake: JsonObject | null; + bridgeDepositFixture: JsonObject | null; sources: Record; } diff --git a/services/control-plane/test/control-plane.test.ts b/services/control-plane/test/control-plane.test.ts index fe925d2e..062d8da4 100644 --- a/services/control-plane/test/control-plane.test.ts +++ b/services/control-plane/test/control-plane.test.ts @@ -9,6 +9,7 @@ import { canonicalJson } from "../../shared/src/index.ts"; import { dispatchJsonRpc, loadControlPlaneState, + scanJsonForSecrets, type RpcErrorResponse, type RpcSuccessResponse, } from "../src/index.ts"; @@ -66,10 +67,12 @@ test("keeps deterministic chain status response snapshots", () => { }; assert.equal(snapshot(first), snapshot(second)); - assert.equal( - snapshot(first), - "{\"capabilities\":[\"health_reads\",\"fixture_status_reads\",\"block_reads\",\"transaction_reads\",\"receipt_lookup\",\"verifier_report_lookup\",\"memory_lineage_lookup\",\"artifact_fixture_lookup\",\"devnet_handoff_reads\",\"raw_json_reads\"],\"chainId\":\"flowmemory-local-alpha\",\"counts\":{\"agents\":2,\"artifactAvailability\":5,\"blocks\":11,\"challenges\":1,\"devnetBlocks\":2,\"duplicates\":1,\"finalityRows\":9,\"memoryCells\":1,\"memoryReceipts\":8,\"memorySignals\":8,\"models\":2,\"observations\":8,\"rejectedLogs\":2,\"rootfields\":2,\"transactions\":23,\"verifierModules\":3,\"verifierReports\":8,\"workReceipts\":9},\"schema\":\"flowmemory.control_plane.chain_status.v0\"}", - ); + assert.equal(first.result.chainId, "flowmemory-local-devnet-v0"); + assert.ok((first.result.capabilities as string[]).includes("live_local_state_reads")); + assert.ok((first.result.capabilities as string[]).includes("transaction_submission")); + assert.ok((first.result.capabilities as string[]).includes("bridge_observation_intake")); + assert.equal(first.result.counts.observations, 8); + assert.equal(first.result.counts.bridgeDeposits, 1); }); test("recovers when generated launch/indexer/verifier fixtures are missing", () => { @@ -169,10 +172,38 @@ test("smoke client queries the complete local lifecycle surface", () => { assert.equal(smoke.schema, "flowmemory.control_plane.smoke.v0"); assert.equal(smoke.ok, true); - assert.equal(smoke.methodCount, 31); + assert.equal(smoke.methodCount, 57); + assert.equal(smoke.noSecretResponseScan, "passed"); assert.ok((smoke.responseSchemas as string[]).includes("flowmemory.control_plane.raw_json.v0")); }); +test("detects secret-bearing response keys", () => { + const findings = scanJsonForSecrets({ + schema: "test", + privateKey: "not-allowed", + }); + + assert.equal(findings.length, 1); + assert.equal(findings[0]?.reason, "forbidden secret-bearing key"); +}); + +test("rejects transaction submissions with secret-bearing fields", () => { + const response = dispatchJsonRpc({ + jsonrpc: "2.0", + id: 1, + method: "transaction_submit", + params: { + tx: { + type: "RegisterRootfield", + privateKey: "not-allowed", + }, + }, + }) as RpcErrorResponse; + + assert.equal(response.error.code, -32602); + assert.equal(response.error.data.reasonCode, "params.invalid"); +}); + test("HTTP server exposes browser-safe health and state endpoints", async () => { const server = startControlPlaneServer({ host: "127.0.0.1", port: 0 }); @@ -196,6 +227,20 @@ test("HTTP server exposes browser-safe health and state endpoints", async () => assert.equal(state.status, 200); assert.equal(state.headers.get("access-control-allow-origin"), "*"); assert.equal((await state.json()).schema, "flowmemory.control_plane.devnet_state.v0"); + + const node = await fetch(`http://127.0.0.1:${port}/node/status`, { + headers: { Origin: "http://127.0.0.1:5173" }, + }); + assert.equal(node.status, 200); + assert.equal(node.headers.get("access-control-allow-origin"), "*"); + assert.equal((await node.json()).schema, "flowmemory.control_plane.node_status.v0"); + + const deposits = await fetch(`http://127.0.0.1:${port}/bridge/deposits?limit=1`, { + headers: { Origin: "http://127.0.0.1:5173" }, + }); + assert.equal(deposits.status, 200); + assert.equal(deposits.headers.get("access-control-allow-origin"), "*"); + assert.equal((await deposits.json()).schema, "flowmemory.control_plane.bridge_deposit_list.v0"); } finally { await new Promise((resolve, reject) => { server.close((error) => { From 82dd8fda8584e6fcc352688d41f20481ae8c8723 Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 18:07:52 -0500 Subject: [PATCH 05/10] Add FlowChain local wallet envelopes --- crypto/.gitignore | 3 + crypto/FLOWCHAIN_LOCAL_ALPHA_OBJECTS.md | 60 ++++ crypto/FLOWMEMORY_CRYPTO_SPEC.md | 13 +- crypto/README.md | 24 +- crypto/TEST_VECTORS.md | 19 +- crypto/fixtures/local-alpha-objects.json | 120 +++++++ .../fixtures/local-transaction-vectors.json | 291 ++++++++++++++++ crypto/fixtures/vectors.json | 125 ++++++- crypto/package.json | 11 +- crypto/src/constants.js | 23 +- crypto/src/index.d.ts | 198 +++++++++++ crypto/src/index.js | 2 + crypto/src/objects.js | 285 +++++++++++++++ crypto/src/transactions.js | 251 ++++++++++++++ .../validate-local-transaction-fixtures.js | 103 ++++++ crypto/src/validate-vectors.js | 22 +- crypto/src/wallet-cli.js | 187 ++++++++++ crypto/src/wallet.js | 326 ++++++++++++++++++ crypto/test/crypto.test.js | 140 +++++++- .../flowchain-local-wallet-envelope-v0.md | 63 ++++ docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md | 12 +- schemas/flowmemory/README.md | 17 + schemas/flowmemory/bridge-credit.schema.json | 35 ++ .../flowmemory/bridge-withdrawal.schema.json | 38 ++ .../local-account-balance.schema.json | 35 ++ .../local-transaction-envelope.schema.json | 70 ++++ .../local-wallet-public-metadata.schema.json | 86 +++++ 27 files changed, 2534 insertions(+), 25 deletions(-) create mode 100644 crypto/fixtures/local-transaction-vectors.json create mode 100644 crypto/src/transactions.js create mode 100644 crypto/src/validate-local-transaction-fixtures.js create mode 100644 crypto/src/wallet-cli.js create mode 100644 crypto/src/wallet.js create mode 100644 docs/DECISIONS/flowchain-local-wallet-envelope-v0.md create mode 100644 schemas/flowmemory/bridge-credit.schema.json create mode 100644 schemas/flowmemory/bridge-withdrawal.schema.json create mode 100644 schemas/flowmemory/local-account-balance.schema.json create mode 100644 schemas/flowmemory/local-transaction-envelope.schema.json create mode 100644 schemas/flowmemory/local-wallet-public-metadata.schema.json 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/FLOWCHAIN_LOCAL_ALPHA_OBJECTS.md b/crypto/FLOWCHAIN_LOCAL_ALPHA_OBJECTS.md index a82f207a..12fe6121 100644 --- a/crypto/FLOWCHAIN_LOCAL_ALPHA_OBJECTS.md +++ b/crypto/FLOWCHAIN_LOCAL_ALPHA_OBJECTS.md @@ -61,6 +61,10 @@ pre-hashed before entering the typed object. | MemoryCell | `memoryCellId` | `memoryCellV0` | `memoryCellId` | `memoryCellId` | | Challenge | `challengeId` | `challengeV0` | `challengeId` | `challengeId` | | FinalityReceipt | `finalityReceiptId` | `finalityReceiptV0` | `finalityReceiptId` | `finalityReceiptId` | +| BridgeDeposit | `depositId` | `bridgeDepositV0` | `bridgeDepositId` | `bridgeDepositId` | +| BridgeCredit | `creditId` | `bridgeCreditV0` | `bridgeCreditId` | `bridgeCreditId` | +| BridgeWithdrawal | `withdrawalId` | `bridgeWithdrawalV0` | `bridgeWithdrawalId` | `bridgeWithdrawalId` | +| LocalAccountBalance | `balanceId` | `localAccountBalanceV0` | `localAccountBalanceId` | `localAccountBalanceId` | | HardwareSignalEnvelope | `hardwareSignalEnvelopeId` | `hardwareSignalEnvelopeV0` | `hardwareSignalEnvelopeId` | `hardwareSignalEnvelopeId` | | Control-plane provenance response | `provenanceResponseId` | `controlPlaneProvenanceResponseV0` | `controlPlaneProvenanceResponseId` | `controlPlaneProvenanceResponseId` | @@ -77,6 +81,20 @@ domain separator, signer ID, signer key ID, signer role, sequence, validity window, and nonce. The signing digest is the local EIP-712 style digest over that struct hash and the object domain separator. +`LocalTransactionEnvelope` uses `localTransactionEnvelopeV0` and +`localTransactionEnvelopeHash`. It signs the chain id, nonce, signer ID, signer +key ID, signer role, canonical payload hash, validity window, and transaction +domain separator. The JSON payload preserves `payload.tx` so the existing +devnet transaction model can unwrap and submit the same transaction object after +envelope validation. + +`BridgeDeposit` intentionally keeps the existing +`flowmemory.bridge_deposit.v0` schema used by the bridge observer. The crypto +ID is a typed hash over the Base source chain id, source contract, tx hash, +log index, token, amount, sender, FlowChain recipient, nonce, and metadata +hash. `BridgeCredit` and `BridgeWithdrawal` are local FlowChain objects that +record no-production bridge accounting for private/local testing only. + Runnable definitions live in `crypto/src/objects.js`. Canonical object fixtures live in: @@ -103,8 +121,14 @@ schemas/flowmemory/verifier-module.schema.json schemas/flowmemory/verifier-report.schema.json schemas/flowmemory/challenge.schema.json schemas/flowmemory/finality-receipt.schema.json +schemas/flowmemory/bridge-deposit.schema.json +schemas/flowmemory/bridge-credit.schema.json +schemas/flowmemory/bridge-withdrawal.schema.json +schemas/flowmemory/local-account-balance.schema.json schemas/flowmemory/hardware-signal-envelope.schema.json schemas/flowmemory/local-signature-envelope.schema.json +schemas/flowmemory/local-transaction-envelope.schema.json +schemas/flowmemory/local-wallet-public-metadata.schema.json schemas/flowmemory/control-plane-provenance-response.schema.json ``` @@ -136,6 +160,41 @@ signer, bad signature, zero hash, malformed ID, malformed dependency, bad parent/root, and wrong object type. Every Local Alpha object envelope also has a valid fixture and a bad-signature invalid fixture. +Local transaction envelope validation additionally requires: + +- `domain` and `domainSeparator` match `flowchain.local.v0.transaction-envelope`. +- the context-supplied chain id matches the envelope chain id. +- `payloadHash` recomputes from canonical JSON. +- `signerId` and `signerKeyId` derive from the public key and signer role. +- the caller supplies replay context and rejects repeated signer/domain/nonce tuples. +- object documents embedded under `payload.object` pass the same object ID and root checks. +- the secp256k1 signature verifies against the transaction signing digest. + +The transaction vectors cover wrong chain id, wrong domain, wrong signer, +replayed nonce, malformed roots, malformed bridge deposit, and changed object +type. + +## Local Wallet Boundary + +`crypto/src/wallet.js` provides an encrypted local vault for no-value test keys. +It supports create, unlock, list public accounts, sign transaction, verify +transaction, public metadata import/export, and rotate/create additional +accounts. The vault uses scrypt plus AES-256-GCM and stores private keys only in +the encrypted blob. The export shape is +`flowchain.local_wallet_public_metadata.v0`, which contains public account IDs, +signer IDs, key IDs, roles, public keys, labels, and nonces only. + +The CLI entry point is `crypto/src/wallet-cli.js` and is exposed through: + +```powershell +npm run wallet:create --prefix crypto +npm run wallet:sign --prefix crypto +npm run wallet:verify --prefix crypto +``` + +Set `FLOWCHAIN_WALLET_PASSWORD` for non-interactive use. The default vault path +is `crypto/.wallet/flowchain-wallet.local.json`, which is ignored by git. + ## Consumer Rules Chain/devnet: @@ -178,6 +237,7 @@ V0 also proves: - domain/type-string separation for each object class; - malformed hex rejection for bytes32/address fields; - canonical JSON stability for pre-hashed control-plane response bodies; +- local wallet-signed transaction envelope verification without exposing private keys; - duplicate ID detection in fixture validation; - explicit finality and challenge state labels for local/test consumers. diff --git a/crypto/FLOWMEMORY_CRYPTO_SPEC.md b/crypto/FLOWMEMORY_CRYPTO_SPEC.md index 44b23c46..2d016cc7 100644 --- a/crypto/FLOWMEMORY_CRYPTO_SPEC.md +++ b/crypto/FLOWMEMORY_CRYPTO_SPEC.md @@ -110,9 +110,16 @@ artifactAvailabilityProofId verifierModuleId challengeId finalityReceiptId +bridgeDepositId +bridgeCreditId +bridgeWithdrawalId +localAccountBalanceId +localSignerId +localSignerKeyId hardwareSignalEnvelopeId controlPlaneProvenanceResponseId localSignatureEnvelope +localTransactionEnvelope ``` ## Versioning Strategy @@ -138,8 +145,10 @@ The current package implements: - deterministic verifier reports - verifier signature envelopes - reorg-aware status handling -- FlowChain Local Alpha object identity for agent accounts, model passports, work receipts, artifact availability proofs, verifier modules, verifier reports, memory cells, challenges, finality receipts, hardware signal envelopes, and control-plane provenance responses +- FlowChain Local Alpha object identity for agent accounts, model passports, work receipts, artifact availability proofs, verifier modules, verifier reports, memory cells, challenges, finality receipts, bridge deposits, bridge credits, bridge withdrawals, local account balances, hardware signal envelopes, and control-plane provenance responses - Local Alpha operator, agent, verifier, and hardware signature envelope payloads and validators for replay, wrong domain, missing signer, zero hash, malformed id, malformed dependency, bad parent/root, and wrong object type checks +- local transaction envelopes that bind domain, chain id, nonce, signer, payload hash, validity window, and signature while preserving `payload.tx` for devnet consumers +- encrypted local wallet/vault helpers for no-value test keys with public metadata import/export and rotation - test vectors and cross-language conformance tests The runnable package in `crypto/src/` currently implements the v0 hash utilities and tests them against fixtures in `crypto/fixtures/`. @@ -151,7 +160,7 @@ MVP should remain verifier-attested for: - storage provider claims - model or worker behavior - final status labels before proof systems exist -- local operator-vault policy; current fixture keys are deterministic no-value test keys and do not represent wallet custody, production account control, or transferable value +- local operator-vault policy; current fixture keys and local wallet keys are deterministic or generated no-value test keys and do not represent production wallet custody, production account control, or transferable value ## Future Split diff --git a/crypto/README.md b/crypto/README.md index 93cd96ea..bd913243 100644 --- a/crypto/README.md +++ b/crypto/README.md @@ -40,6 +40,20 @@ canonical JSON Schemas: npm run validate:local-alpha ``` +Create and use a local encrypted no-value wallet vault: + +```powershell +$env:FLOWCHAIN_WALLET_PASSWORD = "replace-with-local-test-password" +npm run wallet:create +npm run wallet:sign +npm run wallet:verify +``` + +The default vault and generated envelopes live under `crypto/.wallet/`, which is +ignored by git. Use `wallet:list`, `wallet:unlock`, `wallet:rotate`, +`wallet:export-public`, and `wallet:import-public` for public account metadata +and additional local accounts. + ## Read Order 1. `FLOWMEMORY_CRYPTO_SPEC.md` @@ -50,7 +64,7 @@ npm run validate:local-alpha 6. `FLOWCHAIN_LOCAL_ALPHA_OBJECTS.md` 7. `TEST_VECTORS.md` -Runnable fixtures live in `fixtures/`. `fixtures/vectors.json` contains the current 33 package-level vectors. `fixtures/local-alpha-objects.json` contains positive and negative Local Alpha object and signed-envelope fixtures. Supporting cross-language vectors live in `test-vectors/`. +Runnable fixtures live in `fixtures/`. `fixtures/vectors.json` contains the current 41 package-level vectors. `fixtures/local-alpha-objects.json` contains positive and negative Local Alpha object and signed-envelope fixtures. `fixtures/local-transaction-vectors.json` contains wallet-signed local transaction envelopes and negative transaction vectors. Supporting cross-language vectors live in `test-vectors/`. Validate the current vector set with: @@ -68,12 +82,14 @@ The Python validator is a cross-check for the FlowPulse aggregate vector. The pr - `artifactRoot`: commitment to off-chain artifact bytes and metadata. - `reportId`: deterministic identifier for a verifier report. - `attestation`: signed worker or verifier envelope over a receipt, report, artifact, or root. -- Local Alpha object IDs: canonical IDs for `AgentAccount`, `ModelPassport`, `WorkReceipt`, `ArtifactAvailabilityProof`, `VerifierModule`, `VerifierReport`, `MemoryCell`, `Challenge`, `FinalityReceipt`, hardware signal envelopes, and control-plane provenance responses. +- Local Alpha object IDs: canonical IDs for `AgentAccount`, `ModelPassport`, `WorkReceipt`, `ArtifactAvailabilityProof`, `VerifierModule`, `VerifierReport`, `MemoryCell`, `Challenge`, `FinalityReceipt`, `BridgeDeposit`, `BridgeCredit`, `BridgeWithdrawal`, local account balances, hardware signal envelopes, and control-plane provenance responses. - Local Alpha signature envelopes: local operator, agent, verifier, and hardware secp256k1 test signatures over typed object IDs. These are no-value local/test keys and are not wallet custody or production key-management claims. +- Local transaction envelopes: wallet-signed wrappers that bind domain separation, chain id, nonce, signer, payload hash, validity window, and signature while preserving `payload.tx` for devnet/control-plane consumers. +- Local wallet vault: AES-256-GCM encrypted local test-key storage with public metadata export. It is not a production wallet or custody system. ## Implemented Helpers -The package exports Keccak helpers, canonical JSON hashing, typed hash utilities, FlowPulse observation ids, cursor ids, report digests, receipt hashes, artifact/root commitments, work receipt ids, Local Alpha object ids, hardware signal envelope ids, Local Alpha signature envelope payloads, envelope validators, Merkle roots, worker/verifier signature payloads, verifier attestation envelope hashes, and local secp256k1 sign/verify helpers for tests. +The package exports Keccak helpers, canonical JSON hashing, typed hash utilities, FlowPulse observation ids, cursor ids, report digests, receipt hashes, artifact/root commitments, work receipt ids, Local Alpha object ids, bridge/account-balance ids, hardware signal envelope ids, Local Alpha signature envelope payloads, local transaction envelope payloads, wallet vault helpers, envelope validators, Merkle roots, worker/verifier signature payloads, verifier attestation envelope hashes, and local secp256k1 sign/verify helpers for tests. The implementation is ESM JavaScript with `src/index.d.ts` declarations for TypeScript consumers. @@ -99,6 +115,8 @@ Nearby Noesis/FlowChain RD crates under `E:\FlowMemory\github-research-sources\n ## Downstream Consumption - Chain/devnet agents should use the object ID helpers as transaction/object keys and reject zero roots, malformed IDs, wrong object types, replayed signer sequences, and bad parent/root relationships before state updates. +- Chain/devnet agents can consume wallet-signed transaction envelopes by validating the envelope and then reading the existing devnet transaction object at `payload.tx`. - Services and verifiers should use `validateLocalAlphaEnvelope` before accepting object documents from local transactions, API calls, hardware packets, or fixture imports. +- Control-plane agents should display only `flowchain.local_wallet_public_metadata.v0` account metadata and never vault ciphertext or private keys. - Dashboard/workbench agents should display IDs, domains, signer roles, status labels, and validation errors from these fixtures without implying production proof security. - Hardware agents should treat hardware signal envelopes as low-bandwidth authenticated control messages only; payloads remain off-chain and signal roots are commitments, not radio bandwidth or field-deployment claims. diff --git a/crypto/TEST_VECTORS.md b/crypto/TEST_VECTORS.md index 38907e32..520400a2 100644 --- a/crypto/TEST_VECTORS.md +++ b/crypto/TEST_VECTORS.md @@ -2,7 +2,9 @@ Status: draft v0. -The test vectors are synthetic and contain no production secrets or signatures. +The test vectors are synthetic and contain no production secrets. Signatures are +no-value local/test signatures with public keys only; private keys are not +committed in fixtures or public exports. ## Vector Files @@ -10,7 +12,8 @@ The test vectors are synthetic and contain no production secrets or signatures. - `fixtures/sample-observation.json`: observation metadata, artifact/storage inputs, and expected `observationId` / `receiptHash`. - `fixtures/sample-report.json`: verifier report, worker signature payload, verifier signature payload, and attestation envelope expectations. - `fixtures/local-alpha-objects.json`: positive and negative fixtures for FlowChain Local Alpha object identity, signed-envelope validation, and schema validation. -- `fixtures/vectors.json`: 33 package-level vectors for domains, canonical JSON, observation ids, receipts, artifacts, Merkle roots, reports, attestations, cursors, identities, root commitments, work receipts, devnet block hashes, Local Alpha object ids, hardware signal envelopes, and local signature envelopes. +- `fixtures/local-transaction-vectors.json`: wallet-signed local transaction envelopes, public wallet metadata, and negative vectors for chain id, domain, signer, nonce replay, malformed roots, malformed bridge deposits, and changed object type. +- `fixtures/vectors.json`: 41 package-level vectors for domains, canonical JSON, observation ids, receipts, artifacts, Merkle roots, reports, attestations, cursors, identities, root commitments, work receipts, devnet block hashes, Local Alpha object ids, bridge/account objects, signer IDs, local transaction payloads, hardware signal envelopes, and local signature envelopes. - `test-vectors/flowpulse-observation-v0.json`: FlowPulse-specific observation, receipt, artifact, report, worker signature digest, and verifier signature digest. ## FlowPulse Observation Vector Highlights @@ -50,8 +53,9 @@ An implementation should reproduce: - Merkle root and artifact root - deterministic verifier report id - EIP-712 signing digests without requiring test private keys -- Local Alpha object IDs for AgentAccount, ModelPassport, WorkReceipt, ArtifactAvailabilityProof, VerifierModule, VerifierReport, MemoryCell, Challenge, FinalityReceipt, hardware signal envelopes, and control-plane provenance responses +- Local Alpha object IDs for AgentAccount, ModelPassport, WorkReceipt, ArtifactAvailabilityProof, VerifierModule, VerifierReport, MemoryCell, Challenge, FinalityReceipt, BridgeDeposit, BridgeCredit, BridgeWithdrawal, local account balance, hardware signal envelopes, and control-plane provenance responses - Local Alpha signature envelope IDs and signing digests for local operator, agent, verifier, and hardware no-value test keys +- Local transaction envelope IDs, payload hashes, signing digests, and public signer metadata Run the package test suite: @@ -69,7 +73,7 @@ npm run validate:vectors Expected output: ```text -FLOWMEMORY_CRYPTO_VECTORS_OK 33 +FLOWMEMORY_CRYPTO_VECTORS_OK vectors=41 localTransactionPositive=2 localTransactionNegative=7 ``` Validate the Local Alpha object documents and signature envelopes against the @@ -82,7 +86,7 @@ npm run validate:local-alpha Expected output: ```text -FLOWCHAIN_LOCAL_ALPHA_FIXTURES_OK documents=11 envelopes=11 schemas=12 +FLOWCHAIN_LOCAL_ALPHA_FIXTURES_OK documents=15 envelopes=11 schemas=16 ``` Print the sample vector summary: @@ -116,6 +120,9 @@ FLOWPULSE_VECTOR_RECOMPUTE_OK - duplicate Local Alpha object IDs should be rejected by fixture validation - canonical JSON key order should not change the control-plane provenance response body hash - replayed Local Alpha signer/domain/sequence tuples should be rejected +- replayed local transaction signer/domain/nonce tuples should be rejected +- wrong local transaction chain id, domain, and signer should be rejected +- malformed bridge deposits inside local transaction payloads should be rejected - wrong signature domains should be rejected - missing local operator/agent/verifier/hardware signer fields should be rejected - each Local Alpha object envelope has a bad-signature invalid vector @@ -123,4 +130,4 @@ FLOWPULSE_VECTOR_RECOMPUTE_OK - expired worker signature should be rejected by verifier policy - reorged observation should not mutate into a verified report -The package tests cover the hash, schema, malformed hex, duplicate, type-string, canonical JSON, signed-envelope, replay, wrong-domain, missing-signer, bad-signature, zero-hash, malformed-dependency, bad-parent/root, and wrong-object-type checks. Expiry and reorg-to-report policy are verifier-service responsibilities because they require policy context, not just hash recomputation. +The package tests cover the hash, schema, malformed hex, duplicate, type-string, canonical JSON, signed-envelope, transaction-envelope, wallet public-metadata, replay, wrong-chain-id, wrong-domain, wrong-signer, missing-signer, bad-signature, zero-hash, malformed-dependency, malformed-root, malformed bridge-deposit, bad-parent/root, and wrong-object-type checks. Expiry and reorg-to-report policy are verifier-service responsibilities because they require policy context, not just hash recomputation. diff --git a/crypto/fixtures/local-alpha-objects.json b/crypto/fixtures/local-alpha-objects.json index 77735f8e..ea96f73d 100644 --- a/crypto/fixtures/local-alpha-objects.json +++ b/crypto/fixtures/local-alpha-objects.json @@ -376,6 +376,126 @@ "issuedAtUnixMs": "1778702400000", "responseVersion": 0 } + }, + { + "name": "bridge-deposit.demo", + "schemaPath": "../../schemas/flowmemory/bridge-deposit.schema.json", + "function": "bridgeDepositId", + "idField": "depositId", + "input": { + "sourceChainId": 84532, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666" + }, + "expected": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "document": { + "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" + } + }, + { + "name": "bridge-credit.demo", + "schemaPath": "../../schemas/flowmemory/bridge-credit.schema.json", + "function": "bridgeCreditId", + "idField": "creditId", + "input": { + "depositId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "assetId": "0x9944b8d9cc8dbe786e454a56b94942547de59922ed90e4215bcd9bd28f4fb380", + "amount": "20000000", + "creditedAtBlock": "12", + "creditNonce": "0x7174065548573c03817f74d76ba74d9d941e7443ee0181d91c07eb1918b0ae2a", + "status": 2 + }, + "expected": "0x1a53633e8374a6ffff7345d6bccd6f2d0a4a88fb934c314fe5e52658f14ff44b", + "document": { + "schema": "flowchain.bridge_credit.v0", + "creditId": "0x1a53633e8374a6ffff7345d6bccd6f2d0a4a88fb934c314fe5e52658f14ff44b", + "depositId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "assetId": "0x9944b8d9cc8dbe786e454a56b94942547de59922ed90e4215bcd9bd28f4fb380", + "amount": "20000000", + "creditedAtBlock": "12", + "creditNonce": "0x7174065548573c03817f74d76ba74d9d941e7443ee0181d91c07eb1918b0ae2a", + "status": "credited", + "statusCode": 2 + } + }, + { + "name": "bridge-withdrawal.demo", + "schemaPath": "../../schemas/flowmemory/bridge-withdrawal.schema.json", + "function": "bridgeWithdrawalId", + "idField": "withdrawalId", + "input": { + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "destinationChainId": 84532, + "destinationAddress": "0x7777777777777777777777777777777777777777", + "token": "0x3333333333333333333333333333333333333333", + "amount": "5000000", + "requestedNonce": "0x984406f4b1efbab3c153b2bef95ef4802cf21b5241ca6749bc1e8b1e6f514995", + "feeCommitment": "0xcea2019a6c82969409853d0833763a9f8bb1a511b9aeffb921de63b3fde00fbd", + "status": 1 + }, + "expected": "0xcf85bb6ebdfd6a1d4bfa473756c31517337c6d0192422b5c19d3c6dc6d6b71f8", + "document": { + "schema": "flowchain.bridge_withdrawal.v0", + "withdrawalId": "0xcf85bb6ebdfd6a1d4bfa473756c31517337c6d0192422b5c19d3c6dc6d6b71f8", + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "destinationChainId": 84532, + "destinationAddress": "0x7777777777777777777777777777777777777777", + "token": "0x3333333333333333333333333333333333333333", + "amount": "5000000", + "requestedNonce": "0x984406f4b1efbab3c153b2bef95ef4802cf21b5241ca6749bc1e8b1e6f514995", + "feeCommitment": "0xcea2019a6c82969409853d0833763a9f8bb1a511b9aeffb921de63b3fde00fbd", + "status": "requested", + "statusCode": 1 + } + }, + { + "name": "local-account-balance.demo", + "schemaPath": "../../schemas/flowmemory/local-account-balance.schema.json", + "function": "localAccountBalanceId", + "idField": "balanceId", + "input": { + "chainId": "31337", + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "assetId": "0x9944b8d9cc8dbe786e454a56b94942547de59922ed90e4215bcd9bd28f4fb380", + "available": "15000000", + "locked": "5000000", + "stateNonce": "3", + "balanceRoot": "0xa37f075197cb91b751eb04a107a5d0648019384c5be4d00d1a2f1adefb6e060e" + }, + "expected": "0xbd81721c37e43aababd0c181897a6d036ab435d3f0f5dafcd9d68be494fe81f5", + "document": { + "schema": "flowchain.local_account_balance.v0", + "balanceId": "0xbd81721c37e43aababd0c181897a6d036ab435d3f0f5dafcd9d68be494fe81f5", + "chainId": "31337", + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "assetId": "0x9944b8d9cc8dbe786e454a56b94942547de59922ed90e4215bcd9bd28f4fb380", + "available": "15000000", + "locked": "5000000", + "stateNonce": "3", + "balanceRoot": "0xa37f075197cb91b751eb04a107a5d0648019384c5be4d00d1a2f1adefb6e060e", + "status": "active" + } } ], "negative": [ 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/fixtures/vectors.json b/crypto/fixtures/vectors.json index 5283476c..ed52a790 100644 --- a/crypto/fixtures/vectors.json +++ b/crypto/fixtures/vectors.json @@ -1,6 +1,6 @@ { "schema": "flowmemory.crypto.test-vectors.v0", - "vectorCount": 33, + "vectorCount": 41, "vectors": [ { "name": "domain.flowPulseObservationId", @@ -455,6 +455,129 @@ "nonce": "0xe9b99686c583dbed5b3bf743c2a3db8a5da8c8ceea4398eb45d57c518c555034" }, "expected": "0x55656083efaf8a511fbae76b2a1bb740b08c92959e506a14f489f0fedcef3279" + }, + { + "name": "local-alpha.bridgeDepositId", + "function": "bridgeDepositId", + "input": { + "sourceChainId": 84532, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666" + }, + "expected": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe" + }, + { + "name": "local-alpha.bridgeCreditId", + "function": "bridgeCreditId", + "input": { + "depositId": "0x11d4fc9a23a8181abe8435ec29697a97daf7644b686f4ca6267f6dcfd8ff5ffe", + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "assetId": "0x9944b8d9cc8dbe786e454a56b94942547de59922ed90e4215bcd9bd28f4fb380", + "amount": "20000000", + "creditedAtBlock": "12", + "creditNonce": "0x7174065548573c03817f74d76ba74d9d941e7443ee0181d91c07eb1918b0ae2a", + "status": 2 + }, + "expected": "0x1a53633e8374a6ffff7345d6bccd6f2d0a4a88fb934c314fe5e52658f14ff44b" + }, + { + "name": "local-alpha.bridgeWithdrawalId", + "function": "bridgeWithdrawalId", + "input": { + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "destinationChainId": 84532, + "destinationAddress": "0x7777777777777777777777777777777777777777", + "token": "0x3333333333333333333333333333333333333333", + "amount": "5000000", + "requestedNonce": "0x984406f4b1efbab3c153b2bef95ef4802cf21b5241ca6749bc1e8b1e6f514995", + "feeCommitment": "0xcea2019a6c82969409853d0833763a9f8bb1a511b9aeffb921de63b3fde00fbd", + "status": 1 + }, + "expected": "0xcf85bb6ebdfd6a1d4bfa473756c31517337c6d0192422b5c19d3c6dc6d6b71f8" + }, + { + "name": "local-alpha.localAccountBalanceId", + "function": "localAccountBalanceId", + "input": { + "chainId": "31337", + "accountId": "0x3a0c10e2146f38d2aaa70d6f82dda6c0113a511bd1c59041ff37db9a6b974e26", + "assetId": "0x9944b8d9cc8dbe786e454a56b94942547de59922ed90e4215bcd9bd28f4fb380", + "available": "15000000", + "locked": "5000000", + "stateNonce": "3", + "balanceRoot": "0xa37f075197cb91b751eb04a107a5d0648019384c5be4d00d1a2f1adefb6e060e" + }, + "expected": "0xbd81721c37e43aababd0c181897a6d036ab435d3f0f5dafcd9d68be494fe81f5" + }, + { + "name": "local.signerId", + "function": "localSignerId", + "input": { + "publicKey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + "expected": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1" + }, + { + "name": "local.signerKeyId", + "function": "localSignerKeyId", + "input": { + "publicKey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "signerRole": 1, + "keyScopeHash": "0x12aff8820bbfec739f09eeac1e8a497ef87a9930ef07b5a09ff8e3a67a2b5677" + }, + "expected": "0x011c875680a5f90e1513ec2c05e22c9dab1d83e22d554cb2c61e14199da746a1" + }, + { + "name": "local.transactionPayloadHash.register-agent", + "function": "localTransactionPayloadHash", + "input": { + "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" + } + }, + "expected": "0x3659032f06fcb8573d47c109237dbba39fce5809c041123327767d3af17bb2b2" + }, + { + "name": "local.transactionEnvelopeHash.register-agent", + "function": "localTransactionEnvelopeHash", + "input": { + "chainId": "31337", + "nonce": "1", + "signerId": "0xcefa89096f0b2208885ab27cc7479d0615e344356c8c78b56e793b9041bbfeb1", + "signerKeyId": "0x011c875680a5f90e1513ec2c05e22c9dab1d83e22d554cb2c61e14199da746a1", + "signerRole": 1, + "payloadHash": "0x3659032f06fcb8573d47c109237dbba39fce5809c041123327767d3af17bb2b2", + "issuedAtUnixMs": "1778702400000", + "expiresAtUnixMs": "1810238400000", + "domainSeparator": "0x12aff8820bbfec739f09eeac1e8a497ef87a9930ef07b5a09ff8e3a67a2b5677" + }, + "expected": "0xfba94617ac6fbae608393c67570280d7123b27dabb0c1f31427808ad955a7c46" } ] } diff --git a/crypto/package.json b/crypto/package.json index 088ec3f9..a5963fde 100644 --- a/crypto/package.json +++ b/crypto/package.json @@ -16,7 +16,16 @@ "test": "node --test", "vectors": "node src/cli.js", "validate:vectors": "node src/validate-vectors.js", - "validate:local-alpha": "node src/validate-local-alpha-fixtures.js" + "validate:local-alpha": "node src/validate-local-alpha-fixtures.js", + "wallet:create": "node src/wallet-cli.js create", + "wallet:unlock": "node src/wallet-cli.js unlock", + "wallet:list": "node src/wallet-cli.js list", + "wallet:rotate": "node src/wallet-cli.js rotate", + "wallet:sign": "node src/wallet-cli.js sign", + "wallet:verify": "node src/wallet-cli.js verify", + "wallet:export-public": "node src/wallet-cli.js export-public", + "wallet:import-public": "node src/wallet-cli.js import-public", + "flowchain:full-smoke": "npm test && npm run validate:vectors" }, "dependencies": { "@noble/hashes": "2.2.0", diff --git a/crypto/src/constants.js b/crypto/src/constants.js index d5231e30..30d8e308 100644 --- a/crypto/src/constants.js +++ b/crypto/src/constants.js @@ -59,8 +59,22 @@ export const TYPE_STRINGS = Object.freeze({ "FlowChainHardwareSignalEnvelopeV0(bytes32 deviceId,bytes32 signalRoot,bytes32 previousSignalEnvelopeId,bytes32 channelRoot,uint64 sequence,uint64 observedAtUnixMs,uint8 transport,bytes32 nonce)", controlPlaneProvenanceResponseV0: "FlowChainControlPlaneProvenanceResponseV0(bytes32 requestId,bytes32 subjectId,bytes32 agentId,bytes32 receiptId,bytes32 reportId,bytes32 memoryCellId,bytes32 dependencyRoot,bytes32 responseBodyHash,uint64 issuedAtUnixMs,uint16 responseVersion)", + bridgeDepositV0: + "FlowChainBridgeDepositV0(uint256 sourceChainId,address sourceContract,bytes32 txHash,uint32 logIndex,address token,uint256 amount,address sender,bytes32 flowchainRecipient,uint256 nonce,bytes32 metadataHash)", + bridgeCreditV0: + "FlowChainBridgeCreditV0(bytes32 depositId,bytes32 accountId,bytes32 assetId,uint256 amount,uint64 creditedAtBlock,bytes32 creditNonce,uint8 status)", + bridgeWithdrawalV0: + "FlowChainBridgeWithdrawalV0(bytes32 accountId,uint256 destinationChainId,address destinationAddress,address token,uint256 amount,bytes32 requestedNonce,bytes32 feeCommitment,uint8 status)", + localAccountBalanceV0: + "FlowChainLocalAccountBalanceV0(uint256 chainId,bytes32 accountId,bytes32 assetId,uint256 available,uint256 locked,uint64 stateNonce,bytes32 balanceRoot)", + localSignerV0: + "FlowChainLocalSignerV0(bytes32 publicKeyHash)", + localSignerKeyV0: + "FlowChainLocalSignerKeyV0(bytes32 publicKeyHash,uint8 signerRole,bytes32 keyScopeHash)", localSignatureEnvelopeV0: "FlowChainLocalSignatureEnvelopeV0(bytes32 objectId,bytes32 objectTypeHash,bytes32 domainSeparator,bytes32 signerId,bytes32 signerKeyId,uint8 signerRole,uint64 sequence,uint64 issuedAtUnixMs,uint64 expiresAtUnixMs,bytes32 nonce)", + localTransactionEnvelopeV0: + "FlowChainLocalTransactionEnvelopeV0(uint256 chainId,uint64 nonce,bytes32 signerId,bytes32 signerKeyId,uint8 signerRole,bytes32 payloadHash,uint64 issuedAtUnixMs,uint64 expiresAtUnixMs,bytes32 domainSeparator)", eip712Domain: "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)" }); @@ -86,9 +100,16 @@ export const DOMAIN_STRINGS = Object.freeze({ verifierModuleId: "flowchain.local-alpha.v0.verifier-module.id", challengeId: "flowchain.local-alpha.v0.challenge.id", finalityReceiptId: "flowchain.local-alpha.v0.finality-receipt.id", + bridgeDepositId: "flowchain.local-alpha.v0.bridge-deposit.id", + bridgeCreditId: "flowchain.local-alpha.v0.bridge-credit.id", + bridgeWithdrawalId: "flowchain.local-alpha.v0.bridge-withdrawal.id", + localAccountBalanceId: "flowchain.local-alpha.v0.local-account-balance.id", + localSignerId: "flowchain.local.v0.signer.id", + localSignerKeyId: "flowchain.local.v0.signer-key.id", hardwareSignalEnvelopeId: "flowchain.local-alpha.v0.hardware-signal-envelope.id", controlPlaneProvenanceResponseId: "flowchain.local-alpha.v0.control-plane-provenance-response.id", - localSignatureEnvelope: "flowchain.local-alpha.v0.local-signature-envelope" + localSignatureEnvelope: "flowchain.local-alpha.v0.local-signature-envelope", + localTransactionEnvelope: "flowchain.local.v0.transaction-envelope" }); export const MERKLE_SCHEME_V0 = "FM-MERKLE-KECCAK256-BINARY-V0"; diff --git a/crypto/src/index.d.ts b/crypto/src/index.d.ts index 81b0058d..a0ee7ac1 100644 --- a/crypto/src/index.d.ts +++ b/crypto/src/index.d.ts @@ -276,6 +276,50 @@ export interface ControlPlaneProvenanceResponseInput { responseVersion: number | bigint | string; } +export interface BridgeDepositInput { + sourceChainId: number | bigint | string; + sourceContract: Address; + txHash: Bytes32; + logIndex: number | bigint | string; + token: Address; + amount: number | bigint | string; + sender: Address; + flowchainRecipient: Bytes32; + nonce: number | bigint | string; + metadataHash?: Bytes32; +} + +export interface BridgeCreditInput { + depositId: Bytes32; + accountId: Bytes32; + assetId: Bytes32; + amount: number | bigint | string; + creditedAtBlock: number | bigint | string; + creditNonce: Bytes32; + status: number | bigint | string; +} + +export interface BridgeWithdrawalInput { + accountId: Bytes32; + destinationChainId: number | bigint | string; + destinationAddress: Address; + token: Address; + amount: number | bigint | string; + requestedNonce: Bytes32; + feeCommitment: Bytes32; + status: number | bigint | string; +} + +export interface LocalAccountBalanceInput { + chainId: number | bigint | string; + accountId: Bytes32; + assetId: Bytes32; + available: number | bigint | string; + locked: number | bigint | string; + stateNonce: number | bigint | string; + balanceRoot: Bytes32; +} + export interface HardwareSignalEnvelopeInput { deviceId: Bytes32; signalRoot: Bytes32; @@ -305,6 +349,46 @@ export interface LocalSignatureEnvelopePayload { signingDigest: Bytes32; } +export interface LocalTransactionEnvelopeInput { + chainId: number | bigint | string; + nonce: number | bigint | string; + signerId: Bytes32; + signerKeyId: Bytes32; + signerRole: number | bigint | string; + payloadHash: Bytes32; + issuedAtUnixMs: number | bigint | string; + expiresAtUnixMs: number | bigint | string; + domainSeparator: Bytes32; +} + +export interface LocalTransactionEnvelopePayload { + structHash: Bytes32; + signingDigest: Bytes32; +} + +export interface LocalSignerPublicMetadata { + accountId: Bytes32; + signerId: Bytes32; + signerKeyId: Bytes32; + signerRole: string; + signerRoleCode: number; + publicKey: Hex; +} + +export interface LocalTransactionEnvelopeValidationInput { + envelope: Record; + context?: { + expectedChainId?: number | bigint | string; + expectedSignerId?: Bytes32; + seenNonces?: Set; + }; +} + +export interface LocalTransactionEnvelopeValidationResult { + valid: boolean; + errors: string[]; +} + export interface LocalAlphaEnvelopeValidationInput { document: Record; envelope: Record; @@ -318,6 +402,22 @@ export interface LocalAlphaEnvelopeValidationResult { errors: string[]; } +export interface WalletPublicAccount extends LocalSignerPublicMetadata { + label: string; + status: "active" | "rotated" | "revoked"; + createdAtUnixMs: string; + nextNonce: string; +} + +export interface WalletPublicMetadata { + schema: "flowchain.local_wallet_public_metadata.v0"; + vaultId: Bytes32; + createdAtUnixMs: string; + updatedAtUnixMs: string; + accounts: WalletPublicAccount[]; + importedAccounts?: Array; +} + export const ZERO_BYTES32: Bytes32; export const FLOWPULSE_SCHEMA_ID_PREIMAGE: string; export const FLOWPULSE_EVENT_SIGNATURE: string; @@ -331,6 +431,9 @@ export const LOCAL_ALPHA_CHALLENGE_STATUSES: Readonly>; export const LOCAL_ALPHA_FINALITY_STATES: Readonly>; export const LOCAL_ALPHA_HARDWARE_TRANSPORTS: Readonly>; export const LOCAL_ALPHA_SIGNER_ROLES: Readonly>; +export const DEFAULT_WALLET_PATH: string; +export const WALLET_SCHEMA: string; +export const WALLET_PUBLIC_METADATA_SCHEMA: string; export function strip0x(value: string): string; export function bytesToHex(bytes: Uint8Array): Hex; @@ -415,6 +518,17 @@ export function artifactAvailabilityProofId(input: ArtifactAvailabilityProofInpu export function verifierModuleId(input: VerifierModuleInput): Bytes32; export function challengeId(input: ChallengeInput): Bytes32; export function finalityReceiptId(input: FinalityReceiptInput): Bytes32; +export function bridgeDepositId(input: BridgeDepositInput): Bytes32; +export function bridgeCreditId(input: BridgeCreditInput): Bytes32; +export function bridgeWithdrawalId(input: BridgeWithdrawalInput): Bytes32; +export function localAccountBalanceId(input: LocalAccountBalanceInput): Bytes32; +export function localPublicKeyHash(publicKey: Hex): Bytes32; +export function localSignerId(input: { publicKey: Hex }): Bytes32; +export function localSignerKeyId(input: { + publicKey: Hex; + signerRole: number | bigint | string; + keyScopeHash?: Bytes32; +}): Bytes32; export function hardwareSignalEnvelopeId(input: HardwareSignalEnvelopeInput): Bytes32; export function controlPlaneProvenanceResponseId(input: ControlPlaneProvenanceResponseInput): Bytes32; export function localSignatureEnvelopeHash(input: LocalSignatureEnvelopeInput): Bytes32; @@ -425,8 +539,92 @@ export const LOCAL_ALPHA_OBJECT_DESCRIPTORS: Readonly>; export function localAlphaObjectDescriptor(objectSchema: string): unknown; export function localAlphaObjectInput(document: Record): unknown; export function localAlphaObjectId(document: Record): Bytes32; +export function validateLocalAlphaObjectDocument( + document: Record, + context?: { expectedObjectType?: string } +): LocalAlphaEnvelopeValidationResult; export function localAlphaEnvelopeReplayKey(envelope: Record): string; export function localSignatureEnvelopeInput(envelope: Record): LocalSignatureEnvelopeInput; export function validateLocalAlphaEnvelope( input: LocalAlphaEnvelopeValidationInput ): LocalAlphaEnvelopeValidationResult; + +export function localTransactionPayloadHash(payload: unknown): Bytes32; +export function localTransactionEnvelopeHash(input: LocalTransactionEnvelopeInput): Bytes32; +export const localTransactionEnvelopeId: typeof localTransactionEnvelopeHash; +export function localTransactionEnvelopePayload( + input: LocalTransactionEnvelopeInput +): LocalTransactionEnvelopePayload; +export function localTransactionEnvelopeInput(envelope: Record): LocalTransactionEnvelopeInput; +export function localTransactionReplayKey(envelope: Record): string; +export function localSignerRoleCode(role: number | bigint | string): number; +export function localSignerPublicMetadata(input: { + publicKey: Hex; + signerRole?: number | bigint | string; + keyScopeHash?: Bytes32; +}): LocalSignerPublicMetadata; +export function createLocalTransactionEnvelope(input: { + chainId: number | bigint | string; + nonce: number | bigint | string; + payload: unknown; + signer: LocalSignerPublicMetadata; + issuedAtUnixMs: number | bigint | string; + expiresAtUnixMs: number | bigint | string; + signature?: Hex | null; +}): Record; +export function validateLocalTransactionEnvelope( + input: LocalTransactionEnvelopeValidationInput +): LocalTransactionEnvelopeValidationResult; + +export function createWalletVault(input: { + password: string; + vaultPath?: string; + label?: string; + signerRole?: string; + force?: boolean; + now?: number; +}): WalletPublicMetadata; +export function unlockWalletVault(input: { + password: string; + vaultPath?: string; +}): { vault: Record; publicMetadata: WalletPublicMetadata; secret: Record }; +export function listWalletPublicAccounts(input?: { vaultPath?: string }): WalletPublicMetadata; +export function rotateWalletAccount(input: { + password: string; + vaultPath?: string; + label?: string; + signerRole?: string; + now?: number; +}): WalletPublicMetadata; +export function signWalletTransaction(input: { + password: string; + payload: unknown; + vaultPath?: string; + accountId?: Bytes32; + chainId?: number | bigint | string; + nonce?: number | bigint | string; + issuedAtUnixMs?: number | bigint | string; + expiresAtUnixMs?: number | bigint | string; +}): Promise>; +export function verifyWalletTransaction(input?: { + envelope: Record; + expectedChainId?: number | bigint | string; + seenNonces?: Set; + expectedSignerId?: Bytes32; +}): LocalTransactionEnvelopeValidationResult; +export function exportWalletPublicMetadata(input?: { + vaultPath?: string; + outPath?: string; +}): WalletPublicMetadata; +export function importWalletPublicMetadata(input: { + vaultPath?: string; + metadata?: WalletPublicMetadata; + inPath?: string; + now?: number; +}): WalletPublicMetadata; +export function createWalletAccount(input: { + label: string; + signerRole?: string; + now?: number; +}): WalletPublicAccount & { privateKey: Hex }; +export function assertPublicMetadataHasNoSecrets(metadata: unknown): void; diff --git a/crypto/src/index.js b/crypto/src/index.js index dd165ee2..24b0b9ad 100644 --- a/crypto/src/index.js +++ b/crypto/src/index.js @@ -6,3 +6,5 @@ export * from "./flowpulse.js"; export * from "./hashes.js"; export * from "./merkle.js"; export * from "./objects.js"; +export * from "./transactions.js"; +export * from "./wallet.js"; diff --git a/crypto/src/objects.js b/crypto/src/objects.js index 5a466c10..22e857f7 100644 --- a/crypto/src/objects.js +++ b/crypto/src/objects.js @@ -215,6 +215,102 @@ export function controlPlaneProvenanceResponseId({ ]); } +export function bridgeDepositId({ + sourceChainId, + sourceContract, + txHash, + logIndex, + token, + amount, + sender, + flowchainRecipient, + nonce, + metadataHash = ZERO_BYTES32 +}) { + return typedHash(TYPE_STRINGS.bridgeDepositV0, [ + ["uint256", sourceChainId], + ["address", sourceContract], + ["bytes32", txHash], + ["uint32", logIndex], + ["address", token], + ["uint256", amount], + ["address", sender], + ["bytes32", flowchainRecipient], + ["uint256", nonce], + ["bytes32", metadataHash] + ]); +} + +export function bridgeCreditId({ + depositId, + accountId, + assetId, + amount, + creditedAtBlock, + creditNonce, + status +}) { + return typedHash(TYPE_STRINGS.bridgeCreditV0, [ + ["bytes32", depositId], + ["bytes32", accountId], + ["bytes32", assetId], + ["uint256", amount], + ["uint64", creditedAtBlock], + ["bytes32", creditNonce], + ["uint8", status] + ]); +} + +export function bridgeWithdrawalId({ + accountId, + destinationChainId, + destinationAddress, + token, + amount, + requestedNonce, + feeCommitment, + status +}) { + return typedHash(TYPE_STRINGS.bridgeWithdrawalV0, [ + ["bytes32", accountId], + ["uint256", destinationChainId], + ["address", destinationAddress], + ["address", token], + ["uint256", amount], + ["bytes32", requestedNonce], + ["bytes32", feeCommitment], + ["uint8", status] + ]); +} + +export function localAccountBalanceId({ chainId, accountId, assetId, available, locked, stateNonce, balanceRoot }) { + return typedHash(TYPE_STRINGS.localAccountBalanceV0, [ + ["uint256", chainId], + ["bytes32", accountId], + ["bytes32", assetId], + ["uint256", available], + ["uint256", locked], + ["uint64", stateNonce], + ["bytes32", balanceRoot] + ]); +} + +export function localPublicKeyHash(publicKey) { + return keccakUtf8(String(publicKey).toLowerCase()); +} + +export function localSignerId({ publicKey }) { + return typedHash(TYPE_STRINGS.localSignerV0, [["bytes32", localPublicKeyHash(publicKey)]]); +} + +export function localSignerKeyId({ publicKey, signerRole, keyScopeHash = domainSeparator("localTransactionEnvelope") }) { + return typedHash(TYPE_STRINGS.localSignerKeyV0, [ + ["bytes32", localPublicKeyHash(publicKey)], + ["uint8", signerRole], + ["bytes32", keyScopeHash] + ]); +} + export function localSignatureEnvelopeHash({ objectId, objectTypeHash, @@ -270,6 +366,15 @@ export const LOCAL_ALPHA_OBJECT_DESCRIPTORS = Object.freeze({ "memoryNamespaceRoot", "nonce" ], + hex32Fields: [ + "agentId", + "namespaceId", + "policyRoot", + "toolPermissionsRoot", + "modelAllowlistRoot", + "memoryNamespaceRoot", + "nonce" + ], input: (document) => ({ namespaceId: document.namespaceId, owner: document.owner, @@ -482,6 +587,87 @@ export const LOCAL_ALPHA_OBJECT_DESCRIPTORS = Object.freeze({ ); } }, + "flowmemory.bridge_deposit.v0": { + objectType: "bridge_deposit", + idField: "depositId", + domainName: "bridgeDepositId", + signerRoles: ["operator"], + nonzeroFields: ["depositId", "txHash", "flowchainRecipient"], + hex32Fields: ["depositId", "txHash", "flowchainRecipient", "metadataHash"], + addressFields: ["sourceContract", "token", "sender"], + positiveUintFields: ["amount"], + input: (document) => ({ + sourceChainId: document.sourceChainId, + sourceContract: document.sourceContract, + txHash: document.txHash, + logIndex: document.logIndex, + token: document.token, + amount: document.amount, + sender: document.sender, + flowchainRecipient: document.flowchainRecipient, + nonce: document.nonce, + metadataHash: document.metadataHash ?? ZERO_BYTES32 + }), + id: bridgeDepositId + }, + "flowchain.bridge_credit.v0": { + objectType: "bridge_credit", + idField: "creditId", + domainName: "bridgeCreditId", + signerRoles: ["operator"], + nonzeroFields: ["creditId", "depositId", "accountId", "assetId", "creditNonce"], + hex32Fields: ["creditId", "depositId", "accountId", "assetId", "creditNonce"], + positiveUintFields: ["amount"], + input: (document) => ({ + depositId: document.depositId, + accountId: document.accountId, + assetId: document.assetId, + amount: document.amount, + creditedAtBlock: document.creditedAtBlock, + creditNonce: document.creditNonce, + status: document.statusCode + }), + id: bridgeCreditId + }, + "flowchain.bridge_withdrawal.v0": { + objectType: "bridge_withdrawal", + idField: "withdrawalId", + domainName: "bridgeWithdrawalId", + signerRoles: ["operator"], + nonzeroFields: ["withdrawalId", "accountId", "requestedNonce", "feeCommitment"], + hex32Fields: ["withdrawalId", "accountId", "requestedNonce", "feeCommitment"], + addressFields: ["destinationAddress", "token"], + positiveUintFields: ["amount"], + input: (document) => ({ + accountId: document.accountId, + destinationChainId: document.destinationChainId, + destinationAddress: document.destinationAddress, + token: document.token, + amount: document.amount, + requestedNonce: document.requestedNonce, + feeCommitment: document.feeCommitment, + status: document.statusCode + }), + id: bridgeWithdrawalId + }, + "flowchain.local_account_balance.v0": { + objectType: "local_account_balance", + idField: "balanceId", + domainName: "localAccountBalanceId", + signerRoles: ["operator"], + nonzeroFields: ["balanceId", "accountId", "assetId", "balanceRoot"], + hex32Fields: ["balanceId", "accountId", "assetId", "balanceRoot"], + input: (document) => ({ + chainId: document.chainId, + accountId: document.accountId, + assetId: document.assetId, + available: document.available, + locked: document.locked, + stateNonce: document.stateNonce, + balanceRoot: document.balanceRoot + }), + id: localAccountBalanceId + }, "flowchain.hardware_signal_envelope.v0": { objectType: "hardware_signal_envelope", idField: "hardwareSignalEnvelopeId", @@ -556,6 +742,77 @@ export function localAlphaObjectId(document) { return descriptor.id(descriptor.input(document)); } +export function validateLocalAlphaObjectDocument(document, context = {}) { + const errors = []; + const descriptor = localAlphaObjectDescriptor(document?.schema); + + if (!descriptor) { + return { valid: false, errors: ["wrong-object-type"] }; + } + + if (context.expectedObjectType && descriptor.objectType !== context.expectedObjectType) { + errors.push("changed-object-type"); + } + + const idField = descriptor.idField; + if (!isHex32(document[idField])) { + errors.push("malformed-id"); + } + + for (const field of descriptor.hex32Fields ?? []) { + if (document[field] !== undefined && !isHex32(document[field])) { + errors.push(field.toLowerCase().includes("root") ? "malformed-root" : "malformed-id"); + break; + } + } + + for (const field of descriptor.addressFields ?? []) { + if (!isAddress(document[field])) { + errors.push("malformed-address"); + break; + } + } + + for (const field of descriptor.positiveUintFields ?? []) { + if (!isPositiveUint(document[field])) { + errors.push(descriptor.objectType === "bridge_deposit" ? "malformed-bridge-deposit" : "malformed-uint"); + break; + } + } + + for (const field of descriptor.nonzeroFields ?? []) { + if (document[field] === ZERO_BYTES32) { + errors.push(field.toLowerCase().includes("root") ? "malformed-root" : "zero-hash"); + break; + } + } + + if (descriptor.dependencyField) { + const dependency = document[descriptor.dependencyField]; + if (!isHex32(dependency) || dependency === ZERO_BYTES32) { + errors.push("malformed-dependency"); + } + } + + if (descriptor.parentRootCheck && !descriptor.parentRootCheck(document)) { + errors.push("bad-parent-root"); + } + + try { + const expectedObjectId = localAlphaObjectId(document); + if (document[idField] !== expectedObjectId) { + errors.push("bad-object-id"); + } + } catch (error) { + errors.push(classifyObjectError(error)); + } + + return { + valid: errors.length === 0, + errors: [...new Set(errors)] + }; +} + export function localAlphaEnvelopeReplayKey(envelope) { return `${envelope.signerId}:${envelope.domain}:${envelope.sequence}`; } @@ -584,6 +841,8 @@ export function validateLocalAlphaEnvelope({ document, envelope, context = {} }) return { valid: false, errors }; } + errors.push(...validateLocalAlphaObjectDocument(document).errors); + if (!envelope || typeof envelope !== "object") { errors.push("missing-signer"); return { valid: false, errors }; @@ -699,6 +958,32 @@ function isHex32(value) { } } +function isAddress(value) { + if (typeof value !== "string") { + return false; + } + try { + hexToBytes(value, 20); + return true; + } catch { + return false; + } +} + +function isPositiveUint(value) { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") { + return false; + } + if (typeof value === "string" && !/^[0-9]+$/.test(value)) { + return false; + } + try { + return BigInt(value) > 0n; + } catch { + return false; + } +} + function classifyObjectError(error) { if (/hex|bytes/i.test(String(error?.message))) { return "malformed-id"; diff --git a/crypto/src/transactions.js b/crypto/src/transactions.js new file mode 100644 index 00000000..7258597c --- /dev/null +++ b/crypto/src/transactions.js @@ -0,0 +1,251 @@ +import { DOMAIN_STRINGS, LOCAL_ALPHA_SIGNER_ROLES, TYPE_STRINGS, ZERO_BYTES32 } from "./constants.js"; +import { verifyDigest } from "./attestations.js"; +import { eip712Digest } from "./flowpulse.js"; +import { canonicalJsonHash, domainSeparator, typedHash } from "./hashes.js"; +import { + localSignerId, + localSignerKeyId, + validateLocalAlphaObjectDocument +} from "./objects.js"; + +export function localTransactionPayloadHash(payload) { + return canonicalJsonHash(payload); +} + +export function localTransactionEnvelopeHash({ + chainId, + nonce, + signerId, + signerKeyId, + signerRole, + payloadHash, + issuedAtUnixMs, + expiresAtUnixMs, + domainSeparator +}) { + return typedHash(TYPE_STRINGS.localTransactionEnvelopeV0, [ + ["uint256", chainId], + ["uint64", nonce], + ["bytes32", signerId], + ["bytes32", signerKeyId], + ["uint8", signerRole], + ["bytes32", payloadHash], + ["uint64", issuedAtUnixMs], + ["uint64", expiresAtUnixMs], + ["bytes32", domainSeparator] + ]); +} + +export const localTransactionEnvelopeId = localTransactionEnvelopeHash; + +export function localTransactionEnvelopePayload(input) { + const structHash = localTransactionEnvelopeHash(input); + return { + structHash, + signingDigest: eip712Digest(input.domainSeparator, structHash) + }; +} + +export function localTransactionEnvelopeInput(envelope) { + const signer = envelope?.signer ?? {}; + return { + chainId: envelope.chainId, + nonce: envelope.nonce, + signerId: signer.signerId, + signerKeyId: signer.signerKeyId, + signerRole: signer.signerRoleCode, + payloadHash: envelope.payloadHash, + issuedAtUnixMs: envelope.issuedAtUnixMs, + expiresAtUnixMs: envelope.expiresAtUnixMs, + domainSeparator: envelope.domainSeparator + }; +} + +export function localTransactionReplayKey(envelope) { + const signerId = envelope?.signer?.signerId ?? "missing-signer"; + return `${envelope?.chainId}:${envelope?.domain}:${signerId}:${envelope?.nonce}`; +} + +export function localSignerRoleCode(role) { + if (typeof role === "number" || typeof role === "bigint") { + return Number(role); + } + if (typeof role === "string" && /^[0-9]+$/.test(role)) { + return Number(role); + } + const code = LOCAL_ALPHA_SIGNER_ROLES[role]; + if (code === undefined) { + throw new Error(`unknown signer role: ${role}`); + } + return code; +} + +export function localSignerPublicMetadata({ publicKey, signerRole = "operator", keyScopeHash }) { + const signerRoleCode = localSignerRoleCode(signerRole); + const signerId = localSignerId({ publicKey }); + const signerKeyId = localSignerKeyId({ publicKey, signerRole: signerRoleCode, keyScopeHash }); + return { + accountId: signerId, + signerId, + signerKeyId, + signerRole: signerRoleName(signerRoleCode), + signerRoleCode, + publicKey + }; +} + +export function createLocalTransactionEnvelope({ + chainId, + nonce, + payload, + signer, + issuedAtUnixMs, + expiresAtUnixMs, + signature = null +}) { + const txDomain = DOMAIN_STRINGS.localTransactionEnvelope; + const txDomainSeparator = domainSeparator("localTransactionEnvelope"); + const payloadHash = localTransactionPayloadHash(payload); + const input = { + chainId, + nonce, + signerId: signer.signerId, + signerKeyId: signer.signerKeyId, + signerRole: signer.signerRoleCode, + payloadHash, + issuedAtUnixMs, + expiresAtUnixMs, + domainSeparator: txDomainSeparator + }; + const signing = localTransactionEnvelopePayload(input); + + return { + schema: "flowchain.local_transaction_envelope.v0", + envelopeId: signing.structHash, + domain: txDomain, + domainSeparator: txDomainSeparator, + chainId: String(chainId), + nonce: String(nonce), + payloadHash, + payload, + signer, + issuedAtUnixMs: String(issuedAtUnixMs), + expiresAtUnixMs: String(expiresAtUnixMs), + signingDigest: signing.signingDigest, + signature + }; +} + +export function validateLocalTransactionEnvelope({ envelope, context = {} }) { + const errors = []; + + if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) { + return { valid: false, errors: ["missing-envelope"] }; + } + + if (envelope.schema !== "flowchain.local_transaction_envelope.v0") { + errors.push("changed-object-type"); + } + + const expectedDomain = DOMAIN_STRINGS.localTransactionEnvelope; + const expectedDomainSeparator = domainSeparator("localTransactionEnvelope"); + if (envelope.domain !== expectedDomain || envelope.domainSeparator !== expectedDomainSeparator) { + errors.push("wrong-domain"); + } + + if (context.expectedChainId !== undefined && String(envelope.chainId) !== String(context.expectedChainId)) { + errors.push("wrong-chain-id"); + } + + if (!isUintString(envelope.chainId) || !isUintString(envelope.nonce)) { + errors.push("malformed-nonce"); + } + + if (envelope.payloadHash !== localTransactionPayloadHash(envelope.payload)) { + errors.push("bad-payload-hash"); + } + + const expectedObjectType = envelope.payload?.objectType; + if (envelope.payload?.object) { + const objectResult = validateLocalAlphaObjectDocument(envelope.payload.object, { expectedObjectType }); + errors.push(...objectResult.errors); + } + + const signer = envelope.signer; + if (!signer || typeof signer !== "object") { + errors.push("missing-signer"); + } else { + try { + const signerRoleCode = localSignerRoleCode(signer.signerRole); + if (signerRoleCode !== signer.signerRoleCode) { + errors.push("wrong-signer"); + } + const expectedSigner = localSignerPublicMetadata({ + publicKey: signer.publicKey, + signerRole: signerRoleCode + }); + if (signer.signerId !== expectedSigner.signerId || signer.signerKeyId !== expectedSigner.signerKeyId) { + errors.push("wrong-signer"); + } + if (context.expectedSignerId && signer.signerId !== context.expectedSignerId) { + errors.push("wrong-signer"); + } + if (signer.signerId === ZERO_BYTES32 || signer.signerKeyId === ZERO_BYTES32) { + errors.push("missing-signer"); + } + } catch { + errors.push("wrong-signer"); + } + } + + if (context.seenNonces?.has?.(localTransactionReplayKey(envelope))) { + errors.push("replay"); + } + + try { + const input = localTransactionEnvelopeInput(envelope); + const expectedEnvelopeId = localTransactionEnvelopeHash(input); + const expectedPayload = localTransactionEnvelopePayload(input); + if (envelope.envelopeId !== expectedEnvelopeId) { + errors.push("bad-envelope-id"); + } + if (envelope.signingDigest !== expectedPayload.signingDigest) { + errors.push("bad-envelope-digest"); + } + if ( + envelope.signature && + envelope.signer?.publicKey && + !verifyDigest({ + digest: envelope.signingDigest, + signature: envelope.signature, + publicKey: envelope.signer.publicKey + }) + ) { + errors.push("bad-signature"); + } + } catch { + errors.push("bad-envelope-id"); + } + + if (!envelope.signature) { + errors.push("missing-signature"); + } + + return { + valid: errors.length === 0, + errors: [...new Set(errors)] + }; +} + +function signerRoleName(code) { + for (const [name, value] of Object.entries(LOCAL_ALPHA_SIGNER_ROLES)) { + if (value === Number(code)) { + return name; + } + } + throw new Error(`unknown signer role code: ${code}`); +} + +function isUintString(value) { + return typeof value === "string" && /^[0-9]+$/.test(value); +} 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/src/validate-vectors.js b/crypto/src/validate-vectors.js index 9701e472..d3f89ecc 100644 --- a/crypto/src/validate-vectors.js +++ b/crypto/src/validate-vectors.js @@ -21,7 +21,15 @@ import { flowPulseSchemaId, hardwareSignalEnvelopeId, indexerCursorId, + bridgeCreditId, + bridgeDepositId, + bridgeWithdrawalId, + localAccountBalanceId, + localSignerId, + localSignerKeyId, localSignatureEnvelopeHash, + localTransactionEnvelopeHash, + localTransactionPayloadHash, memoryCellId, merkleLeafHash, merkleRoot, @@ -36,6 +44,7 @@ import { workReceiptId, workerIdentity } from "./index.js"; +import { validateLocalTransactionFixtures } from "./validate-local-transaction-fixtures.js"; const validators = Object.freeze({ artifactFromChunks, @@ -55,7 +64,15 @@ const validators = Object.freeze({ flowPulseSchemaId, hardwareSignalEnvelopeId, indexerCursorId, + bridgeCreditId, + bridgeDepositId, + bridgeWithdrawalId, + localAccountBalanceId, + localSignerId, + localSignerKeyId, localSignatureEnvelopeHash, + localTransactionEnvelopeHash, + localTransactionPayloadHash, memoryCellId, merkleLeafHash, merkleRoot: ({ leaves }) => merkleRoot(leaves), @@ -89,5 +106,8 @@ export function validateVectors(vectorPath = resolve(import.meta.dirname, "..", if (fileURLToPath(import.meta.url) === resolve(process.argv[1])) { const count = validateVectors(process.argv[2]); - console.log(`FLOWMEMORY_CRYPTO_VECTORS_OK ${count}`); + const transactions = validateLocalTransactionFixtures(); + console.log( + `FLOWMEMORY_CRYPTO_VECTORS_OK vectors=${count} localTransactionPositive=${transactions.positive} localTransactionNegative=${transactions.negative}` + ); } diff --git a/crypto/src/wallet-cli.js b/crypto/src/wallet-cli.js new file mode 100644 index 00000000..4fcaa355 --- /dev/null +++ b/crypto/src/wallet-cli.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; + +import { + DEFAULT_WALLET_PATH, + createWalletVault, + exportWalletPublicMetadata, + importWalletPublicMetadata, + listWalletPublicAccounts, + rotateWalletAccount, + signWalletTransaction, + unlockWalletVault, + verifyWalletTransaction +} from "./wallet.js"; +import { keccakUtf8 } from "./hashes.js"; + +const DEFAULT_ENVELOPE_PATH = resolve(DEFAULT_WALLET_PATH, "..", "last-signed-envelope.local.json"); +const DEFAULT_PUBLIC_METADATA_PATH = resolve(DEFAULT_WALLET_PATH, "..", "public-metadata.local.json"); + +const command = process.argv[2] ?? "help"; +const args = process.argv.slice(3); + +try { + if (command === "create") { + const password = await passwordFromEnvOrPrompt(); + print( + createWalletVault({ + password, + vaultPath: option("--vault", DEFAULT_WALLET_PATH), + label: option("--label", "flowchain-local-operator"), + signerRole: option("--role", "operator"), + force: hasFlag("--force") + }) + ); + } else if (command === "unlock") { + const password = await passwordFromEnvOrPrompt(); + const result = unlockWalletVault({ + password, + vaultPath: option("--vault", DEFAULT_WALLET_PATH) + }); + print({ + schema: "flowchain.local_wallet_unlock_result.v0", + unlocked: true, + public: result.publicMetadata + }); + } else if (command === "list") { + print(listWalletPublicAccounts({ vaultPath: option("--vault", DEFAULT_WALLET_PATH) })); + } else if (command === "rotate") { + const password = await passwordFromEnvOrPrompt(); + print( + rotateWalletAccount({ + password, + vaultPath: option("--vault", DEFAULT_WALLET_PATH), + label: option("--label", "flowchain-local-account"), + signerRole: option("--role", "operator") + }) + ); + } else if (command === "sign") { + const password = await passwordFromEnvOrPrompt(); + const outPath = option("--out", DEFAULT_ENVELOPE_PATH); + const envelope = await signWalletTransaction({ + password, + vaultPath: option("--vault", DEFAULT_WALLET_PATH), + accountId: option("--account", undefined), + chainId: option("--chain-id", "31337"), + nonce: option("--nonce", undefined), + payload: readPayload(option("--payload", undefined)) + }); + writeJson(outPath, envelope); + print({ + schema: "flowchain.local_wallet_sign_result.v0", + envelopePath: outPath, + envelope + }); + } else if (command === "verify") { + const envelope = readJson(option("--envelope", DEFAULT_ENVELOPE_PATH)); + print({ + schema: "flowchain.local_wallet_verify_result.v0", + ...verifyWalletTransaction({ + envelope, + expectedChainId: option("--chain-id", undefined), + expectedSignerId: option("--signer", undefined) + }) + }); + } else if (command === "export-public") { + print( + exportWalletPublicMetadata({ + vaultPath: option("--vault", DEFAULT_WALLET_PATH), + outPath: option("--out", DEFAULT_PUBLIC_METADATA_PATH) + }) + ); + } else if (command === "import-public") { + print( + importWalletPublicMetadata({ + vaultPath: option("--vault", DEFAULT_WALLET_PATH), + inPath: option("--in", DEFAULT_PUBLIC_METADATA_PATH) + }) + ); + } else { + printHelp(); + process.exit(command === "help" || command === "--help" || command === "-h" ? 0 : 1); + } +} catch (error) { + console.error(error.message); + process.exit(1); +} + +function option(name, fallback) { + const index = args.indexOf(name); + if (index === -1) { + return fallback; + } + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +function hasFlag(name) { + return args.includes(name); +} + +async function passwordFromEnvOrPrompt() { + if (process.env.FLOWCHAIN_WALLET_PASSWORD) { + return process.env.FLOWCHAIN_WALLET_PASSWORD; + } + if (!process.stdin.isTTY) { + throw new Error("set FLOWCHAIN_WALLET_PASSWORD or run interactively to unlock the local wallet"); + } + const rl = createInterface({ input, output }); + try { + return await rl.question("FlowChain local wallet password: "); + } finally { + rl.close(); + } +} + +function readPayload(path) { + if (path) { + return readJson(path); + } + return { + schema: "flowmemory.local_devnet.tx_payload.v0", + objectType: "agent_account", + tx: { + type: "RegisterAgent", + agentId: keccakUtf8("wallet-cli-demo-agent"), + controller: "operator:wallet-cli-demo", + modelPassportId: null, + metadataHash: keccakUtf8("wallet-cli-demo-agent.metadata") + } + }; +} + +function readJson(path) { + return JSON.parse(readFileSync(resolve(path), "utf8")); +} + +function writeJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +function print(value) { + console.log(JSON.stringify(value, null, 2)); +} + +function printHelp() { + console.log(`FlowChain local wallet CLI + +Commands: + create [--vault ] [--label