From 25d9e4805dc9fa66ed6aafbe7a337e73a2e7e164 Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 19:06:57 -0500 Subject: [PATCH 1/2] Add FlowChain product E2E gate --- ...FLOWCHAIN_PRODUCT_TESTNET_V1_ACCEPTANCE.md | 56 ++++++ infra/scripts/flowchain-product-e2e.ps1 | 164 ++++++++++++++++++ package.json | 1 + 3 files changed, 221 insertions(+) create mode 100644 docs/FLOWCHAIN_PRODUCT_TESTNET_V1_ACCEPTANCE.md create mode 100644 infra/scripts/flowchain-product-e2e.ps1 diff --git a/docs/FLOWCHAIN_PRODUCT_TESTNET_V1_ACCEPTANCE.md b/docs/FLOWCHAIN_PRODUCT_TESTNET_V1_ACCEPTANCE.md new file mode 100644 index 00000000..b813cfd5 --- /dev/null +++ b/docs/FLOWCHAIN_PRODUCT_TESTNET_V1_ACCEPTANCE.md @@ -0,0 +1,56 @@ +# FlowChain Product Testnet V1 Acceptance + +Status: executable acceptance contract for the full local/testnet product chain. + +Last updated: 2026-05-13. + +## Command + +The readiness command is: + +```powershell +npm run flowchain:product-e2e +``` + +This command is intentionally stricter than `npm run flowchain:full-smoke`. +`flowchain:full-smoke` proves the current private/local foundation package. +`flowchain:product-e2e` is the gate for the user-facing product testnet flow. + +## Required User Journey + +The product testnet is not ready until the E2E command proves: + +1. dependencies and prerequisite smoke checks pass; +2. a local node can start and produce blocks; +3. the control-plane API is reachable; +4. the workbench is reachable; +5. a local test wallet can sign and verify product transactions; +6. an account can receive local test units through faucet or test bridge credit; +7. a signed transfer is included in a block; +8. a local test token can be launched; +9. a DEX pool can be created; +10. liquidity can be added; +11. a swap changes balances and writes a receipt; +12. explorer/API/workbench surfaces show blocks, txs, accounts, tokens, pools, + positions, swaps, and bridge records; +13. export/import preserves expected local state; +14. no public route returns private keys or secret-shaped material. + +## Current Rule + +If a required product surface is missing, `flowchain:product-e2e` must fail and +name the owning subsystem. Do not use partial subsystem smoke tests as readiness +evidence. + +For coordination reports only, agents may run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-product-e2e.ps1 -AllowIncomplete +``` + +## Boundary + +This target is for local/testnet validation. It may include local Anvil bridge +or Base Sepolia testnet flow. Base mainnet and real-funds bridge behavior remain +blocked behind a separate audited production release gate. + diff --git a/infra/scripts/flowchain-product-e2e.ps1 b/infra/scripts/flowchain-product-e2e.ps1 new file mode 100644 index 00000000..a1cd98c5 --- /dev/null +++ b/infra/scripts/flowchain-product-e2e.ps1 @@ -0,0 +1,164 @@ +param( + [switch] $SkipFullSmoke, + [switch] $AllowIncomplete +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +$productRoot = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path "devnet/local/product-e2e") + +if (Test-Path -LiteralPath $productRoot) { + Remove-Item -LiteralPath $productRoot -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $productRoot | Out-Null + +$checks = [ordered]@{} +$missing = New-Object System.Collections.Generic.List[string] + +function Add-ProductCheck { + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true)] + [bool] $Passed, + + [Parameter(Mandatory = $true)] + [string] $Owner, + + [Parameter(Mandatory = $true)] + [string] $Evidence + ) + + $checks[$Name] = [ordered]@{ + passed = $Passed + owner = $Owner + evidence = $Evidence + } + + if (-not $Passed) { + $missing.Add("$Name ($Owner): $Evidence") | Out-Null + } +} + +if (-not $SkipFullSmoke) { + Invoke-FlowChainCommand -Label "Run private/local prerequisite full smoke" -FilePath "npm" -ArgumentList @("run", "flowchain:full-smoke") + Add-ProductCheck -Name "privateLocalFullSmoke" -Passed $true -Owner "integration" -Evidence "npm run flowchain:full-smoke passed" +} +else { + Add-ProductCheck -Name "privateLocalFullSmoke" -Passed $true -Owner "integration" -Evidence "skipped by caller" +} + +$packageJson = Get-Content -Raw -LiteralPath (Join-Path $repoRoot "package.json") | ConvertFrom-Json +$scripts = $packageJson.scripts + +$requiredScripts = [ordered]@{ + "flowchain:product-e2e" = "integration" + "flowchain:node" = "runtime" + "flowchain:faucet" = "runtime" + "flowchain:tx" = "runtime" + "workbench:dev" = "dashboard" + "control-plane:serve" = "control-plane" + "bridge:local-credit:smoke" = "bridge" +} + +foreach ($scriptName in $requiredScripts.Keys) { + $exists = $scripts.PSObject.Properties.Name -contains $scriptName + Add-ProductCheck -Name "script:$scriptName" -Passed $exists -Owner $requiredScripts[$scriptName] -Evidence $(if ($exists) { "script exists" } else { "missing root package script" }) +} + +$previousErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "Continue" +try { + $runtimeHelp = (& cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --help 2>&1) -join [Environment]::NewLine + if ($LASTEXITCODE -ne 0) { + throw "Failed to inspect flowmemory-devnet help." + } +} +finally { + $ErrorActionPreference = $previousErrorActionPreference +} + +$requiredRuntimeCommands = [ordered]@{ + "node" = "runtime" + "submit-tx" = "runtime" + "faucet" = "runtime" + "product-demo" = "runtime/token-dex" + "product-smoke" = "runtime/token-dex" +} + +foreach ($commandName in $requiredRuntimeCommands.Keys) { + $exists = $runtimeHelp -match "(^|\s)$([regex]::Escape($commandName))(\s|$)" + Add-ProductCheck -Name "runtime-command:$commandName" -Passed $exists -Owner $requiredRuntimeCommands[$commandName] -Evidence $(if ($exists) { "flowmemory-devnet exposes $commandName" } else { "flowmemory-devnet must expose $commandName" }) +} + +$controlPlaneMethodsPath = Join-Path $repoRoot "services/control-plane/src/types.ts" +$controlPlaneTypes = Get-Content -Raw -LiteralPath $controlPlaneMethodsPath +$requiredControlPlaneMethods = [ordered]@{ + "token_list" = "control-plane" + "token_get" = "control-plane" + "token_balance_list" = "control-plane" + "dex_pool_list" = "control-plane" + "dex_pool_get" = "control-plane" + "liquidity_position_list" = "control-plane" + "swap_list" = "control-plane" + "product_flow_status" = "control-plane" +} + +foreach ($methodName in $requiredControlPlaneMethods.Keys) { + $exists = $controlPlaneTypes.Contains("`"$methodName`"") + Add-ProductCheck -Name "control-plane-method:$methodName" -Passed $exists -Owner $requiredControlPlaneMethods[$methodName] -Evidence $(if ($exists) { "method type exists" } else { "method must be implemented and typed" }) +} + +$dashboardSource = Get-ChildItem -LiteralPath (Join-Path $repoRoot "apps/dashboard/src") -Recurse -File -Include *.tsx,*.ts,*.css | + ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName } +$dashboardText = $dashboardSource -join [Environment]::NewLine +$requiredDashboardSignals = [ordered]@{ + "Launch Token" = "dashboard" + "Create Pool" = "dashboard" + "Add Liquidity" = "dashboard" + "Swap" = "dashboard" + "Bridge Credit" = "dashboard" +} + +foreach ($signalName in $requiredDashboardSignals.Keys) { + $exists = $dashboardText.Contains($signalName) + Add-ProductCheck -Name "dashboard-surface:$signalName" -Passed $exists -Owner $requiredDashboardSignals[$signalName] -Evidence $(if ($exists) { "surface text exists" } else { "workbench must expose $signalName user flow" }) +} + +$reportPath = Join-Path $productRoot "flowchain-product-e2e-report.json" +$report = [ordered]@{ + schema = "flowchain.product_testnet_v1.e2e_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + commit = (& git rev-parse HEAD).Trim() + status = $(if ($missing.Count -eq 0) { "passed" } else { "incomplete" }) + checks = $checks + missingCoverage = @($missing) + readyDefinition = @( + "wallet funding transfer token launch DEX pool liquidity swap explorer verification", + "second-computer setup and restart-safe local services", + "local/testnet bridge funding only until separate production bridge gate" + ) +} + +Write-FlowChainJson -Path $reportPath -Value $report -Depth 16 + +Write-Host "" +Write-Host "FlowChain Product Testnet V1 E2E report: $reportPath" +if ($missing.Count -gt 0) { + Write-Host "" + Write-Host "Product Testnet V1 is not complete yet. Missing coverage:" + foreach ($item in $missing) { + Write-Host "- $item" + } + if (-not $AllowIncomplete) { + throw "FlowChain Product Testnet V1 E2E is incomplete. Rerun with -AllowIncomplete only for coordination reports." + } +} +else { + Write-Host "FlowChain Product Testnet V1 E2E passed." +} diff --git a/package.json b/package.json index 24b8ecf0..0321008d 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "flowchain:demo": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-demo.ps1", "flowchain:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-smoke.ps1", "flowchain:full-smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-full-smoke.ps1", + "flowchain:product-e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-product-e2e.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", "workbench:dev": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-workbench.ps1", From 5a9e4ff91a8bfbe696ff74c4fb172d94835608d8 Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 19:23:07 -0500 Subject: [PATCH 2/2] Build FlowChain product testnet E2E flow --- README.md | 11 +- START_FLOWCHAIN_LOCAL.ps1 | 2 +- ...lowchain-local-devnet-dashboard-state.json | 37 +- .../data/flowchain-local-devnet-state.json | 36 +- .../public/data/flowmemory-dashboard-v0.json | 14 +- apps/dashboard/src/data/workbench.ts | 359 ++++- apps/dashboard/src/styles.css | 81 ++ apps/dashboard/src/test/dashboardData.test.ts | 25 +- apps/dashboard/src/views/WorkbenchView.tsx | 144 +- crates/flowmemory-devnet/src/cli.rs | 259 +++- crates/flowmemory-devnet/src/lib.rs | 15 +- crates/flowmemory-devnet/src/model.rs | 1213 ++++++++++++++++- .../flowmemory-devnet/tests/devnet_tests.rs | 402 +++++- crypto/README.md | 14 +- crypto/TEST_VECTORS.md | 9 +- .../product-testnet-transactions.json | 745 ++++++++++ crypto/fixtures/vectors.json | 126 +- crypto/package.json | 7 + crypto/src/constants.js | 24 + crypto/src/index.d.ts | 101 ++ crypto/src/objects.js | 383 ++++++ crypto/src/transactions.js | 23 + .../src/validate-product-testnet-fixtures.js | 136 ++ crypto/src/validate-vectors.js | 16 + crypto/src/wallet-cli.js | 67 +- crypto/test/crypto.test.js | 77 +- docs/DASHBOARD_MVP.md | 15 +- docs/EASY_SECOND_COMPUTER_SETUP.md | 15 +- docs/FLOWCHAIN_CONTROL_PLANE_API.md | 152 ++- docs/FLOWCHAIN_SECOND_COMPUTER_SETUP.md | 30 +- .../dashboard/flowmemory-dashboard-v0.json | 14 +- .../devnet/control-plane-handoff.json | 53 +- .../generated/devnet/dashboard-state.json | 37 +- .../generated/devnet/indexer-handoff.json | 32 +- .../launch-core/generated/devnet/state.json | 36 +- .../generated/devnet/verifier-handoff.json | 22 +- infra/scripts/flowchain-product-e2e.ps1 | 63 +- schemas/flowmemory/README.md | 7 + .../local-transaction-envelope.schema.json | 8 + .../product-transaction.schema.json | 128 ++ services/control-plane/README.md | 17 +- services/control-plane/src/methods.ts | 614 ++++++++- services/control-plane/src/server.ts | 12 + services/control-plane/src/smoke.ts | 27 +- services/control-plane/src/types.ts | 11 + .../control-plane/test/control-plane.test.ts | 159 ++- 46 files changed, 5540 insertions(+), 238 deletions(-) create mode 100644 crypto/fixtures/product-testnet-transactions.json create mode 100644 crypto/src/validate-product-testnet-fixtures.js create mode 100644 schemas/flowmemory/product-transaction.schema.json diff --git a/README.md b/README.md index e3fe4595..82fa3d95 100644 --- a/README.md +++ b/README.md @@ -105,15 +105,16 @@ npm run flowchain:demo npm run flowchain:export ``` -Run the private/local acceptance smoke path when Foundry, Python, Visual Studio -Build Tools C++ workload, dashboard dependencies, and crypto dependencies are -installed: +Run the private/local product testnet acceptance path when Foundry, Python, +Visual Studio Build Tools C++ workload, dashboard dependencies, and crypto +dependencies are installed: ```powershell npm install --prefix apps/dashboard npm install --prefix crypto npm run flowchain:smoke npm run flowchain:full-smoke +npm run flowchain:product-e2e ``` Run the existing dashboard as the local workbench: @@ -141,8 +142,8 @@ npm run read:base-sepolia -- --rpc-url --address { + return collectionFrom(devnetState, [ + "localTestUnitBalances", + "balances", + "accountBalances", + "balanceSheet", + "credits", + "creditBalances", + ]).map((balance, index) => { const id = text(balance.balanceId ?? balance.accountId ?? balance.agentId ?? balance.id, `balance:${index + 1}`); return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { id, - kind: "No-value balance", + kind: "Local test-unit balance", title: id, summary: text(balance.summary, "Local no-value balance or credit metadata exported by the private testnet API."), status: statusFrom(balance.status, "observed"), facts: [ { label: "account", value: text(balance.accountId ?? balance.agentId) }, - { label: "amount", value: text(balance.amount ?? balance.balance ?? balance.credits) }, - { label: "unit", value: text(balance.unit, "no-value local credit") }, - { label: "updated", value: text(balance.updatedAt ?? balance.blockNumber) }, + { label: "owner", value: text(balance.owner ?? balance.controller) }, + { label: "amount", value: text(balance.amount ?? balance.balance ?? balance.credits ?? balance.units) }, + { label: "unit", value: text(balance.unit, "no-value local test unit") }, + { label: "faucet total", value: text(balance.totalFaucetUnits) }, + { label: "updated", value: text(balance.updatedAt ?? balance.updatedAtBlock ?? balance.blockNumber) }, ], raw: balance, }); @@ -778,8 +867,8 @@ function buildBalanceRecords(devnetState: unknown): WorkbenchRecord[] { } function buildFaucetEventRecords(devnetState: unknown): WorkbenchRecord[] { - return collectionFrom(devnetState, ["faucetEvents", "faucetClaims", "faucetCredits", "faucet"]).map((event, index) => { - const id = text(event.eventId ?? event.faucetEventId ?? event.txId ?? event.id, `faucet-event:${index + 1}`); + return collectionFrom(devnetState, ["faucetRecords", "faucetEvents", "faucetClaims", "faucetCredits", "faucet"]).map((event, index) => { + const id = text(event.eventId ?? event.faucetRecordId ?? event.faucetEventId ?? event.txId ?? event.id, `faucet-event:${index + 1}`); return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { id, kind: "Faucet event", @@ -788,8 +877,9 @@ function buildFaucetEventRecords(devnetState: unknown): WorkbenchRecord[] { status: statusFrom(event.status, "observed"), facts: [ { label: "account", value: text(event.accountId ?? event.agentId ?? event.wallet) }, - { label: "amount", value: text(event.amount ?? event.credits) }, - { label: "block", value: text(event.blockNumber) }, + { label: "recipient", value: text(event.recipient) }, + { label: "amount", value: text(event.amount ?? event.amountUnits ?? event.credits) }, + { label: "block", value: text(event.blockNumber ?? event.creditedAtBlock) }, { label: "created", value: text(event.createdAt ?? event.timestamp) }, ], raw: event, @@ -798,11 +888,11 @@ function buildFaucetEventRecords(devnetState: unknown): WorkbenchRecord[] { } function buildWalletMetadataRecords(devnetState: unknown): WorkbenchRecord[] { - return collectionFrom(devnetState, ["wallets", "walletMetadata", "publicWallets", "operatorKeyReferences"]).map((wallet, index) => { + return collectionFrom(devnetState, ["wallets", "walletMetadata", "publicWallets", "publicAccounts", "operatorKeyReferences"]).map((wallet, index) => { const id = text(wallet.walletId ?? wallet.keyReferenceId ?? wallet.operatorId ?? wallet.id, `wallet:${index + 1}`); return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { id, - kind: "Public wallet metadata", + kind: "Public wallet/account metadata", title: id, summary: text( wallet.secretMaterialBoundary ?? wallet.summary, @@ -821,26 +911,167 @@ function buildWalletMetadataRecords(devnetState: unknown): WorkbenchRecord[] { }); } -function buildBridgeRecords(devnetState: unknown, kind: "deposits" | "credits" | "withdrawals"): WorkbenchRecord[] { +function buildTokenLaunchRecords(devnetState: unknown): WorkbenchRecord[] { + return collectionFrom(devnetState, ["tokenLaunches", "tokenDefinitions", "tokens", "localTokens", "launchedTokens"]).map((token, index) => { + const id = text(token.tokenId ?? token.launchId ?? token.id ?? token.symbol, `token-launch:${index + 1}`); + return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id, + kind: "Token launch", + title: text(token.symbol ?? token.name ?? id), + summary: text( + token.summary, + "Local/testnet token definition exported for the Product Testnet V1 token-launch surface.", + ), + status: token.active === false ? "stale" : statusFrom(token.status, token.active === true ? "verified" : "observed"), + facts: [ + { label: "token id", value: id }, + { label: "name", value: text(token.name) }, + { label: "symbol", value: text(token.symbol) }, + { label: "issuer", value: text(token.issuer ?? token.owner ?? token.creator) }, + { label: "supply", value: text(token.initialSupply ?? token.supply ?? token.totalSupply) }, + { label: "block", value: text(token.blockNumber ?? token.launchedAtBlock) }, + ], + raw: token, + }); + }); +} + +function buildTokenBalanceRecords(devnetState: unknown): WorkbenchRecord[] { + return collectionFrom(devnetState, [ + "tokenBalances", + "tokenAccountBalances", + "accountTokenBalances", + "tokenLedger", + "tokenHoldings", + ]).map((balance, index) => { + const id = text( + balance.balanceId ?? balance.tokenBalanceId ?? balance.id ?? `${text(balance.accountId)}:${text(balance.tokenId ?? balance.symbol)}`, + `token-balance:${index + 1}`, + ); + return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id, + kind: "Token balance", + title: id, + summary: text(balance.summary, "Local/testnet token balance exported by the runtime/control-plane."), + status: statusFrom(balance.status, "observed"), + facts: [ + { label: "account", value: text(balance.accountId ?? balance.owner ?? balance.wallet) }, + { label: "token", value: text(balance.tokenId ?? balance.symbol) }, + { label: "amount", value: text(balance.amount ?? balance.balance ?? balance.units) }, + { label: "locked", value: text(balance.locked ?? balance.reserved, "0") }, + { label: "updated", value: text(balance.updatedAt ?? balance.updatedAtBlock ?? balance.blockNumber) }, + ], + raw: balance, + }); + }); +} + +function buildDexPoolRecords(devnetState: unknown): WorkbenchRecord[] { + return collectionFrom(devnetState, ["dexPools", "pools", "ammPools", "liquidityPools"]).map((pool, index) => { + const id = text(pool.poolId ?? pool.id ?? `${text(pool.baseToken ?? pool.tokenA)}:${text(pool.quoteToken ?? pool.tokenB)}`, `pool:${index + 1}`); + return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id, + kind: "DEX pool", + title: id, + summary: text(pool.summary, "Local/testnet DEX pool exported by the Product Testnet V1 runtime."), + status: statusFrom(pool.status, pool.active === false ? "stale" : "observed"), + facts: [ + { label: "base", value: text(pool.baseToken ?? pool.tokenA) }, + { label: "quote", value: text(pool.quoteToken ?? pool.tokenB) }, + { label: "reserve base", value: text(pool.reserveBase ?? pool.reserveA) }, + { label: "reserve quote", value: text(pool.reserveQuote ?? pool.reserveB) }, + { label: "lp supply", value: text(pool.lpSupply ?? pool.totalShares) }, + { label: "fee bps", value: text(pool.feeBps ?? pool.fee, "local default") }, + ], + raw: pool, + }); + }); +} + +function buildLiquidityPositionRecords(devnetState: unknown): WorkbenchRecord[] { + return collectionFrom(devnetState, [ + "liquidityPositions", + "lpPositions", + "positions", + "liquidityEvents", + "liquidityReceipts", + ]).map((position, index) => { + const id = text(position.positionId ?? position.lpPositionId ?? position.id, `liquidity:${index + 1}`); + return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id, + kind: "Liquidity position", + title: id, + summary: text(position.summary, "Local/testnet liquidity position or liquidity receipt."), + status: statusFrom(position.status, "observed"), + facts: [ + { label: "owner", value: text(position.owner ?? position.accountId ?? position.wallet) }, + { label: "pool", value: text(position.poolId) }, + { label: "shares", value: text(position.shares ?? position.lpTokens) }, + { label: "amount base", value: text(position.amountBase ?? position.amountA) }, + { label: "amount quote", value: text(position.amountQuote ?? position.amountB) }, + { label: "block", value: text(position.blockNumber ?? position.updatedAtBlock) }, + ], + raw: position, + }); + }); +} + +function buildSwapRecords(devnetState: unknown): WorkbenchRecord[] { + return collectionFrom(devnetState, ["swaps", "swapReceipts", "swapEvents", "dexSwaps"]).map((swap, index) => { + const id = text(swap.swapId ?? swap.receiptId ?? swap.txId ?? swap.id, `swap:${index + 1}`); + return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + id, + kind: "Swap", + title: id, + summary: text(swap.summary, "Local/testnet swap receipt exported by the DEX runtime/control-plane."), + status: statusFrom(swap.status, "observed"), + facts: [ + { label: "trader", value: text(swap.trader ?? swap.accountId ?? swap.wallet) }, + { label: "pool", value: text(swap.poolId) }, + { label: "token in", value: text(swap.tokenIn) }, + { label: "amount in", value: text(swap.amountIn) }, + { label: "token out", value: text(swap.tokenOut) }, + { label: "amount out", value: text(swap.amountOut) }, + ], + raw: swap, + }); + }); +} + +function bridgeFixtureRecords(kind: "deposits" | "credits" | "withdrawals", bridgeTestDeposit: unknown | null): UnknownRecord[] { + if (kind !== "deposits" || !isRecord(bridgeTestDeposit)) { + return []; + } + + return [bridgeTestDeposit]; +} + +function buildBridgeRecords( + devnetState: unknown, + kind: "deposits" | "credits" | "withdrawals", + bridgeTestDeposit: unknown | null = null, +): WorkbenchRecord[] { const keyMap = { deposits: ["bridgeDeposits", "deposits"], credits: ["bridgeCredits", "bridgeCreditEvents"], withdrawals: ["bridgeWithdrawals", "withdrawals"], } satisfies Record; - return collectionFrom(devnetState, keyMap[kind]).map((event, index) => { + return [...collectionFrom(devnetState, keyMap[kind]), ...bridgeFixtureRecords(kind, bridgeTestDeposit)].map((event, index) => { const id = text(event.bridgeEventId ?? event.depositId ?? event.creditId ?? event.withdrawalId ?? event.id, `bridge-${kind}:${index + 1}`); - return makeRecord("devnet", WORKBENCH_DEVNET_STATE_PATH, { + const fixturePath = event === bridgeTestDeposit ? WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH : WORKBENCH_DEVNET_STATE_PATH; + return makeRecord("devnet", fixturePath, { id, kind: `Bridge ${kind.slice(0, -1)}`, title: id, - summary: text(event.summary, "Private/local bridge lifecycle object exported by the control-plane."), + summary: text(event.summary, "Private/local or Base Sepolia bridge lifecycle test object exported for workbench inspection."), status: statusFrom(event.status, "pending"), facts: [ - { label: "account", value: text(event.accountId ?? event.wallet ?? event.recipient) }, + { label: "account", value: text(event.accountId ?? event.wallet ?? event.recipient ?? event.flowchainRecipient) }, { label: "amount", value: text(event.amount) }, - { label: "source", value: text(event.sourceChain ?? event.fromChain) }, + { label: "source", value: text(event.sourceChain ?? event.sourceChainId ?? event.fromChain) }, { label: "destination", value: text(event.destinationChain ?? event.toChain) }, + { label: "tx hash", value: text(event.txHash) }, { label: "block", value: text(event.blockNumber) }, ], raw: event, @@ -951,6 +1182,29 @@ function buildTransactionRecords(data: DashboardData, devnetState: unknown): Wor ); } +function buildExplorerRecords(data: DashboardData, devnetState: unknown, bridgeTestDeposit: unknown | null): WorkbenchRecord[] { + const explorerRecords = [ + ...buildBlockRecords(data, devnetState).slice(0, 4), + ...buildTransactionRecords(data, devnetState).slice(0, 8), + ...buildReceiptRecords(data, devnetState).slice(0, 6), + ...buildTokenLaunchRecords(devnetState).slice(0, 4), + ...buildTokenBalanceRecords(devnetState).slice(0, 4), + ...buildDexPoolRecords(devnetState).slice(0, 4), + ...buildLiquidityPositionRecords(devnetState).slice(0, 4), + ...buildSwapRecords(devnetState).slice(0, 4), + ...buildBridgeRecords(devnetState, "deposits", bridgeTestDeposit).slice(0, 4), + ...buildBridgeRecords(devnetState, "credits", bridgeTestDeposit).slice(0, 4), + ...buildBridgeRecords(devnetState, "withdrawals", bridgeTestDeposit).slice(0, 4), + ]; + + return explorerRecords.map((record) => ({ + ...record, + id: `explorer:${record.kind}:${record.id}`, + kind: `Explorer ${record.kind}`, + summary: `Explorer index projection: ${record.summary}`, + })); +} + function buildRootfieldRecords(data: DashboardData, devnetState: unknown): WorkbenchRecord[] { const devnetRootfields = collectionFrom(devnetState, ["rootfields", "rootfieldState", "rootfieldsById"]).map((rootfield, index) => { const id = text(rootfield.rootfieldId ?? rootfield.id, `rootfield:${index + 1}`); @@ -1423,6 +1677,7 @@ function buildRawJsonRecords( controlPlane: ControlPlaneProbe, devnetState: unknown | null, devnetDashboardState: unknown | null, + bridgeTestDeposit: unknown | null, ): WorkbenchRecord[] { return [ makeRecord("indexer", data.metadata.fixturePath, { @@ -1464,6 +1719,20 @@ function buildRawJsonRecords( ], raw: devnetDashboardState, }), + makeRecord("devnet", WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH, { + id: "raw-bridge-test-deposit", + kind: "Raw JSON", + title: WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH, + summary: bridgeTestDeposit + ? "Bridge test-deposit fixture loaded for the local/testnet bridge record surface." + : "Bridge test-deposit fixture was not loaded.", + status: bridgeTestDeposit ? "verified" : "unresolved", + facts: [ + { label: "schema", value: isRecord(bridgeTestDeposit) ? text(bridgeTestDeposit.schema) : "missing" }, + { label: "keys", value: topLevelKeys(bridgeTestDeposit) }, + ], + raw: bridgeTestDeposit, + }), makeLocalRecord( "indexer", controlPlane.url, @@ -1498,6 +1767,7 @@ function buildProvenanceRecords( controlPlane: ControlPlaneProbe, devnetState: unknown | null, devnetDashboardState: unknown | null, + bridgeTestDeposit: unknown | null, ): WorkbenchRecord[] { return [ makeLocalRecord( @@ -1561,6 +1831,20 @@ function buildProvenanceRecords( ], raw: devnetDashboardState, }), + makeRecord("devnet", WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH, { + id: "bridge-test-deposit-fixture", + kind: "Bridge fixture", + title: WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH, + summary: bridgeTestDeposit + ? "Existing bridge test-deposit fixture is available for bridge/explorer views." + : "Bridge test-deposit fixture was not loaded.", + status: bridgeTestDeposit ? "verified" : "unresolved", + facts: [ + { label: "schema", value: isRecord(bridgeTestDeposit) ? text(bridgeTestDeposit.schema) : "missing" }, + { label: "source", value: "fixtures/bridge/test deposit runtime copy" }, + ], + raw: bridgeTestDeposit, + }), ]; } @@ -1636,6 +1920,7 @@ export function buildWorkbenchSnapshot( controlPlane?: ControlPlaneProbe; devnetState?: unknown | null; devnetDashboardState?: unknown | null; + bridgeTestDeposit?: unknown | null; loadIssues?: string[]; } = {}, ): WorkbenchSnapshot { @@ -1650,6 +1935,7 @@ export function buildWorkbenchSnapshot( } satisfies ControlPlaneProbe); const controlPlaneState = extractControlPlaneState(controlPlane.state); const activeDevnetState = controlPlaneState ?? options.devnetState ?? null; + const bridgeTestDeposit = options.bridgeTestDeposit ?? null; const source: WorkbenchSource = controlPlane.status === "available" && controlPlaneState ? "control-plane" : "fixture-fallback"; const sections: Record = { @@ -1662,6 +1948,12 @@ export function buildWorkbenchSnapshot( balances: buildBalanceRecords(activeDevnetState), faucetEvents: buildFaucetEventRecords(activeDevnetState), walletMetadata: buildWalletMetadataRecords(activeDevnetState), + tokenLaunches: buildTokenLaunchRecords(activeDevnetState), + tokenBalances: buildTokenBalanceRecords(activeDevnetState), + dexPools: buildDexPoolRecords(activeDevnetState), + liquidityPositions: buildLiquidityPositionRecords(activeDevnetState), + swaps: buildSwapRecords(activeDevnetState), + explorerRecords: buildExplorerRecords(data, activeDevnetState, bridgeTestDeposit), rootfields: buildRootfieldRecords(data, activeDevnetState), agents: buildAgentRecords(data, activeDevnetState), models: buildModelRecords(activeDevnetState), @@ -1672,16 +1964,28 @@ export function buildWorkbenchSnapshot( verifierReports: buildVerifierRecords(data, activeDevnetState), challenges: buildChallengeRecords(activeDevnetState), finality: buildFinalityRecords(data, activeDevnetState), - bridgeDeposits: buildBridgeRecords(activeDevnetState, "deposits"), - bridgeCredits: buildBridgeRecords(activeDevnetState, "credits"), - bridgeWithdrawals: buildBridgeRecords(activeDevnetState, "withdrawals"), + bridgeDeposits: buildBridgeRecords(activeDevnetState, "deposits", bridgeTestDeposit), + bridgeCredits: buildBridgeRecords(activeDevnetState, "credits", bridgeTestDeposit), + bridgeWithdrawals: buildBridgeRecords(activeDevnetState, "withdrawals", bridgeTestDeposit), provenance: [], hardwareSignals: buildHardwareSignalRecords(data, activeDevnetState), rawJson: [], }; - sections.provenance = buildProvenanceRecords(data, controlPlane, options.devnetState ?? null, options.devnetDashboardState ?? null); - sections.rawJson = buildRawJsonRecords(data, controlPlane, options.devnetState ?? null, options.devnetDashboardState ?? null); + sections.provenance = buildProvenanceRecords( + data, + controlPlane, + options.devnetState ?? null, + options.devnetDashboardState ?? null, + bridgeTestDeposit, + ); + sections.rawJson = buildRawJsonRecords( + data, + controlPlane, + options.devnetState ?? null, + options.devnetDashboardState ?? null, + bridgeTestDeposit, + ); const displayedSections = source === "control-plane" ? relabelDevnetRecordsAsControlPlane(sections, controlPlane) : sections; return { @@ -1697,6 +2001,7 @@ export function buildWorkbenchSnapshot( dashboard: data, devnetState: options.devnetState ?? null, devnetDashboardState: options.devnetDashboardState ?? null, + bridgeTestDeposit, controlPlaneHealth: controlPlane.health ?? null, controlPlaneState: controlPlane.state ?? null, }, @@ -1704,12 +2009,13 @@ export function buildWorkbenchSnapshot( } export async function fetchWorkbenchSnapshot(data: DashboardData): Promise { - const [controlPlane, devnetStateResult, devnetDashboardStateResult] = await Promise.all([ + const [controlPlane, devnetStateResult, devnetDashboardStateResult, bridgeTestDepositResult] = await Promise.all([ probeControlPlane(), fetchOptionalJson(WORKBENCH_DEVNET_STATE_PATH), fetchOptionalJson(WORKBENCH_DEVNET_DASHBOARD_STATE_PATH), + fetchOptionalJson(WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH), ]); - const loadIssues = [devnetStateResult.error, devnetDashboardStateResult.error].filter( + const loadIssues = [devnetStateResult.error, devnetDashboardStateResult.error, bridgeTestDepositResult.error].filter( (issue): issue is string => typeof issue === "string" && issue.length > 0, ); @@ -1717,6 +2023,7 @@ export async function fetchWorkbenchSnapshot(data: DashboardData): Promise { const workbench = buildWorkbenchSnapshot(data, { devnetState, devnetDashboardState, + bridgeTestDeposit, }); expect(workbench.source).toBe("fixture-fallback"); @@ -132,9 +135,16 @@ describe("dashboard fixture", () => { expect(workbench.sections.rawJson.map((record) => record.id)).toContain("raw-dashboard-fixture"); expect(workbench.sections.models.length).toBeGreaterThan(0); expect(workbench.sections.challenges.length).toBeGreaterThan(0); - expect(workbench.sections.bridgeDeposits).toHaveLength(0); + expect(workbench.sections.balances.length).toBeGreaterThan(0); + expect(workbench.sections.tokenLaunches).toHaveLength(0); + expect(workbench.sections.tokenBalances).toHaveLength(0); + expect(workbench.sections.dexPools).toHaveLength(0); + expect(workbench.sections.liquidityPositions).toHaveLength(0); + expect(workbench.sections.swaps).toHaveLength(0); + expect(workbench.sections.bridgeDeposits.length).toBeGreaterThan(0); expect(workbench.sections.bridgeCredits).toHaveLength(0); expect(workbench.sections.bridgeWithdrawals).toHaveLength(0); + expect(workbench.sections.explorerRecords.length).toBeGreaterThan(0); expect(workbench.node.status).toBe("offline"); expect(workbench.actions).toEqual([]); @@ -197,6 +207,9 @@ describe("dashboard fixture", () => { if (url === WORKBENCH_DEVNET_DASHBOARD_STATE_PATH) { return Response.json(devnetDashboardState); } + if (url === WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH) { + return Response.json(bridgeTestDeposit); + } return new Response("not found", { status: 404 }); }); @@ -208,15 +221,18 @@ describe("dashboard fixture", () => { expect(workbench.raw.controlPlaneHealth).toEqual({ status: "ok" }); expect(workbench.raw.controlPlaneState).toEqual({ state: devnetState }); expect(workbench.raw.devnetState).toEqual(devnetState); + expect(workbench.raw.bridgeTestDeposit).toEqual(bridgeTestDeposit); expect(workbench.loadIssues).toEqual([]); expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:8787/health", expect.any(Object)); expect(fetchMock).toHaveBeenCalledWith(WORKBENCH_DEVNET_STATE_PATH, expect.any(Object)); + expect(fetchMock).toHaveBeenCalledWith(WORKBENCH_BRIDGE_TEST_DEPOSIT_PATH, expect.any(Object)); }); it("renders the critical workbench view labels from fixture fallback", () => { const workbench = buildWorkbenchSnapshot(data, { devnetState, devnetDashboardState, + bridgeTestDeposit, }); const html = renderToStaticMarkup(createElement(WorkbenchView, { data, workbench })); @@ -224,7 +240,14 @@ describe("dashboard fixture", () => { expect(html).toContain("Node and API status"); expect(html).toContain("Control-plane offline"); expect(html).toContain("Wallet Metadata"); + expect(html).toContain("Token Launch"); + expect(html).toContain("Token Balances"); + expect(html).toContain("DEX Pools"); + expect(html).toContain("Liquidity"); + expect(html).toContain("Swaps"); + expect(html).toContain("Explorer Records"); expect(html).toContain("Bridge Deposits"); + expect(html).toContain("private keys in browser localStorage"); 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 4be659b4..8fdd4cc0 100644 --- a/apps/dashboard/src/views/WorkbenchView.tsx +++ b/apps/dashboard/src/views/WorkbenchView.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Activity, Database, Network, PlayCircle, RefreshCw, Search, Server, Terminal } from "lucide-react"; +import { Activity, Coins, Database, ListChecks, Network, PlayCircle, RefreshCw, Repeat2, Search, Server, ShieldAlert, Terminal, Wallet } from "lucide-react"; import { EmptyState } from "../components/EmptyState"; import { HashValue } from "../components/HashValue"; import { ProvenanceLine } from "../components/ProvenanceLine"; @@ -41,6 +41,10 @@ function missingStateDetail(activeDefinition: (typeof WORKBENCH_SECTIONS)[number return `${activeDefinition.missingService} did not provide records for ${activeDefinition.expectedEndpoint}. Run ${activeDefinition.missingCommand} locally, then refresh this dashboard.`; } +function statusForCount(count: number): DashboardStatus { + return count > 0 ? "verified" : "pending"; +} + export function WorkbenchView({ data, workbench, onRefresh }: WorkbenchViewProps) { const [activeSection, setActiveSection] = useState(DEFAULT_SECTION); const [query, setQuery] = useState(""); @@ -52,6 +56,65 @@ export function WorkbenchView({ data, workbench, onRefresh }: WorkbenchViewProps [activeRecords, query], ); const sourceStatus: DashboardStatus = workbench.source === "control-plane" ? "verified" : "stale"; + const bridgeRecordCount = + workbench.sections.bridgeDeposits.length + workbench.sections.bridgeCredits.length + workbench.sections.bridgeWithdrawals.length; + const productSurfaces: Array<{ + key: WorkbenchSectionKey; + label: string; + detail: string; + command: string; + count: number; + Icon: typeof Wallet; + }> = [ + { + key: "walletMetadata", + label: "Wallet public state", + detail: "Public account/key references only. Signing secrets stay outside browser storage.", + command: "npm run flowchain:init", + count: workbench.sections.walletMetadata.length + workbench.sections.accounts.length, + Icon: Wallet, + }, + { + key: "balances", + label: "Local balances", + detail: "No-value local test units from faucet or bridge-credit flows.", + command: "npm run flowchain:faucet", + count: workbench.sections.balances.length + workbench.sections.faucetEvents.length, + Icon: Coins, + }, + { + key: "tokenLaunches", + label: "Token launch", + detail: "Local/testnet token definitions and launch receipts.", + command: "npm run flowchain:product-e2e", + count: workbench.sections.tokenLaunches.length + workbench.sections.tokenBalances.length, + Icon: ListChecks, + }, + { + key: "dexPools", + label: "DEX pools", + detail: "Pool reserves, liquidity positions, and swap receipt visibility.", + command: "npm run flowchain:product-e2e", + count: workbench.sections.dexPools.length + workbench.sections.liquidityPositions.length + workbench.sections.swaps.length, + Icon: Repeat2, + }, + { + key: "explorerRecords", + label: "Explorer records", + detail: "Blocks, transactions, receipts, token, DEX, and bridge rollups.", + command: "npm run flowchain:product-e2e", + count: workbench.sections.explorerRecords.length, + Icon: Database, + }, + { + key: "bridgeDeposits", + label: "Bridge records", + detail: "Local/Anvil/Base Sepolia test records only; real-funds bridge remains blocked.", + command: "npm run bridge:local-credit:smoke", + count: bridgeRecordCount, + Icon: ShieldAlert, + }, + ]; const runLocalAction = async (endpoint: string, label: string) => { const [method, path] = endpoint.split(/\s+/, 2); @@ -73,7 +136,7 @@ export function WorkbenchView({ data, workbench, onRefresh }: WorkbenchViewProps