From b9d9048d7c6975874fcdb47ccf073876818de13d Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 13:18:36 -0500 Subject: [PATCH] Add pre-production hardening and Base Sepolia reader --- .github/workflows/ci.yml | 12 +- .slither.config.json | 3 + README.md | 29 ++- contracts/ACCESS_CONTROL_REVIEW.md | 131 +++++++++++ contracts/DEPLOYMENT_BOUNDARY.md | 58 +++++ contracts/STATIC_ANALYSIS.md | 59 +++++ docs/CURRENT_STATE.md | 19 +- ...ase-sepolia-reader-and-claim-guardrails.md | 35 +++ docs/INDEXER_VERIFIER_MVP.md | 44 +++- docs/MARKETING_CLAIMS_GUARDRAILS.md | 60 +++++ docs/PRODUCTION_READINESS_CHECKLIST.md | 74 ++++++ docs/ROADMAP.md | 15 +- .../reviews/PRODUCTION_READINESS_V0_REVIEW.md | 33 +++ infra/scripts/check-unsafe-claims.mjs | 88 ++++++++ infra/scripts/contracts-static-analysis.ps1 | 37 +++ infra/scripts/contracts-static-analysis.sh | 25 ++ package.json | 1 + services/indexer/README.md | 26 ++- .../fixtures/indexer-state.schema.json | 2 +- services/indexer/package.json | 1 + services/indexer/src/base-sepolia.ts | 213 ++++++++++++++++++ services/indexer/src/indexer.ts | 15 +- services/indexer/src/persistence.ts | 59 +++++ services/indexer/src/rpc.ts | 45 +++- services/indexer/test/indexer.test.ts | 119 +++++++++- tests/RootfieldRegistry.t.sol | 57 +++++ 26 files changed, 1219 insertions(+), 41 deletions(-) create mode 100644 .slither.config.json create mode 100644 contracts/ACCESS_CONTROL_REVIEW.md create mode 100644 contracts/DEPLOYMENT_BOUNDARY.md create mode 100644 contracts/STATIC_ANALYSIS.md create mode 100644 docs/DECISIONS/2026-05-13-base-sepolia-reader-and-claim-guardrails.md create mode 100644 docs/MARKETING_CLAIMS_GUARDRAILS.md create mode 100644 docs/PRODUCTION_READINESS_CHECKLIST.md create mode 100644 docs/reviews/PRODUCTION_READINESS_V0_REVIEW.md create mode 100755 infra/scripts/check-unsafe-claims.mjs create mode 100644 infra/scripts/contracts-static-analysis.ps1 create mode 100755 infra/scripts/contracts-static-analysis.sh create mode 100644 services/indexer/src/base-sepolia.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3375a7c9..3ff0732d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Check required bootstrap paths shell: bash run: | @@ -87,6 +92,9 @@ jobs: fi done + - name: Check launch claim guardrails + run: node infra/scripts/check-unsafe-claims.mjs + contracts: name: Contracts runs-on: ubuntu-latest @@ -97,8 +105,8 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - - name: Run Foundry tests - run: forge test + - name: Run contract hardening baseline + run: bash infra/scripts/contracts-static-analysis.sh services: name: Services and launch core diff --git a/.slither.config.json b/.slither.config.json new file mode 100644 index 00000000..b3f44ee5 --- /dev/null +++ b/.slither.config.json @@ -0,0 +1,3 @@ +{ + "filter_paths": "(cache|out|node_modules|lib)" +} diff --git a/README.md b/README.md index 7115efb0..d214adf7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ FlowMemory is a Base-native AI memory, neural-geometry, reliability, decentralized hardware, and future appchain/L1 research project. -This repository has completed the initial bootstrap and contracts-foundation passes. It contains project context, collaboration rules, planning documents, GitHub templates, a CI scaffold, worktree setup, placeholder work areas, and an initial FlowPulse/Rootfield contracts foundation. Do not treat the current repo as containing production product features yet. +This repository contains the FlowMemory V0 foundation: project operating docs, local/test contracts, fixture-first services, Rootflow and Flow Memory launch-core generation, a fixture-backed dashboard, crypto helpers, a local no-value devnet prototype, and FlowRouter hardware POC materials. Do not treat the current repo as containing production product features yet. ## What FlowMemory Is Exploring @@ -41,7 +41,9 @@ Every contributor and agent should read: 5. `docs/ROOTFLOW_V0.md` 6. `docs/FLOW_MEMORY_V0.md` 7. `docs/V0_LAUNCH_ACCEPTANCE.md` -8. `docs/DAILY_HQ_RUNBOOK.md` if operating HQ or coordinating agents +8. `docs/PRODUCTION_READINESS_CHECKLIST.md` +9. `docs/MARKETING_CLAIMS_GUARDRAILS.md` +10. `docs/DAILY_HQ_RUNBOOK.md` if operating HQ or coordinating agents Then work only inside the assigned scope. @@ -55,6 +57,8 @@ FlowMemory is managed as a multi-agent program. The management layer is part of - `docs/reviews/OPEN_PR_MERGE_READINESS.md`: historical merge-readiness evidence for the merged V0 foundation PRs - `docs/PR_PROCESS.md`: branch, draft PR, review, merge, conflict, and issue-closing rules - `docs/DAILY_HQ_RUNBOOK.md`: morning review, triage, agent launch, PR monitoring, merge order, and handoff +- `docs/PRODUCTION_READINESS_CHECKLIST.md`: blocking checklist before any production language is allowed +- `docs/MARKETING_CLAIMS_GUARDRAILS.md`: allowed and blocked launch claims for docs and marketing - `infra/scripts/status-report.ps1`: read-only local worktree, PR, and issue status report Immediate major milestone: build the Rootflow V0 and Flow Memory V0 launch core. This means local contracts/tests, FlowPulse fixtures, Rootflow transitions, Flow Memory schemas, verifier reports, crypto fixtures, dashboard-readable state, and local smoke-test gates. It does not mean production deployment. @@ -70,6 +74,7 @@ This regenerates local/test Rootflow and Flow Memory V0 fixtures, including `fix ## What Not To Claim - Do not claim FlowMemory has production contracts or deployment automation. +- Do not claim FlowMemory is production-ready or mainnet-ready. - Do not claim Uniswap v4 hook integration exists yet. - Do not claim explorer, hardware console, production FlowRouter hardware, or Meshtastic integration exists yet. - Do not claim cryptographic proof systems, tokenomics, or appchain/L1 implementation exists yet. @@ -86,6 +91,7 @@ This regenerates local/test Rootflow and Flow Memory V0 fixtures, including `fix - `inbox/`: staging area for imported prompts, notes, and unsorted context - `research/`: future AI memory, neural geometry, and appchain/L1 research - `services/`: future indexer, verifier, worker, and API services +- `schemas/flowmemory/`: canonical Flow Memory and Rootflow JSON schemas ## Implemented Foundation @@ -95,20 +101,29 @@ This regenerates local/test Rootflow and Flow Memory V0 fixtures, including `fix - Worktree setup script - `contracts/FlowPulse.sol` - `contracts/RootfieldRegistry.sol` +- contract skeletons for artifacts, cursors, workers, verifiers, receipts, verifier reports, hook adapter, and work scheduling +- contracts hardening docs and static-analysis runner - `contracts/FLOWPULSE_SCHEMA.md` - `tests/RootfieldRegistry.t.sol` -- Initial Foundry tests for the Rootfield registry foundation +- Foundry tests for the Rootfield registry foundation and live V0 contract package +- fixture-first indexer/verifier packages and local launch-core generation +- Base Sepolia reader path with explicit RPC URL and durable checkpoint output +- Flow Memory V0 schemas and generated Rootflow transition fixtures +- fixture-backed dashboard V0 +- crypto helper package and test vectors +- local no-value devnet prototype +- FlowRouter hardware POC docs, schemas, and simulator fixture - Documented URI/log-data limitations for the current contract skeleton ## Still Conceptual - Uniswap v4 hook integration -- Indexer and verifier services -- Complete Rootflow runtime implementation -- Complete Flow Memory runtime implementation +- Production indexer and verifier services +- Production Rootflow runtime implementation +- Production Flow Memory runtime implementation - FlowRouter hardware implementation - Meshtastic integration -- Dashboard, explorer, and hardware console applications +- Explorer and hardware console applications - Cryptographic proof systems - Appchain/L1 design and implementation diff --git a/contracts/ACCESS_CONTROL_REVIEW.md b/contracts/ACCESS_CONTROL_REVIEW.md new file mode 100644 index 00000000..b38f532c --- /dev/null +++ b/contracts/ACCESS_CONTROL_REVIEW.md @@ -0,0 +1,131 @@ +# Contracts Access-Control Review + +Status: V0 launch hardening review. + +## Summary + +The current contracts use simple ownership or self-registration patterns. They do not implement staking, slashing, token custody, rewards, production governance, verifier consensus, or upgrade admin controls. + +## RootfieldRegistry + +Owner model: each `rootfieldId` has one owner. + +Owner-gated functions: + +- `submitRoot` +- `deactivateRootfield` +- `transferRootfieldOwnership` + +Current protections: + +- zero rootfield id rejected +- duplicate rootfield id rejected +- zero root rejected +- inactive rootfield blocks root submission and transfer +- zero new owner rejected +- ownership transfer emits both a FlowPulse status event and a dedicated ownership event + +Launch risk to watch: + +- current ownership transfer uses `parentPulseId = bytes32(0)` by design; future versions may require explicit parent linkage. +- URI fields are advisory event data, not trusted storage pointers. + +## Owner-Allowlist Registries + +Contracts: + +- `VerifierReportRegistry` +- `WorkReceiptRegistry` + +Owner-gated functions: + +- `setVerifierAuthorization` +- `setWorkerAuthorization` + +Submitter-gated functions: + +- `submitVerifierReport` requires an authorized verifier. +- `submitWorkReceipt` requires an authorized worker. + +Current protections: + +- zero worker/verifier rejected +- duplicate report/receipt id rejected +- invalid report status rejected +- invalid work lane rejected +- zero target or commitment fields rejected + +Launch risk to watch: + +- deployer is permanent owner in V0; there is no multisig, timelock, or owner transfer. +- allowlists are coordination controls, not decentralized verifier consensus. + +## Self-Registration Registries + +Contracts: + +- `WorkerRegistry` +- `VerifierRegistry` + +Owner model: the registering address controls its own metadata lifecycle. + +Current protections: + +- duplicate registration rejected +- zero operator id rejected +- zero role rejected +- inactive records cannot update again + +Launch risk to watch: + +- registration does not prove work quality, correctness, identity, or stake. + +## Per-Record Owner Registries + +Contracts: + +- `ArtifactRegistry` +- `CursorRegistry` + +Owner-gated functions: + +- `deprecateArtifact` +- `advanceCursor` + +Current protections: + +- zero ids and zero commitments rejected +- duplicate records rejected +- only the stored owner can mutate the record + +Launch risk to watch: + +- advisory URI strings are emitted as logs and are not validated content availability proofs. + +## Open Submission Contracts + +Contracts: + +- `ReceiptVerifier` +- `WorkDebtScheduler` +- `FlowMemoryHookAdapter` + +Current boundary: + +- `ReceiptVerifier` accepts first-writer receipt-report commitments and does not cryptographically verify receipts. +- `WorkDebtScheduler` allows any scheduler to assign work to a nonzero worker and allows scheduler or worker to mark completion. +- `FlowMemoryHookAdapter` validates nonzero inputs and emits an observation event; it is not a production Uniswap v4 hook. + +Launch risk to watch: + +- open submission is acceptable for V0 commitments only if docs and demos treat outputs as untrusted until off-chain verifier reports exist. + +## Required Review Before Expanding + +Before adding rewards, staking, slashing, custody, dynamic fees, production hook permissions, or appchain/L1 settlement: + +- create a threat model issue +- require a separate review worktree +- require event tests for every state transition +- require static analysis with Slither +- update this access-control review diff --git a/contracts/DEPLOYMENT_BOUNDARY.md b/contracts/DEPLOYMENT_BOUNDARY.md new file mode 100644 index 00000000..63d17d70 --- /dev/null +++ b/contracts/DEPLOYMENT_BOUNDARY.md @@ -0,0 +1,58 @@ +# Contracts Deployment Boundary + +Status: V0 local and Base Sepolia readiness boundary. + +## Allowed Now + +- Local Foundry tests. +- Local fixture generation and indexer/verifier/dashboard flows. +- Base Sepolia deployment preparation for the current V0 contracts. +- Base Sepolia reads from explicit RPC URLs. +- Public docs that describe emitted events, roots, receipts, and off-chain verification paths. + +## Not Allowed Yet + +- Base mainnet deployment claims. +- Production-mainnet readiness claims. +- Production L1 claims. +- Token launch, rewards, slashing, or fee-market mechanics. +- Dynamic Uniswap v4 fee hooks. +- Custody of user tokens. +- Claims that contracts can know `txHash` or `logIndex` during execution. +- Claims that on-chain storage is free or that arbitrary AI data is stored on-chain. + +## Deployment Inputs Required + +Before a Base Sepolia deployment transaction is sent, the PR or issue must record: + +- target chain: Base Sepolia, chain id `84532` +- exact contract names and constructor arguments +- deployer account address +- compiled bytecode hash or Foundry build commit +- expected event signatures +- post-deploy verification steps +- rollback or redeploy plan + +Private keys must not be committed to the repo, copied into docs, or stored in generated artifacts. + +## Current Contract Set + +- `RootfieldRegistry`: Rootfield namespaces and root commitment pulses. +- `FlowMemoryHookAdapter`: dependency-light hook-adapter event scaffold, not a production Uniswap hook. +- `ReceiptVerifier`: compact receipt-report commitments, not cryptographic receipt verification. +- `VerifierReportRegistry`: owner-authorized verifier report commitments. +- `WorkReceiptRegistry`: owner-authorized worker receipt commitments. +- `WorkerRegistry`: self-registration for worker identity metadata. +- `VerifierRegistry`: self-registration for verifier identity metadata. +- `ArtifactRegistry`: artifact commitment metadata. +- `CursorRegistry`: off-chain cursor commitment metadata. +- `WorkDebtScheduler`: work-state commitments without token debt. + +## Post-Deploy Checks + +- Verify source on the explorer when possible. +- Emit one small test event per deployed event source where safe. +- Run the Base Sepolia indexer reader over the deployment block range. +- Confirm persisted indexer state and checkpoint exist. +- Confirm dashboard fixtures can read the generated state. +- Update `docs/CURRENT_STATE.md` with what is deployed and what remains local-only. diff --git a/contracts/STATIC_ANALYSIS.md b/contracts/STATIC_ANALYSIS.md new file mode 100644 index 00000000..0cf55e30 --- /dev/null +++ b/contracts/STATIC_ANALYSIS.md @@ -0,0 +1,59 @@ +# Contracts Static Analysis + +Status: pre-production hardening setup. + +This repository now has one standard command for contract hardening checks: + +```powershell +.\infra\scripts\contracts-static-analysis.ps1 +``` + +On bash-compatible shells: + +```bash +bash infra/scripts/contracts-static-analysis.sh +``` + +The command runs: + +- `forge build` +- `forge test` +- `slither . --config-file .slither.config.json` when Slither is installed + +Formatting can be checked explicitly: + +```powershell +.\infra\scripts\contracts-static-analysis.ps1 -CheckFormat +``` + +```bash +CHECK_FORGE_FMT=1 bash infra/scripts/contracts-static-analysis.sh +``` + +Audit environments should require Slither explicitly: + +```powershell +.\infra\scripts\contracts-static-analysis.ps1 -RequireSlither +``` + +```bash +REQUIRE_SLITHER=1 bash infra/scripts/contracts-static-analysis.sh +``` + +## Current Boundary + +The contracts are V0 launch foundations for FlowPulse, Rootfield, receipts, workers, verifiers, cursors, and hook-adapter events. They are not a production L1, production verifier network, token system, custody system, fee system, or production Uniswap v4 hook deployment. + +Static-analysis findings should be triaged into: + +- blocker: unsafe access control, broken event schema, corrupted state transition, or deploy-time risk +- launch-v0 fix: issue that matters for Base Sepolia/demo correctness +- future hardening: useful improvement that does not block the V0 launch boundary + +## Required Before Any Public Testnet Deployment + +- All Foundry tests pass. +- `forge fmt --check` passes or a deliberate formatting-normalization PR is opened. +- Slither is run and findings are attached to the PR or issue. +- Access-control changes are reviewed against [ACCESS_CONTROL_REVIEW.md](./ACCESS_CONTROL_REVIEW.md). +- Deployment scope is reviewed against [DEPLOYMENT_BOUNDARY.md](./DEPLOYMENT_BOUNDARY.md). diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 1ce4eba1..2462b188 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -8,7 +8,7 @@ This file is the beginner-friendly source of truth for what exists in FlowMemory FlowMemory is in foundation hardening. -The bootstrap repository operating system, contracts V0 foundation, crypto V0 foundation, local indexer/verifier fixture package, dashboard V0, FlowRouter hardware POC, and local no-value devnet prototype have merged into `main`. +The bootstrap repository operating system, contracts V0 foundation, crypto V0 foundation, local indexer/verifier fixture package, dashboard V0, FlowRouter hardware POC, local no-value devnet prototype, launch-core contract-event spine, and pre-production hardening guardrails have merged into `main`. The launch-core V0 stack now has a single runnable local command that connects contract fixtures, local indexing/verifier outputs, crypto schema vocabulary, Rootflow transitions, Flow Memory objects, generated dashboard state, local no-value devnet output, and hardware POC output without production deployment. @@ -34,8 +34,10 @@ Contracts foundation: - `contracts/FlowMemoryHookAdapter.sol` is a compileable V0 hook-adapter scaffold. It is not a production Uniswap v4 hook. - `contracts/ArtifactRegistry.sol`, `CursorRegistry.sol`, `ReceiptVerifier.sol`, `WorkerRegistry.sol`, `VerifierRegistry.sol`, `WorkReceiptRegistry.sol`, `VerifierReportRegistry.sol`, and `WorkDebtScheduler.sol` provide local/test skeleton surfaces for commitments, cursors, work receipts, verifier reports, and work state. - `contracts/FLOWPULSE_SCHEMA.md` documents event fields, receipt boundaries, and URI/log-data limitations. -- `tests/RootfieldRegistry.t.sol` and `tests/LiveV0Package.t.sol` contain 33 passing Foundry tests. +- `tests/RootfieldRegistry.t.sol` and `tests/LiveV0Package.t.sol` contain 35 passing Foundry tests. - `tests/README.md` documents the current test command. +- `contracts/STATIC_ANALYSIS.md`, `contracts/DEPLOYMENT_BOUNDARY.md`, and `contracts/ACCESS_CONTROL_REVIEW.md` define the current hardening, deployment, and access-control boundaries. +- `infra/scripts/contracts-static-analysis.ps1` and `infra/scripts/contracts-static-analysis.sh` run the contract hardening baseline. Slither is optional by default and required only when explicitly requested. Crypto foundation: @@ -45,9 +47,11 @@ Crypto foundation: Indexer/verifier local package: - `services/shared/`, `services/indexer/`, and `services/verifier/` contain fixture-first local packages. -- The local services test suite currently has 24 passing tests. +- The local services test suite currently has 30 passing tests. - `npm run e2e` currently indexes 7 observations, writes 6 cursors, rejects 2 logs, tracks 1 duplicate, and produces 7 verifier reports. - The verifier uses local fixture evidence only. It is not a production verifier network. +- `npm run index:base-sepolia -- --rpc-url --address --from-block --to-block ` provides a constrained Base Sepolia reader path. +- The Base Sepolia reader requires an explicit RPC URL, rejects non-Base-Sepolia chain ids, and persists both canonical state and a durable checkpoint without storing RPC URLs or keys. Dashboard V0: @@ -66,6 +70,7 @@ Launch-core integration: - Generated RootflowTransitions include `contractEventRef` so reviewers and dashboards can trace each transition back to the contract event that produced the MemorySignal. - `services/flowmemory/src/status.ts` implements the explicit verifier-to-Flow-Memory status adapter: `valid` -> `verified`, `invalid` -> `failed`, `unresolved` -> `unresolved`, `unsupported` -> `unsupported`, `reorged` -> `reorged`. - `.github/workflows/ci.yml` now includes area jobs for contracts, services/launch core, crypto, dashboard, devnet, and hardware. +- CI repository hygiene now runs `node infra/scripts/check-unsafe-claims.mjs` to block unsafe positive production, mainnet, free-storage, trustless-verifier, ISP-replacement, and AI-on-chain claims in README/docs/marketing surfaces. Local no-value devnet prototype: @@ -102,6 +107,7 @@ Launch-core specifications: - Rich JSON Schema runtime validation with a dedicated validator dependency. - Production indexer or verifier service runtime. - Production persistence layer, production live RPC reader, production APIs, or hosted services. +- Base mainnet reader. - Explorer or hardware console implementation. - FlowRouter firmware, manufacturing, final enclosure work, or field deployment. - Real Meshtastic or LoRa device integration. @@ -129,6 +135,7 @@ Recently merged PRs: - #61 Indexer/verifier V0 fixture package. - #62 Dashboard V0. - #68 Launch-core FlowMemory V0 integration. +- #69 Contract event spine for launch-core Flow Memory objects. ## Active Local Work @@ -161,9 +168,9 @@ Before assigning agents, check for dirty worktrees and avoid overlapping folders 1. Keep the generated launch-core command stable in CI. 2. Add runtime schema validation and fixture diff guardrails before live services. -3. Finish contracts hardening without production deployment or token mechanics. -4. Keep dashboard work fixture-backed until a production API is explicitly scoped. -5. Keep chain/appchain work no-value and local until explicit gates are passed. +3. Exercise the Base Sepolia reader path on explicit testnet contract addresses only. +4. Continue contracts hardening without production deployment or token mechanics. +5. Keep dashboard work fixture-backed until a production API is explicitly scoped. ## Update Rule diff --git a/docs/DECISIONS/2026-05-13-base-sepolia-reader-and-claim-guardrails.md b/docs/DECISIONS/2026-05-13-base-sepolia-reader-and-claim-guardrails.md new file mode 100644 index 00000000..c8ebb59f --- /dev/null +++ b/docs/DECISIONS/2026-05-13-base-sepolia-reader-and-claim-guardrails.md @@ -0,0 +1,35 @@ +# Decision: Base Sepolia Reader And Claim Guardrails + +Date: 2026-05-13 + +## Status + +Accepted for V0 hardening. + +## Context + +FlowMemory needs a launch-critical path beyond fixtures, but it must not jump to production-mainnet claims. The next useful step is a constrained Base Sepolia reader that can observe FlowPulse logs from explicit contract addresses and persist durable local state. + +The project also needs stronger protection against unsafe launch language as docs and marketing work accelerate. + +## Decision + +- Add a Base Sepolia-only FlowPulse reader path in `services/indexer`. +- Require an explicit RPC URL and explicit emitting contract addresses. +- Reject RPC endpoints unless `eth_chainId` is Base Sepolia (`84532`). +- Persist both canonical indexer state and a Base Sepolia checkpoint. +- Add contracts hardening docs for static analysis, deployment boundary, and access-control review. +- Add CI claim guardrails that scan `README.md`, `docs/`, and `marketing/` if present. + +## Non-Goals + +- No Base mainnet reader by default. +- No production indexer service. +- No production verifier network. +- No production L1 or mainnet readiness claim. +- No free-storage claim. +- No claim that AI runs on-chain. + +## Consequences + +Developers can test the first live reader path against Base Sepolia while keeping production claims blocked. Marketing and README changes now have a CI-backed guardrail for the highest-risk overclaims. diff --git a/docs/INDEXER_VERIFIER_MVP.md b/docs/INDEXER_VERIFIER_MVP.md index 46ac5761..e8005562 100644 --- a/docs/INDEXER_VERIFIER_MVP.md +++ b/docs/INDEXER_VERIFIER_MVP.md @@ -1,6 +1,6 @@ # Indexer Verifier MVP -This document describes the runnable FlowMemory Indexer + Verifier V0 local package. It advances issues #13, #14, #43, #44, #45, #46, #47, #54, and #55 by proving the off-chain path with fixtures, pure functions, local JSON persistence, CLIs, and tests. +This document describes the runnable FlowMemory Indexer + Verifier V0 local package. It advances issues #13, #14, #43, #44, #45, #46, #47, #54, and #55 by proving the off-chain path with fixtures, pure functions, local JSON persistence, CLIs, and tests. It now also includes a constrained Base Sepolia reader path for explicit FlowPulse contract addresses. V0 is non-production. It does not include tokenomics, a verifier network, production RPC deployment, a production database, proof infrastructure, or chain/L1 implementation. @@ -15,11 +15,12 @@ Run from the repository root: ```powershell npm test npm run index:fixtures +npm run index:base-sepolia -- --rpc-url --address --from-block --to-block npm run verify:fixtures npm run e2e ``` -The commands require no secrets and no live RPC. +The fixture commands require no secrets and no live RPC. The Base Sepolia command requires an explicit RPC URL, refuses non-Base-Sepolia chain ids, and does not store the RPC URL in output artifacts. ## FlowPulse Input @@ -81,6 +82,33 @@ Contracts do not know `txHash`, `transactionIndex`, `logIndex`, or final block m Malformed logs are rejected with deterministic reason codes and do not become verifier inputs. +## Base Sepolia Reader Path + +`services/indexer/src/base-sepolia.ts` provides the first live testnet reader path. + +It requires: + +- `--rpc-url` +- one or more `--address` values +- `--from-block` +- `--to-block` + +It enforces: + +- `eth_chainId` must be Base Sepolia (`84532`) +- block values must be explicit decimal or `0x` quantities +- emitting addresses must be explicit EVM addresses +- output files must not contain RPC URLs or private keys + +It writes: + +```text +services/indexer/out/base-sepolia-indexer-state.json +services/indexer/out/base-sepolia-indexer-checkpoint.json +``` + +This is a testnet reader boundary, not a production mainnet indexer. + ## Identity Model `pulseId` is contract-emitted protocol data. It is not the canonical observed-log identity. @@ -232,6 +260,8 @@ Those are future protocol decisions, not part of this local package. ## Handoff Outputs - Dashboard-friendly indexer state: `services/indexer/out/indexer-state.json` +- Base Sepolia reader state: `services/indexer/out/base-sepolia-indexer-state.json` +- Base Sepolia checkpoint: `services/indexer/out/base-sepolia-indexer-checkpoint.json` - Chain/devnet-friendly verifier report fixture: `services/verifier/out/reports.json` - Indexer state JSON schema: `services/indexer/fixtures/indexer-state.schema.json` - Verifier report JSON schema: `services/verifier/fixtures/verification-report.schema.json` @@ -241,8 +271,8 @@ Those are future protocol decisions, not part of this local package. ## Open Questions - What exact artifact canonicalization format should produce `artifactCommitment`? -- What finality depth should a future Base RPC indexer use? -- Should live RPC indexing persist cursors before or after report generation? +- What finality depth should a future production Base RPC indexer use? +- Should live RPC indexing persist cursors before or after report generation in hosted service mode? - Should future attestations use EIP-712, raw digest signatures, or another envelope? - How should dashboards display pulse duplicates versus exact duplicate observations? @@ -251,21 +281,23 @@ Those are future protocol decisions, not part of this local package. What changed: - Added a runnable fixture-first indexer/verifier package with CLIs, persistence, schemas, and tests. +- Added a constrained Base Sepolia reader with durable local state and checkpoint output. - Defined contract `pulseId`, indexer `observationId`, indexer `cursorId`, and verifier `reportId`. - Defined V0 lifecycle states, duplicate behavior, resolver policy boundaries, and report statuses. Why it changed: -- The service layer needs a deterministic off-chain path before live RPC, production storage, verifier networking, or on-chain attestations. +- The service layer needs a deterministic off-chain path and a constrained testnet read path before production storage, verifier networking, or on-chain attestations. Checks: - `npm test` - `npm run index:fixtures` +- `npm run index:base-sepolia -- --rpc-url --address --from-block --to-block ` - `npm run verify:fixtures` - `npm run e2e` Risks and follow-ups: - V0 fixtures are synthetic and do not claim production reorg handling. -- Live RPC, durable database storage, artifact canonicalization, report signing, and attestations need separate scoped issues. +- Durable database storage, artifact canonicalization, report signing, attestations, and production live indexing need separate scoped issues. diff --git a/docs/MARKETING_CLAIMS_GUARDRAILS.md b/docs/MARKETING_CLAIMS_GUARDRAILS.md new file mode 100644 index 00000000..e410aae1 --- /dev/null +++ b/docs/MARKETING_CLAIMS_GUARDRAILS.md @@ -0,0 +1,60 @@ +# Marketing Claims Guardrails + +Status: required for launch copy, README updates, docs, and marketing drafts. + +FlowMemory can have ambitious language. It should not make claims that the current repo cannot support. + +## Strong Claims That Are Allowed + +- FlowMemory turns market and network activity into verifiable memory signals for AI agents. +- FlowPulse events create a public signal spine for roots, receipts, work state, and verifier reports. +- Rootflow models parent/child memory-state transitions. +- Flow Memory V0 exposes agent-facing MemorySignal, MemoryReceipt, RootfieldBundle, AgentMemoryView, and RootflowTransition objects. +- Heavy AI, model, memory, media, and artifact data stays off-chain while commitments and receipts stay on-chain or in signed fixtures. +- Base Sepolia live reading is being built as a testnet reader path. +- FlowRouter and FlowNet are research directions for hardware, cache, mesh signaling, and receipt relay. + +## Claims That Remain Blocked + +- FlowMemory is production-ready. +- FlowMemory is mainnet-ready. +- FlowMemory is a production L1. +- AI runs on-chain. +- Storage is free. +- Transaction hashes store arbitrary AI data. +- The Uniswap v4 hook can know `txHash` or `logIndex` during execution. +- The verifier network is fully trustless. +- FlowRouter replaces ISPs. +- Meshtastic provides normal internet bandwidth. +- Current hardware is manufactured, certified, or field deployed. + +## Required Framing + +Use: + +- local/test V0 +- fixture-backed dashboard +- Base Sepolia reader path +- off-chain verification path +- commitments, receipts, roots, and state transitions +- future appchain/L1 research + +Avoid: + +- production launch +- mainnet launch +- trustless network +- on-chain AI +- free storage +- decentralized ISP replacement +- final hardware + +## CI Enforcement + +The repository hygiene workflow runs: + +```powershell +node infra/scripts/check-unsafe-claims.mjs +``` + +The script scans `README.md`, `docs/`, and `marketing/` if present. It fails on unsafe positive claims unless the line or section clearly marks them as blocked, non-goals, boundaries, or not implemented. diff --git a/docs/PRODUCTION_READINESS_CHECKLIST.md b/docs/PRODUCTION_READINESS_CHECKLIST.md new file mode 100644 index 00000000..efab8670 --- /dev/null +++ b/docs/PRODUCTION_READINESS_CHECKLIST.md @@ -0,0 +1,74 @@ +# Production Readiness Checklist + +Status: blocking checklist. FlowMemory is not production-ready until every relevant gate is complete and reviewed. + +## Launch V0 Reality + +Current launch target: + +- local/test Rootflow V0 and Flow Memory V0 +- fixture-backed dashboard +- Base Sepolia reader path for FlowPulse logs +- contract hardening baseline +- clear claim boundaries + +Current launch target is not: + +- production mainnet +- production L1 +- token launch +- production verifier network +- production Uniswap v4 hook deployment +- decentralized ISP replacement +- free on-chain storage +- AI running on-chain + +## Contract Gates + +- Foundry tests pass. +- `forge fmt --check` passes or a deliberate formatting-normalization PR is complete. +- `forge build` passes. +- Static-analysis baseline script passes. +- Slither findings are captured before any public testnet deployment. +- Deployment boundary is documented in `contracts/DEPLOYMENT_BOUNDARY.md`. +- Access-control boundary is documented in `contracts/ACCESS_CONTROL_REVIEW.md`. +- Event tests cover every launch-critical state transition. +- No private keys, RPC secrets, deployment mnemonics, or live credentials are committed. + +## Indexer And Backend Gates + +- Fixture indexer tests pass. +- Live Base Sepolia reader requires an explicit RPC URL. +- Live reader rejects non-Base Sepolia chain ids. +- Live reader persists deterministic state and a durable checkpoint. +- Base mainnet reads are not enabled by default. +- Reorg, pending, finalized, failed, and removed states remain visible to downstream systems. +- Dashboard fixtures can be regenerated from local outputs. + +## Flow Memory And Rootflow Gates + +- `npm run launch:v0` passes. +- MemorySignal, MemoryReceipt, RootfieldBundle, AgentMemoryView, and RootflowTransition schemas are present. +- Rootflow transitions preserve parent/child linkage. +- Contract-event linkage remains explicit. +- Receipt-only metadata remains indexer-derived. +- Verifier status adapter maps valid and invalid reports into Flow Memory states. + +## Dashboard Gates + +- Dashboard tests pass. +- Dashboard production build passes. +- Dashboard uses generated fixtures until a production API is explicitly scoped. +- Dashboard copy does not imply production deployment, production L1, full trustless verification, free storage, or AI running on-chain. + +## Review Gates + +- CI passes. +- Claim guardrail script passes. +- Source-of-truth docs are updated after merge. +- Open risks are captured in GitHub issues. +- A review agent checks the diff for scope creep and unsafe claims. + +## Go/No-Go Rule + +If any gate fails, the project can still demo local/test V0 behavior, but it must not be described as production-ready or mainnet-ready. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3bd7a1be..e3050099 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,9 +48,10 @@ Status: implemented as a local/test foundation; hardening still active. - Minimal Foundry config and contract tests exist. - `FlowPulse`, `RootfieldRegistry`, hook-adapter scaffold, artifact/cursor/worker/verifier/work registries, receipt verifier, work receipt registry, verifier report registry, and scheduler skeletons exist. -- `forge test` currently runs 33 passing tests. +- `forge test` currently runs 35 passing tests. - FlowPulse v0 and Rootfield URI/log-data decisions are documented. -- Define status lifecycle, ownership/recovery, namespace policy, and static-analysis plan before implementation. +- Static-analysis runner, deployment boundary, and access-control review docs exist. +- Define status lifecycle, ownership/recovery, and namespace policy before expanding deployment scope. - Keep dynamic fees, tokenomics, production deployment, and production hooks out of scope. ### Phase 2: V0 Local Stack @@ -63,7 +64,8 @@ Status: implemented as fixture-first services plus generated launch-core state; - Flow Memory schemas for MemorySignal, MemoryReceipt, RootfieldBundle, and AgentMemoryView exist under `schemas/flowmemory/`. - Generated MemorySignal and RootflowTransition fixtures expose contract-event linkage through `contractEvent` and `contractEventRef`. - Fixture-based parser and reorg-state tests exist in the indexer/verifier packages. -- Define persistence and local RPC reader boundaries only after fixture behavior stabilizes. +- Deterministic persistence exists for fixture state and the constrained Base Sepolia reader checkpoint. +- A Base Sepolia reader path exists for explicit RPC URLs and explicit FlowPulse contract addresses; it rejects non-Base-Sepolia chain ids. - Local devnet smoke-test gates exist as a no-value Rust prototype, without mainnet or production deployment. ### Phase 3: V0 Review/Audit @@ -72,7 +74,8 @@ Status: active. - Define foundation PR review rules. - Add security reporting guidance. -- Add static-analysis planning before claiming audit readiness. +- Enforce claim guardrails in CI for README/docs/marketing surfaces. +- Keep Slither required for audit environments, optional for ordinary local hardening runs until the toolchain is installed. - Enforce allowed-folder and forbidden-folder boundaries. ## Mid-Term Phases @@ -136,7 +139,7 @@ The initial merge sequence has completed for repo OS, contracts foundation, cryp Next merge preference: 1. Runtime schema validation and fixture-diff guardrails. -2. Live RPC indexing boundary after fixture behavior remains stable. +2. Base Sepolia reader soak tests against explicit testnet deployments. 3. Dashboard polish and explorer/hardware-console separation. -4. Static analysis and contract hardening. +4. Static analysis follow-up with Slither installed and findings triaged. 5. Production-gated research only after V0 local acceptance stays green. diff --git a/docs/reviews/PRODUCTION_READINESS_V0_REVIEW.md b/docs/reviews/PRODUCTION_READINESS_V0_REVIEW.md new file mode 100644 index 00000000..032937d7 --- /dev/null +++ b/docs/reviews/PRODUCTION_READINESS_V0_REVIEW.md @@ -0,0 +1,33 @@ +# Production Readiness V0 Review + +Date: 2026-05-13 + +## Result + +FlowMemory is not production-ready. + +The current repo is suitable for local/test V0 development, launch-core demos, fixture-backed dashboard review, contract hardening, and a constrained Base Sepolia reader path. + +## Evidence Added + +- `docs/PRODUCTION_READINESS_CHECKLIST.md` defines the blocking readiness gates. +- `docs/MARKETING_CLAIMS_GUARDRAILS.md` defines allowed and blocked launch copy. +- `infra/scripts/check-unsafe-claims.mjs` scans README/docs/marketing claim surfaces. +- CI repository hygiene now runs the claim guardrail script. +- `contracts/STATIC_ANALYSIS.md` and hardening scripts define the contracts baseline. +- `contracts/DEPLOYMENT_BOUNDARY.md` blocks production-mainnet and unsafe deployment claims. +- `contracts/ACCESS_CONTROL_REVIEW.md` records V0 ownership and authorization boundaries. + +## Still Blocking Production Language + +- no production deployment automation +- no production verifier network +- no production indexer or API service +- no production Uniswap v4 hook deployment +- no token, reward, fee, staking, or slashing mechanics +- no production L1 or appchain implementation +- no manufactured or field-deployed hardware + +## Review Rule + +Any PR that changes README, docs, or marketing copy must pass the claim guardrail script and should be reviewed against `docs/MARKETING_CLAIMS_GUARDRAILS.md`. diff --git a/infra/scripts/check-unsafe-claims.mjs b/infra/scripts/check-unsafe-claims.mjs new file mode 100755 index 00000000..74f28d86 --- /dev/null +++ b/infra/scripts/check-unsafe-claims.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const root = process.cwd(); +const scanRoots = ["README.md", "docs", "marketing"].filter((entry) => existsSync(join(root, entry))); + +const forbiddenClaims = [ + { name: "production-ready", pattern: /\bproduction[- ]ready\b/i }, + { name: "mainnet-ready", pattern: /\bmainnet[- ]ready\b/i }, + { name: "production L1", pattern: /\bproduction\s+L1\b/i }, + { name: "free storage", pattern: /\bfree\s+storage\b|\bstorage\s+is\s+free\b/i }, + { name: "AI on-chain", pattern: /\bAI\s+(runs|running)\s+on[- ]chain\b|\bon[- ]chain\s+AI\b/i }, + { name: "fully trustless", pattern: /\bfully\s+trustless\b|\bfull\s+trustless\b/i }, + { name: "ISP replacement", pattern: /\breplaces?\s+ISPs?\b/i }, + { name: "normal internet bandwidth", pattern: /\bnormal\s+internet\s+bandwidth\b/i }, +]; + +const allowedLineContext = + /\b(not|no|never|cannot|can't|do not|does not|without|blocked|forbid|forbidden|avoid|out of scope|non-goal|non-goals|boundary|boundaries|guardrail|guardrails|unsafe claim|not allowed|later gated|blocked until|must not|remain blocked)\b/i; +const allowedHeadingContext = + /\b(not|non-goal|non-goals|blocked|guardrail|guardrails|boundary|boundaries|out of scope|conceptual|not implemented|later gated|do not|unsafe|what not to claim|avoid)\b/i; +const startsGuardedList = + /\b(not allowed|claims that remain blocked|current launch target is not|reject or send back|stop and ask|what not to claim|avoid)\b/i; + +function listFiles(entry) { + const path = join(root, entry); + if (statSync(path).isFile()) { + return [path]; + } + + const files = []; + const stack = [path]; + while (stack.length > 0) { + const current = stack.pop(); + for (const child of readdirSync(current, { withFileTypes: true })) { + const childPath = join(current, child.name); + if (child.isDirectory()) { + stack.push(childPath); + } else if (/\.(md|mdx|txt)$/i.test(child.name)) { + files.push(childPath); + } + } + } + return files; +} + +const violations = []; +for (const file of scanRoots.flatMap(listFiles)) { + const rel = relative(root, file).replaceAll("\\", "/"); + const lines = readFileSync(file, "utf8").split(/\r?\n/); + let headingAllowsForbiddenClaims = false; + let guardedListLinesRemaining = 0; + + lines.forEach((line, index) => { + if (/^#{1,6}\s+/.test(line)) { + headingAllowsForbiddenClaims = allowedHeadingContext.test(line); + } + if (allowedHeadingContext.test(line) || startsGuardedList.test(line)) { + guardedListLinesRemaining = 25; + } + + for (const claim of forbiddenClaims) { + if (!claim.pattern.test(line)) { + continue; + } + if (allowedLineContext.test(line) || headingAllowsForbiddenClaims || guardedListLinesRemaining > 0) { + continue; + } + violations.push(`${rel}:${index + 1}: ${claim.name}: ${line.trim()}`); + } + + if (guardedListLinesRemaining > 0) { + guardedListLinesRemaining -= 1; + } + }); +} + +if (violations.length > 0) { + console.error("Unsafe FlowMemory launch claims found:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error("Rewrite the claim with an explicit boundary, or move it under a guardrail/non-goal section."); + process.exit(1); +} + +console.log(`Checked launch claims in ${scanRoots.join(", ")}.`); diff --git a/infra/scripts/contracts-static-analysis.ps1 b/infra/scripts/contracts-static-analysis.ps1 new file mode 100644 index 00000000..c3586cc9 --- /dev/null +++ b/infra/scripts/contracts-static-analysis.ps1 @@ -0,0 +1,37 @@ +param( + [switch]$CheckFormat, + [switch]$RequireSlither +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Command forge -ErrorAction SilentlyContinue)) { + throw "forge is required for contract hardening checks" +} + +if ($CheckFormat) { + forge fmt --check + if ($LASTEXITCODE -ne 0) { + throw "forge fmt --check failed" + } +} +forge build +if ($LASTEXITCODE -ne 0) { + throw "forge build failed" +} +forge test +if ($LASTEXITCODE -ne 0) { + throw "forge test failed" +} + +$slither = Get-Command slither -ErrorAction SilentlyContinue +if ($slither) { + slither . --config-file .slither.config.json + if ($LASTEXITCODE -ne 0) { + throw "slither failed" + } +} elseif ($RequireSlither) { + throw "slither is required but was not found on PATH" +} else { + Write-Warning "slither was not found on PATH; install slither-analyzer or rerun with -RequireSlither in audit environments" +} diff --git a/infra/scripts/contracts-static-analysis.sh b/infra/scripts/contracts-static-analysis.sh new file mode 100755 index 00000000..680c7ada --- /dev/null +++ b/infra/scripts/contracts-static-analysis.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +REQUIRE_SLITHER="${REQUIRE_SLITHER:-0}" +CHECK_FORGE_FMT="${CHECK_FORGE_FMT:-0}" + +if ! command -v forge >/dev/null 2>&1; then + echo "forge is required for contract hardening checks" >&2 + exit 1 +fi + +if [ "$CHECK_FORGE_FMT" = "1" ]; then + forge fmt --check +fi +forge build +forge test + +if command -v slither >/dev/null 2>&1; then + slither . --config-file .slither.config.json +elif [ "$REQUIRE_SLITHER" = "1" ]; then + echo "slither is required but was not found on PATH" >&2 + exit 1 +else + echo "warning: slither was not found on PATH; install slither-analyzer or set REQUIRE_SLITHER=1 in audit environments" >&2 +fi diff --git a/package.json b/package.json index 35cca6f2..a7d432ac 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ ], "scripts": { "test": "npm test --prefix services/shared && npm test --prefix services/indexer && npm test --prefix services/verifier && npm test --prefix services/flowmemory", + "index:base-sepolia": "npm run index:base-sepolia --prefix services/indexer", "index:fixtures": "npm run index:fixtures --prefix services/indexer", "verify:fixtures": "npm run verify:fixtures --prefix services/verifier", "flowmemory:generate": "npm run generate --prefix services/flowmemory", diff --git a/services/indexer/README.md b/services/indexer/README.md index 88ec31fa..3903eaba 100644 --- a/services/indexer/README.md +++ b/services/indexer/README.md @@ -8,6 +8,7 @@ From the repository root: ```powershell npm run index:fixtures +npm run index:base-sepolia -- --rpc-url --address --from-block --to-block npm run demo:indexer npm test --prefix services/indexer ``` @@ -24,6 +25,19 @@ Use a custom output path: npm run index:fixtures -- --out out/custom-state.json ``` +`npm run index:base-sepolia` writes: + +```text +services/indexer/out/base-sepolia-indexer-state.json +services/indexer/out/base-sepolia-indexer-checkpoint.json +``` + +Use custom output paths: + +```powershell +npm run index:base-sepolia -- --rpc-url --address --from-block 123456 --to-block 123999 --finalized-block 123900 --out out/base-sepolia-state.json --checkpoint-out out/base-sepolia-checkpoint.json +``` + ## Fixtures Primary receipt fixtures: @@ -154,14 +168,24 @@ flowmemory.indexer.state.v0 JSON output is deterministic and contains observations, cursors, batches, rootfields, pulses, rejected logs, and duplicate records. +The Base Sepolia reader also writes a durable checkpoint: + +```text +flowmemory.indexer.base_sepolia_checkpoint.v0 +``` + +The checkpoint records the network, chain id, emitting addresses, scan range, finality threshold, state path, counts, and latest indexed block. It intentionally does not store RPC URLs or private keys. + The JSON schema fixture lives at: ```text services/indexer/fixtures/indexer-state.schema.json ``` -## Local RPC Boundary +## RPC Boundary `readLocalRpcFlowPulseLogs` maps explicit JSON-RPC responses into the same raw fixture shape. It has no default RPC URL, no env file, no secrets, and tests use mocked fetch responses. Future live RPC indexing should be handled by a separate scoped issue. +`readBaseSepoliaFlowPulseLogs` is the current live reader boundary. It requires an explicit RPC URL and refuses endpoints unless `eth_chainId` returns Base Sepolia (`84532`). It is not a Base mainnet reader and does not make production-mainnet readiness claims. + See [docs/INDEXER_VERIFIER_MVP.md](../../docs/INDEXER_VERIFIER_MVP.md) for the full pipeline. diff --git a/services/indexer/fixtures/indexer-state.schema.json b/services/indexer/fixtures/indexer-state.schema.json index ee885d1b..2ffe3c0b 100644 --- a/services/indexer/fixtures/indexer-state.schema.json +++ b/services/indexer/fixtures/indexer-state.schema.json @@ -20,7 +20,7 @@ ], "properties": { "schema": { "const": "flowmemory.indexer.state.v0" }, - "source": { "enum": ["fixture", "local-rpc-placeholder"] }, + "source": { "enum": ["fixture", "local-rpc-placeholder", "base-sepolia-rpc"] }, "observations": { "type": "array" }, "pulses": { "type": "array" }, "rootfields": { "type": "array" }, diff --git a/services/indexer/package.json b/services/indexer/package.json index 06da877b..2ceedfd9 100644 --- a/services/indexer/package.json +++ b/services/indexer/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "demo": "node src/demo.ts", + "index:base-sepolia": "node src/base-sepolia.ts", "index:fixtures": "node src/index-fixtures.ts", "test": "node --test test/*.test.ts" } diff --git a/services/indexer/src/base-sepolia.ts b/services/indexer/src/base-sepolia.ts new file mode 100644 index 00000000..d45d2952 --- /dev/null +++ b/services/indexer/src/base-sepolia.ts @@ -0,0 +1,213 @@ +import { resolve } from "node:path"; + +import { indexFlowPulseLogs, type IndexerState } from "./indexer.ts"; +import { + baseSepoliaIndexerCheckpoint, + type BaseSepoliaIndexerCheckpoint, + writeBaseSepoliaIndexerCheckpoint, + writeIndexerState, +} from "./persistence.ts"; +import { BASE_SEPOLIA_CHAIN_ID, readBaseSepoliaFlowPulseLogs } from "./rpc.ts"; + +export interface BaseSepoliaReaderOptions { + rpcUrl: string; + addresses: string[]; + fromBlock: string; + toBlock: string; + outPath?: string; + checkpointPath?: string; + finalizedBlockNumber?: string; + generatedAt?: string; + fetchImpl?: typeof fetch; +} + +export interface BaseSepoliaReaderResult { + state: IndexerState; + checkpoint: BaseSepoliaIndexerCheckpoint; + statePath: string; + checkpointPath: string; +} + +interface CliOptions extends BaseSepoliaReaderOptions { + outPath: string; + checkpointPath: string; +} + +function normalizeAddress(address: string): string { + const normalized = address.trim().toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(normalized)) { + throw new Error(`invalid EVM address: ${address}`); + } + return normalized; +} + +function normalizeAddresses(addresses: string[]): string[] { + const normalized = addresses.flatMap((entry) => entry.split(",")).map(normalizeAddress); + const unique = [...new Set(normalized)].sort((left, right) => left.localeCompare(right)); + if (unique.length === 0) { + throw new Error("at least one FlowPulse contract address is required"); + } + return unique; +} + +export function blockArgumentToDecimalString(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (/^0x[0-9a-f]+$/.test(trimmed)) { + return BigInt(trimmed).toString(); + } + if (/^[0-9]+$/.test(trimmed)) { + return BigInt(trimmed).toString(); + } + throw new Error(`block value must be a decimal or 0x quantity, received: ${value}`); +} + +export function blockArgumentToRpcQuantity(value: string): string { + return `0x${BigInt(blockArgumentToDecimalString(value)).toString(16)}`; +} + +function readArgValue(args: string[], index: number, name: string): string { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +export function parseBaseSepoliaReaderArgs(args: string[]): CliOptions { + let rpcUrl = ""; + let fromBlock = ""; + let toBlock = ""; + let finalizedBlockNumber: string | undefined; + const addresses: string[] = []; + let outPath = "out/base-sepolia-indexer-state.json"; + let checkpointPath = "out/base-sepolia-indexer-checkpoint.json"; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--rpc-url") { + rpcUrl = readArgValue(args, index, arg); + index += 1; + } else if (arg === "--address" || arg === "--addresses") { + addresses.push(readArgValue(args, index, arg)); + index += 1; + } else if (arg === "--from-block") { + fromBlock = readArgValue(args, index, arg); + index += 1; + } else if (arg === "--to-block") { + toBlock = readArgValue(args, index, arg); + index += 1; + } else if (arg === "--finalized-block") { + finalizedBlockNumber = blockArgumentToDecimalString(readArgValue(args, index, arg)); + index += 1; + } else if (arg === "--out") { + outPath = readArgValue(args, index, arg); + index += 1; + } else if (arg === "--checkpoint-out") { + checkpointPath = readArgValue(args, index, arg); + index += 1; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (rpcUrl.trim() === "") { + throw new Error("--rpc-url is required; FlowMemory does not ship a default RPC endpoint"); + } + if (fromBlock.trim() === "") { + throw new Error("--from-block is required"); + } + if (toBlock.trim() === "") { + throw new Error("--to-block is required"); + } + + return { + rpcUrl, + addresses: normalizeAddresses(addresses), + fromBlock: blockArgumentToDecimalString(fromBlock), + toBlock: blockArgumentToDecimalString(toBlock), + finalizedBlockNumber, + outPath, + checkpointPath, + }; +} + +export async function runBaseSepoliaReader(options: BaseSepoliaReaderOptions): Promise { + const addresses = normalizeAddresses(options.addresses); + const fromBlock = blockArgumentToDecimalString(options.fromBlock); + const toBlock = blockArgumentToDecimalString(options.toBlock); + const outPath = resolve(options.outPath ?? "out/base-sepolia-indexer-state.json"); + const checkpointPath = resolve(options.checkpointPath ?? "out/base-sepolia-indexer-checkpoint.json"); + + if (BigInt(toBlock) < BigInt(fromBlock)) { + throw new Error("--to-block must be greater than or equal to --from-block"); + } + + const readResult = await readBaseSepoliaFlowPulseLogs({ + rpcUrl: options.rpcUrl, + addresses, + fromBlock: blockArgumentToRpcQuantity(fromBlock), + toBlock: blockArgumentToRpcQuantity(toBlock), + fetchImpl: options.fetchImpl, + }); + + const finalizedBlockNumber = options.finalizedBlockNumber === undefined + ? undefined + : blockArgumentToDecimalString(options.finalizedBlockNumber); + + const state = indexFlowPulseLogs(readResult.logs, { + chainId: BASE_SEPOLIA_CHAIN_ID, + finalizedBlockNumber, + source: "base-sepolia-rpc", + sourceAddresses: addresses, + }); + const checkpoint = baseSepoliaIndexerCheckpoint({ + addresses, + fromBlock, + toBlock, + finalizedBlockNumber, + statePath: outPath, + state, + generatedAt: options.generatedAt, + }); + + writeIndexerState(outPath, state); + writeBaseSepoliaIndexerCheckpoint(checkpointPath, checkpoint); + + return { + state, + checkpoint, + statePath: outPath, + checkpointPath, + }; +} + +function usage(): string { + return [ + "Usage:", + " node src/base-sepolia.ts --rpc-url --address <0x...> --from-block --to-block [--finalized-block ] [--out ] [--checkpoint-out ]", + "", + "Boundary:", + ` This reader only accepts Base Sepolia chainId ${BASE_SEPOLIA_CHAIN_ID}. It does not read Base mainnet.`, + ].join("\n"); +} + +if (process.argv[1]?.replaceAll("\\", "/").endsWith("/base-sepolia.ts")) { + runBaseSepoliaReader(parseBaseSepoliaReaderArgs(process.argv.slice(2))) + .then((result) => { + console.log(JSON.stringify({ + schema: "flowmemory.indexer.base_sepolia_reader_summary.v0", + network: result.checkpoint.network, + chainId: result.checkpoint.chainId, + statePath: result.statePath, + checkpointPath: result.checkpointPath, + observationCount: result.checkpoint.observationCount, + rejectedLogCount: result.checkpoint.rejectedLogCount, + lastIndexedBlock: result.checkpoint.lastIndexedBlock, + }, null, 2)); + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : error); + console.error(usage()); + process.exitCode = 1; + }); +} diff --git a/services/indexer/src/indexer.ts b/services/indexer/src/indexer.ts index 031afd2a..0a36db76 100644 --- a/services/indexer/src/indexer.ts +++ b/services/indexer/src/indexer.ts @@ -43,7 +43,7 @@ export interface IndexedCursor { export interface IndexedBatch { schema: "flowmemory.indexer.batch.v0"; - source: "fixture" | "local-rpc-placeholder"; + source: IndexerStateSource; sourceSetId: string; observationCount: number; cursorCount: number; @@ -71,9 +71,11 @@ export interface IndexedRootfield { pulseCount: number; } +export type IndexerStateSource = "fixture" | "local-rpc-placeholder" | "base-sepolia-rpc"; + export interface IndexerState { schema: "flowmemory.indexer.state.v0"; - source: "fixture" | "local-rpc-placeholder"; + source: IndexerStateSource; observations: IndexedObservation[]; pulses: IndexedPulse[]; rootfields: IndexedRootfield[]; @@ -90,6 +92,8 @@ export interface IndexerState { export interface IndexerStateOptions { finalizedBlockNumber?: string | number | bigint; canonicalBlockHashes?: Record; + chainId?: string; + source?: IndexerStateSource; sourceAddresses?: string[]; } @@ -170,8 +174,9 @@ export function indexFlowPulseLogs(logs: RawFlowPulseLogFixture[], options: Inde const cursors = new Map(); const rejectedLogs: IndexRejectedLog[] = []; const duplicates: IndexerState["duplicates"] = []; + const source = options.source ?? "fixture"; const sourceAddresses = options.sourceAddresses ?? logs.map((log) => log.address); - const sourceSetId = deriveSourceSetId(logs[0]?.chainId ?? "0", sourceAddresses); + const sourceSetId = deriveSourceSetId(logs[0]?.chainId ?? options.chainId ?? "0", sourceAddresses); for (const log of logs) { if (log.receiptStatus !== "success") { @@ -259,10 +264,10 @@ export function indexFlowPulseLogs(logs: RawFlowPulseLogFixture[], options: Inde return { schema: "flowmemory.indexer.state.v0", - source: "fixture", + source, batches: [{ schema: "flowmemory.indexer.batch.v0", - source: "fixture", + source, sourceSetId, observationCount: observations.length, cursorCount: cursors.size, diff --git a/services/indexer/src/persistence.ts b/services/indexer/src/persistence.ts index e2ed0bc3..765d5138 100644 --- a/services/indexer/src/persistence.ts +++ b/services/indexer/src/persistence.ts @@ -9,6 +9,24 @@ export interface PersistedIndexerState { state: IndexerState; } +export interface BaseSepoliaIndexerCheckpoint { + schema: "flowmemory.indexer.base_sepolia_checkpoint.v0"; + network: "base-sepolia"; + chainId: "84532"; + source: "base-sepolia-rpc"; + addresses: string[]; + fromBlock: string; + toBlock: string; + finalizedBlockNumber?: string; + statePath: string; + observationCount: number; + cursorCount: number; + rejectedLogCount: number; + duplicateCount: number; + lastIndexedBlock: string; + generatedAt: string; +} + export function persistedIndexerState(state: IndexerState): PersistedIndexerState { return { schema: "flowmemory.indexer.persistence.v0", @@ -16,6 +34,38 @@ export function persistedIndexerState(state: IndexerState): PersistedIndexerStat }; } +export function baseSepoliaIndexerCheckpoint(input: { + addresses: string[]; + fromBlock: string; + toBlock: string; + finalizedBlockNumber?: string; + statePath: string; + state: IndexerState; + generatedAt?: string; +}): BaseSepoliaIndexerCheckpoint { + const lastIndexedBlock = input.state.cursors.reduce((latest, cursor) => { + return BigInt(cursor.blockNumber) > BigInt(latest) ? cursor.blockNumber : latest; + }, input.fromBlock); + + return { + schema: "flowmemory.indexer.base_sepolia_checkpoint.v0", + network: "base-sepolia", + chainId: "84532", + source: "base-sepolia-rpc", + addresses: [...input.addresses].sort((left, right) => left.localeCompare(right)), + fromBlock: input.fromBlock, + toBlock: input.toBlock, + finalizedBlockNumber: input.finalizedBlockNumber, + statePath: input.statePath, + observationCount: input.state.observations.length, + cursorCount: input.state.cursors.length, + rejectedLogCount: input.state.rejectedLogs.length, + duplicateCount: input.state.duplicates.length, + lastIndexedBlock, + generatedAt: input.generatedAt ?? new Date().toISOString(), + }; +} + export function writeIndexerState(path: string, state: IndexerState): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${canonicalJson(persistedIndexerState(state))}\n`, "utf8"); @@ -24,3 +74,12 @@ export function writeIndexerState(path: string, state: IndexerState): void { export function readIndexerState(path: string): PersistedIndexerState { return JSON.parse(readFileSync(path, "utf8")) as PersistedIndexerState; } + +export function writeBaseSepoliaIndexerCheckpoint(path: string, checkpoint: BaseSepoliaIndexerCheckpoint): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${canonicalJson(checkpoint)}\n`, "utf8"); +} + +export function readBaseSepoliaIndexerCheckpoint(path: string): BaseSepoliaIndexerCheckpoint { + return JSON.parse(readFileSync(path, "utf8")) as BaseSepoliaIndexerCheckpoint; +} diff --git a/services/indexer/src/rpc.ts b/services/indexer/src/rpc.ts index 307286f6..487b133f 100644 --- a/services/indexer/src/rpc.ts +++ b/services/indexer/src/rpc.ts @@ -1,5 +1,7 @@ import { FLOWPULSE_EVENT_TOPIC0, type RawFlowPulseLogFixture } from "../../shared/src/index.ts"; +export const BASE_SEPOLIA_CHAIN_ID = "84532"; + export interface LocalRpcReadOptions { rpcUrl: string; addresses: string[]; @@ -8,6 +10,11 @@ export interface LocalRpcReadOptions { fetchImpl?: typeof fetch; } +export interface RpcFlowPulseReadResult { + chainId: string; + logs: RawFlowPulseLogFixture[]; +} + interface JsonRpcResponse { jsonrpc: "2.0"; id: number; @@ -67,10 +74,16 @@ async function rpc(fetchImpl: typeof fetch, rpcUrl: string, method: string, p return payload.result; } -export async function readLocalRpcFlowPulseLogs(options: LocalRpcReadOptions): Promise { - const fetchImpl = options.fetchImpl ?? fetch; - const chainIdQuantity = await rpc(fetchImpl, options.rpcUrl, "eth_chainId", []); - const chainId = quantityToDecimalString(chainIdQuantity); +async function readRpcChainId(fetchImpl: typeof fetch, rpcUrl: string): Promise { + const chainIdQuantity = await rpc(fetchImpl, rpcUrl, "eth_chainId", []); + return quantityToDecimalString(chainIdQuantity); +} + +async function readRpcFlowPulseLogSetWithChainId( + options: LocalRpcReadOptions, + chainId: string, + fetchImpl: typeof fetch, +): Promise { const logs = await rpc(fetchImpl, options.rpcUrl, "eth_getLogs", [{ address: options.addresses, fromBlock: options.fromBlock, @@ -96,5 +109,27 @@ export async function readLocalRpcFlowPulseLogs(options: LocalRpcReadOptions): P }); } - return rawLogs; + return { + chainId, + logs: rawLogs, + }; +} + +export async function readLocalRpcFlowPulseLogs(options: LocalRpcReadOptions): Promise { + return (await readRpcFlowPulseLogSet(options)).logs; +} + +export async function readRpcFlowPulseLogSet(options: LocalRpcReadOptions): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const chainId = await readRpcChainId(fetchImpl, options.rpcUrl); + return readRpcFlowPulseLogSetWithChainId(options, chainId, fetchImpl); +} + +export async function readBaseSepoliaFlowPulseLogs(options: LocalRpcReadOptions): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const chainId = await readRpcChainId(fetchImpl, options.rpcUrl); + if (chainId !== BASE_SEPOLIA_CHAIN_ID) { + throw new Error(`expected Base Sepolia chainId ${BASE_SEPOLIA_CHAIN_ID}, received ${chainId}`); + } + return readRpcFlowPulseLogSetWithChainId(options, chainId, fetchImpl); } diff --git a/services/indexer/test/indexer.test.ts b/services/indexer/test/indexer.test.ts index 3b183a40..5aa046a8 100644 --- a/services/indexer/test/indexer.test.ts +++ b/services/indexer/test/indexer.test.ts @@ -4,10 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { parseBaseSepoliaReaderArgs, runBaseSepoliaReader } from "../src/base-sepolia.ts"; import { indexFlowPulseLogs, indexFlowPulseReceipts } from "../src/indexer.ts"; import { loadIndexerFixtureLogs, loadIndexerFixtureReceipts } from "../src/fixtures.ts"; -import { readIndexerState, writeIndexerState } from "../src/persistence.ts"; -import { readLocalRpcFlowPulseLogs } from "../src/rpc.ts"; +import { readBaseSepoliaIndexerCheckpoint, readIndexerState, writeIndexerState } from "../src/persistence.ts"; +import { readBaseSepoliaFlowPulseLogs, readLocalRpcFlowPulseLogs } from "../src/rpc.ts"; test("indexes FlowPulse fixture logs into canonical observations", () => { const state = indexFlowPulseLogs(loadIndexerFixtureLogs()); @@ -137,3 +138,117 @@ test("maps mocked local RPC logs into raw FlowPulse fixtures without secrets", a assert.equal(logs[0].receiptStatus, "success"); assert.equal(indexFlowPulseLogs(logs).observations[0].observationId, "0x9d958aadf8bf46f989b51e541709a73d21970e7e79643f939c9a0000b50f9a91"); }); + +test("rejects non Base Sepolia RPC endpoints for live reads", async () => { + const [fixtureLog] = loadIndexerFixtureLogs(); + const calls: string[] = []; + const fetchImpl = async (_url: string, init?: RequestInit): Promise => { + const body = JSON.parse(String(init?.body)) as { method: string }; + calls.push(body.method); + if (body.method === "eth_chainId") { + return Response.json({ jsonrpc: "2.0", id: 1, result: "0x1" }); + } + if (body.method === "eth_getLogs") { + return Response.json({ jsonrpc: "2.0", id: 1, result: [] }); + } + return Response.json({ jsonrpc: "2.0", id: 1, error: { code: -32601, message: "not found" } }); + }; + + await assert.rejects( + () => readBaseSepoliaFlowPulseLogs({ + rpcUrl: "https://example.invalid", + addresses: [fixtureLog.address], + fromBlock: "0x1", + toBlock: "0x2", + fetchImpl, + }), + /expected Base Sepolia chainId 84532, received 1/, + ); + assert.deepEqual(calls, ["eth_chainId"]); +}); + +test("runs Base Sepolia reader and persists durable state plus checkpoint", async () => { + const [fixtureLog] = loadIndexerFixtureLogs(); + const dir = mkdtempSync(join(tmpdir(), "flowmemory-base-sepolia-")); + const statePath = join(dir, "state.json"); + const checkpointPath = join(dir, "checkpoint.json"); + const fetchImpl = async (_url: string, init?: RequestInit): Promise => { + const body = JSON.parse(String(init?.body)) as { method: string }; + if (body.method === "eth_chainId") { + return Response.json({ jsonrpc: "2.0", id: 1, result: "0x14a34" }); + } + if (body.method === "eth_getLogs") { + return Response.json({ + jsonrpc: "2.0", + id: 1, + result: [{ + address: fixtureLog.address, + topics: fixtureLog.topics, + data: fixtureLog.data, + blockNumber: "0x1e240", + blockHash: fixtureLog.blockHash, + transactionHash: fixtureLog.transactionHash, + transactionIndex: "0x7", + logIndex: "0x2", + }], + }); + } + if (body.method === "eth_getTransactionReceipt") { + return Response.json({ jsonrpc: "2.0", id: 1, result: { status: "0x1" } }); + } + return Response.json({ jsonrpc: "2.0", id: 1, error: { code: -32601, message: "not found" } }); + }; + + try { + const result = await runBaseSepoliaReader({ + rpcUrl: "https://example.invalid", + addresses: [fixtureLog.address], + fromBlock: "123456", + toBlock: "123456", + finalizedBlockNumber: "123456", + outPath: statePath, + checkpointPath, + generatedAt: "2026-05-13T00:00:00.000Z", + fetchImpl, + }); + const persisted = readIndexerState(statePath); + const checkpoint = readBaseSepoliaIndexerCheckpoint(checkpointPath); + + assert.equal(result.state.source, "base-sepolia-rpc"); + assert.equal(persisted.state.source, "base-sepolia-rpc"); + assert.equal(persisted.state.observations[0].lifecycleState, "finalized"); + assert.equal(checkpoint.schema, "flowmemory.indexer.base_sepolia_checkpoint.v0"); + assert.equal(checkpoint.network, "base-sepolia"); + assert.equal(checkpoint.chainId, "84532"); + assert.equal(checkpoint.lastIndexedBlock, "123456"); + assert.equal(checkpoint.observationCount, 1); + assert.equal(checkpoint.statePath, statePath); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("parses Base Sepolia reader CLI args without defaulting to a public RPC", () => { + const [fixtureLog] = loadIndexerFixtureLogs(); + const parsed = parseBaseSepoliaReaderArgs([ + "--rpc-url", + "https://example.invalid", + "--address", + fixtureLog.address, + "--from-block", + "0x1e240", + "--to-block", + "123456", + "--finalized-block", + "0x1e240", + ]); + + assert.equal(parsed.fromBlock, "123456"); + assert.equal(parsed.toBlock, "123456"); + assert.equal(parsed.finalizedBlockNumber, "123456"); + assert.equal(parsed.addresses[0], fixtureLog.address.toLowerCase()); + assert.throws( + () => parseBaseSepoliaReaderArgs(["--address", fixtureLog.address, "--from-block", "1", "--to-block", "2"]), + /--rpc-url is required/, + ); +}); diff --git a/tests/RootfieldRegistry.t.sol b/tests/RootfieldRegistry.t.sol index 746c7d6a..f3bbb051 100644 --- a/tests/RootfieldRegistry.t.sol +++ b/tests/RootfieldRegistry.t.sol @@ -30,6 +30,8 @@ contract RootfieldRegistryTest { Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); bytes32 private constant FLOWPULSE_SIGNATURE = keccak256("FlowPulse(bytes32,bytes32,address,uint8,bytes32,bytes32,bytes32,uint64,uint64,string)"); + bytes32 private constant OWNERSHIP_TRANSFERRED_SIGNATURE = + keccak256("RootfieldOwnershipTransferred(bytes32,address,address,string)"); RootfieldRegistry private registry; @@ -296,6 +298,50 @@ contract RootfieldRegistryTest { _assertTrue(rootfield.pulseCount == 3); } + function testTransferRootfieldOwnershipEmitsStatusPulseAndOwnershipEvent() public { + bytes32 rootfieldId = keccak256("rootfield.transfer.events"); + RootfieldRegistryCaller newOwner = new RootfieldRegistryCaller(); + registry.registerRootfield(rootfieldId, keccak256("schema.v0"), keccak256("metadata"), ""); + + vm.recordLogs(); + bytes32 pulseId = registry.transferRootfieldOwnership(rootfieldId, address(newOwner), "rootfield://transfer"); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + _assertTrue(logs.length == 2); + _assertTrue(logs[0].emitter == address(registry)); + _assertTrue(logs[0].topics[0] == FLOWPULSE_SIGNATURE); + _assertTrue(logs[0].topics[1] == pulseId); + _assertTrue(logs[0].topics[2] == rootfieldId); + _assertTrue(logs[0].topics[3] == bytes32(uint256(uint160(address(this))))); + + ( + uint8 pulseType, + bytes32 subject, + bytes32 commitment, + bytes32 parentPulseId, + uint64 sequence, + uint64 occurredAt, + string memory uri + ) = abi.decode(logs[0].data, (uint8, bytes32, bytes32, bytes32, uint64, uint64, string)); + + _assertTrue(pulseType == 3); + _assertTrue(subject == rootfieldId); + _assertTrue(commitment == keccak256(abi.encode(address(this), address(newOwner)))); + _assertTrue(parentPulseId == bytes32(0)); + _assertTrue(sequence == 2); + _assertTrue(occurredAt > 0); + _assertTrue(keccak256(bytes(uri)) == keccak256("rootfield://transfer")); + + _assertTrue(logs[1].emitter == address(registry)); + _assertTrue(logs[1].topics[0] == OWNERSHIP_TRANSFERRED_SIGNATURE); + _assertTrue(logs[1].topics[1] == rootfieldId); + _assertTrue(logs[1].topics[2] == bytes32(uint256(uint160(address(this))))); + _assertTrue(logs[1].topics[3] == bytes32(uint256(uint160(address(newOwner))))); + + string memory evidenceURI = abi.decode(logs[1].data, (string)); + _assertTrue(keccak256(bytes(evidenceURI)) == keccak256("rootfield://transfer")); + } + function testCannotTransferRootfieldToZeroOwner() public { bytes32 rootfieldId = keccak256("rootfield.transfer.zero"); registry.registerRootfield(rootfieldId, keccak256("schema.v0"), keccak256("metadata"), ""); @@ -304,6 +350,17 @@ contract RootfieldRegistryTest { registry.transferRootfieldOwnership(rootfieldId, address(0), ""); } + function testCannotTransferInactiveRootfieldOwnership() public { + bytes32 rootfieldId = keccak256("rootfield.transfer.inactive"); + bytes32 registrationPulseId = + registry.registerRootfield(rootfieldId, keccak256("schema.v0"), keccak256("metadata"), ""); + registry.deactivateRootfield(rootfieldId, registrationPulseId, "rootfield://deactivate"); + RootfieldRegistryCaller newOwner = new RootfieldRegistryCaller(); + + vm.expectRevert(abi.encodeWithSelector(RootfieldRegistry.RootfieldInactive.selector, rootfieldId)); + registry.transferRootfieldOwnership(rootfieldId, address(newOwner), ""); + } + function _assertTrue(bool condition) private pure { if (!condition) { revert AssertionFailed();