From 6f7170e32c56f95342a4d550a0e9384cec389dbc Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 8 Apr 2026 04:21:10 +0700 Subject: [PATCH 01/51] Add explorer NATS publishing and backfill service --- .gitmodules | 3 + src/app/zeko/sequencer/README.md | 100 +++ src/app/zeko/sequencer/explorer/dune | 46 ++ .../explorer/explorer-indexing-plan.md | 324 ++++++++++ .../explorer/explorer_backfill_service.ml | 569 ++++++++++++++++++ src/app/zeko/sequencer/explorer/run.ml | 60 ++ src/app/zeko/sequencer/lib/dune | 2 + src/app/zeko/sequencer/lib/explorer_events.ml | 172 ++++++ src/app/zeko/sequencer/lib/zeko_sequencer.ml | 190 +++++- src/app/zeko/sequencer/run.ml | 9 +- src/lib/nats-ml | 1 + 11 files changed, 1453 insertions(+), 23 deletions(-) create mode 100644 src/app/zeko/sequencer/explorer/dune create mode 100644 src/app/zeko/sequencer/explorer/explorer-indexing-plan.md create mode 100644 src/app/zeko/sequencer/explorer/explorer_backfill_service.ml create mode 100644 src/app/zeko/sequencer/explorer/run.ml create mode 100644 src/app/zeko/sequencer/lib/explorer_events.ml create mode 160000 src/lib/nats-ml diff --git a/.gitmodules b/.gitmodules index d3a6efbf68..2998a52fad 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "src/lib/crypto/kimchi_bindings/stubs/kimchi-stubs-vendors"] path = src/lib/crypto/kimchi_bindings/stubs/kimchi-stubs-vendors url = https://github.com/MinaProtocol/kimchi-stubs-vendors.git +[submodule "src/lib/nats-ml"] + path = src/lib/nats-ml + url = https://github.com/Hebilicious/nats-ml diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index b847cf2d4b..b290ce4928 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -45,16 +45,116 @@ dune exec ./run.exe -- \ --deposit-delay-blocks \ --fee-modifier \ --minimum-fee \ + --nats-url \ --slot-acceptance \ --commit-validity-period ``` +`--nats-url` enables explorer event publishing. Without it, the sequencer keeps +the existing behavior and all NATS publish paths become no-ops. + +When NATS is enabled, the sequencer emits: + +- `zeko.l2.transactions` +- `zeko.l2.finality` +- `zeko.health` + +`zeko.l2.transactions` carries: + +- `kind`: `user_command`, `fee_transfer`, `sync_replay`, or `genesis_replay` +- `target_ledger_hash` +- `genesis` +- `diff` + +`diff` uses the existing `Da_layer.Diff.Stable.V3` JSON shape and every +transaction message includes `Nats-Msg-Id: `. + +`zeko.l2.finality` carries: + +- `status`: `proved` or `committed` +- `source_ledger_hash` +- `target_ledger_hash` +- `timestamp` + +`zeko.health` carries: + +- `component` +- `instance_id` +- `status` +- `timestamp` + Run help to see the options: ```bash dune exec ./run.exe -- --help ``` +## Explorer backfill service + +The backfill service republishes historical DA diffs into the same +`zeko.l2.transactions` NATS subject used by the live sequencer path. + +Build it with: + +```bash +DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer/explorer +``` + +Run it with: + +```bash +export DUNE_PROFILE=devnet +dune exec ./explorer/run.exe -- \ + -p \ + --da-node \ + --nats-url +``` + +The GraphQL HTTP endpoint stays on `/graphql`. +The service requires `--nats-url` because each backfill republishes historical +diffs into NATS. + +Available GraphQL operations: + +- `backfill(fromHash, toHash) -> BackfillJob` +- `backfillJob(id) -> BackfillJob | null` +- `health -> Health` + +The GraphQL-SSE endpoint is `/graphql/stream`. + +Supported subscription: + +- `backfillProgress(id) -> BackfillProgress` + +Use the same GraphQL subscription document against `/graphql/stream`; the +service executes the schema subscription and streams `backfillProgress` +responses as GraphQL-SSE events. + +`BackfillJob` fields: + +- `id` +- `fromHash` +- `toHash` +- `status` +- `diffsPublished` +- `error` +- `createdAt` +- `startedAt` +- `finishedAt` + +`BackfillProgress` fields: + +- `id` +- `status` +- `diffsPublished` +- `error` + +`Health` fields: + +- `ok` +- `instanceId` +- `startedAt` + ## Deploy rollup contract to L1 The following script deploys the rollup contract on the L1 with the initial state, which is the genesis ledger of the rollup. diff --git a/src/app/zeko/sequencer/explorer/dune b/src/app/zeko/sequencer/explorer/dune new file mode 100644 index 0000000000..b7c00ceddf --- /dev/null +++ b/src/app/zeko/sequencer/explorer/dune @@ -0,0 +1,46 @@ +(library + (name explorer_backfill_service) + (inline_tests) + (modules explorer_backfill_service) + (libraries + sequencer_lib + da_layer + zeko_constants + init + cli_lib + ;; mina ;; + mina_base + ;; opam libraries ;; + cohttp + cohttp-async + graphql + graphql-async + uri + core + core_kernel + async + async_unix + ppx_inline_test.config) + (preprocess + (pps ppx_let ppx_jane ppx_mina))) + +(executable + (name run) + (modules run) + (libraries + explorer_backfill_service + sequencer_lib + init + cli_lib + ;; opam libraries ;; + cohttp + cohttp-async + graphql + graphql-async + core + core_kernel + async + async_unix + core_unix.command_unix) + (preprocess + (pps ppx_let ppx_jane ppx_mina))) diff --git a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md new file mode 100644 index 0000000000..a6169500a1 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md @@ -0,0 +1,324 @@ +# Explorer Indexing Plan + +## High Level + +This work adds a first-party NATS publishing path to the Zeko sequencer and a standalone backfill service in this repo. + +When it is done, this repo will be able to: + +- publish L2 transaction events as the sequencer processes diffs +- publish L2 finality events as batches move from proved to committed +- publish health heartbeats for freshness monitoring +- republish historical diff ranges into NATS through a standalone backfill service + +This matters because explorer and indexing systems need a direct, replayable event stream from the sequencer and DA layer, without depending on downstream reconstruction of sequencer state. + +## 1. Integrate `nats-ml` into this repo + +Work: + +- Bring `[nats-ml](https://github.com/Hebilicious/nats-ml)` into this repo from GitHub. +- Wire it into the repo build so sequencer code can depend on it. +- Make it available to both the sequencer and the backfill service. + +Result: + +- NATS publishing depends on a repo-local integration of `nats-ml`, not an external manual install step. + +## 2. Add sequencer NATS configuration + +Target files: + +- `src/app/zeko/sequencer/run.ml` +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` +- `src/app/zeko/sequencer/dune` + +Work: + +- Add `--nats-url` to the sequencer CLI. +- Extend sequencer config and runtime state with an optional NATS client. +- Create the `nats-ml` client at startup when `--nats-url` is present. +- Keep all publishing paths as no-ops when NATS is not configured. + +Result: + +- The sequencer runs unchanged without NATS. +- The sequencer publishes to NATS when configured. + +## 3. Add one shared NATS event module + +Target files: + +- `src/app/zeko/sequencer/lib/` + +Work: + +- Add a small module that owns NATS subjects, headers, and JSON encoding. +- Use this module from both live sequencer publishing and the backfill service. +- Keep the event contract in one place. + +Subjects: + +- `zeko.l2.transactions` +- `zeko.l2.finality` +- `zeko.health` + +Headers: + +- `Nats-Msg-Id: ` on every `zeko.l2.transactions` message + +### `zeko.l2.transactions` schema + +Fields: + +- `kind` + - `user_command` + - `fee_transfer` + - `sync_replay` + - `genesis_replay` +- `target_ledger_hash` +- `genesis` +- `diff` + +`diff` fields: + +- `source_ledger_hash` +- `changed_accounts` +- `command_with_action_step_flags` +- `timestamp` +- `acc_set` + +Encoding rules: + +- `diff` uses the existing JSON shape derived from `Da_layer.Diff.Stable.V3`. +- `target_ledger_hash` is the post-diff ledger hash passed to `Da_layer.Client.enqueue_diff`. +- `genesis` matches the flag passed to `Da_layer.Client.enqueue_diff`. + +### `zeko.l2.finality` schema + +Fields: + +- `status` + - `proved` + - `committed` +- `source_ledger_hash` +- `target_ledger_hash` +- `timestamp` + +Encoding rules: + +- `proved` is emitted from the successful committer result. +- `committed` is emitted after `State.Last_committed_ledger.set`. + +### `zeko.health` schema + +Fields: + +- `component` +- `instance_id` +- `status` +- `timestamp` + +Encoding rules: + +- `component = "sequencer"` +- `status = "ok"` + +Result: + +- This repo has one stable event boundary for explorer ingestion. + +## 4. Publish L2 transaction events from the sequencer + +Target file: + +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` + +Integration points: + +- `apply_user_command` +- `apply_fee_transfer` +- `sync` + +Work: + +- After `Da_layer.Client.enqueue_diff` in `apply_user_command`, publish one `zeko.l2.transactions` message with `kind = user_command`. +- After `Da_layer.Client.enqueue_diff` in `apply_fee_transfer`, publish one `zeko.l2.transactions` message with `kind = fee_transfer`. +- During `sync`, publish replayed diffs after they are re-enqueued. +- Use `kind = genesis_replay` when the replayed diff was enqueued with `genesis = true`. +- Use `kind = sync_replay` for the rest of the replayed diffs. +- Set `Nats-Msg-Id` from `target_ledger_hash`. + +Result: + +- Live traffic and replayed traffic produce the same transaction event format. + +## 5. Publish finality events from the sequencer + +Target file: + +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` + +Integration points: + +- `run_committer` +- `Merger.Commit.process` + +Work: + +- Publish `zeko.l2.finality` with `status = proved` when `run_committer` receives `Some (stmt, _)`. +- Publish `zeko.l2.finality` with `status = committed` after `State.Last_committed_ledger.set` in the commit path. +- Include both source and target ledger hashes in both cases. + +Result: + +- The explorer stack can track proved and committed progress directly from NATS. + +## 6. Publish sequencer health heartbeats + +Target file: + +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` + +Work: + +- Add a periodic heartbeat loop. +- Emit `zeko.health` messages using the shared event module. +- Generate one sequencer instance id at startup and reuse it for all heartbeats from that process. + +Result: + +- The sequencer emits a freshness signal even when no transactions are being processed. + +## 7. Add the backfill service + +Target area: + +- new standalone executable under `src/app/zeko/sequencer/` +- build wiring in `src/app/zeko/sequencer/dune` + +Work: + +- Add a standalone OCaml service that reads diffs from the DA layer and republishes them to NATS. +- Use `nats-ml` for publishing. +- Reuse the shared NATS event module so backfilled messages match live messages exactly. +- Track backfill jobs in memory. +- Generate one service instance id at startup. + +GraphQL API: + +- `backfill(fromHash, toHash) -> BackfillJob` +- `backfillJob(id) -> BackfillJob` +- `health -> Health` + +GraphQL-SSE subscription: + +- `backfillProgress(id) -> BackfillProgress` + +`BackfillJob` fields: + +- `id` +- `fromHash` +- `toHash` +- `status` +- `diffsPublished` +- `error` +- `createdAt` +- `startedAt` +- `finishedAt` + +`BackfillProgress` fields: + +- `id` +- `status` +- `diffsPublished` +- `error` + +`Health` fields: + +- `ok` +- `instanceId` +- `startedAt` + +Transport: + +- Use GraphQL over HTTP for query and mutation. +- Use GraphQL-SSE for `backfillProgress`. + +Publishing rules: + +- Republish to `zeko.l2.transactions`. +- Use the same payload schema and `Nats-Msg-Id` rule as the live sequencer path. +- Emit `kind = genesis_replay` only when replaying a genesis diff. +- Emit `kind = sync_replay` for all other backfilled diffs. + +Result: + +- Missing L2 ranges can be republished into NATS without changing sequencer state. + +## 8. Update sequencer docs + +Target file: + +- `src/app/zeko/sequencer/README.md` + +Work: + +- Document `--nats-url`. +- Document the three emitted subjects. +- Document the exact payload families. +- Document how to run the backfill service. +- Document the backfill GraphQL and GraphQL-SSE surface. + +Result: + +- The repo docs describe the explorer-facing ingestion path end to end. + +## 9. Add tests as first-class work + +Work: + +- Add tests for the shared event module: + - transaction payload encoding + - finality payload encoding + - health payload encoding + - `Nats-Msg-Id` generation +- Add sequencer publishing tests for: + - `apply_user_command` + - `apply_fee_transfer` + - `sync` replay + - `run_committer` proved event + - commit-path committed event + - health heartbeat emission +- Add backfill service tests for: + - `backfill` job creation + - ordered republishing across a hash range + - progress updates over GraphQL-SSE + - `health` response contents + - matching payload shape between live and backfilled messages + +Result: + +- The explorer-facing boundary is covered directly in this repo. + +## Delivery Order + +1. Integrate `nats-ml` into this repo. +2. Add sequencer startup/config wiring for NATS. +3. Add the shared NATS event module. +4. Publish `zeko.l2.transactions`. +5. Publish `zeko.l2.finality`. +6. Publish `zeko.health`. +7. Add the backfill service with GraphQL and GraphQL-SSE. +8. Finish docs and tests. + +## Deliverable + +After this work, the `zeko` repo provides: + +- repo-local `nats-ml` integration +- optional sequencer publishing to NATS +- replay of historical diffs during sync +- finality and health events +- a standalone backfill service with GraphQL and GraphQL-SSE +- test coverage around the explorer-facing publish boundary diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml new file mode 100644 index 0000000000..1d1bf169aa --- /dev/null +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -0,0 +1,569 @@ +open Core +open Async +open Sequencer_lib +open Mina_base + +type job_status = + | Queued + | Running + | Completed + | Failed + +type job_snapshot = + { id : string + ; from_hash : string + ; to_hash : string + ; status : string + ; diffs_published : int + ; error : string option + ; created_at : string + ; started_at : string option + ; finished_at : string option + } + +type progress_snapshot = + { id : string + ; status : string + ; diffs_published : int + ; error : string option + } + +type subscriber = + { writer : progress_snapshot Pipe.Writer.t + ; mutable pending : progress_snapshot option + ; mutable draining : bool + ; mutable close_after_flush : bool + } + +type health_snapshot = + { ok : bool + ; instance_id : string + ; started_at : string + } + +type job = + { id : string + ; from_hash : Ledger_hash.t + ; to_hash : Ledger_hash.t + ; mutable status : job_status + ; mutable diffs_published : int + ; mutable error : string option + ; created_at : Time.t + ; mutable started_at : Time.t option + ; mutable finished_at : Time.t option + ; subscribers : subscriber list ref + } + +type t = + { logger : Logger.t + ; da_config : Da_layer.Client.Config.t + ; nats_client : Nats_client_async.client option + ; jobs : job String.Table.t + ; instance_id : string + ; started_at : Time.t + } + +let constraint_constants = Zeko_constants.constraint_constants + +let timestamp_string time = + Time.to_string_iso8601_basic time ~zone:Time.Zone.utc + +let string_of_job_status = function + | Queued -> + "queued" + | Running -> + "running" + | Completed -> + "completed" + | Failed -> + "failed" + +let is_terminal = function + | Completed | Failed -> + true + | Queued | Running -> + false + +let genesis_hash = + Da_layer.Diff.empty_ledger_hash ~depth:constraint_constants.ledger_depth + +let is_genesis_hash ledger_hash = Ledger_hash.equal ledger_hash genesis_hash + +let snapshot job = + { id = job.id + ; from_hash = Ledger_hash.to_decimal_string job.from_hash + ; to_hash = Ledger_hash.to_decimal_string job.to_hash + ; status = string_of_job_status job.status + ; diffs_published = job.diffs_published + ; error = job.error + ; created_at = timestamp_string job.created_at + ; started_at = Option.map job.started_at ~f:timestamp_string + ; finished_at = Option.map job.finished_at ~f:timestamp_string + } + +let progress_of_job job = + { id = job.id + ; status = string_of_job_status job.status + ; diffs_published = job.diffs_published + ; error = job.error + } + +let health t = + { ok = true + ; instance_id = t.instance_id + ; started_at = timestamp_string t.started_at + } + +let rec flush_subscriber subscriber = + let open Deferred.Let_syntax in + match subscriber.pending with + | None -> + subscriber.draining <- false ; + if subscriber.close_after_flush then Pipe.close subscriber.writer ; + Deferred.unit + | Some progress -> + subscriber.pending <- None ; + let%bind () = Pipe.write_if_open subscriber.writer progress in + flush_subscriber subscriber + +let create_subscriber writer = + { writer; pending = None; draining = false; close_after_flush = false } + +let enqueue_progress subscriber progress ~terminal = + if Pipe.is_closed subscriber.writer then false + else ( + subscriber.pending <- Some progress ; + if terminal then subscriber.close_after_flush <- true ; + if not subscriber.draining then ( + subscriber.draining <- true ; + don't_wait_for (flush_subscriber subscriber) ) ; + true ) + +let notify_subscribers job = + let progress = progress_of_job job in + let terminal = is_terminal job.status in + let subscribers = + List.filter !(job.subscribers) ~f:(fun subscriber -> + enqueue_progress subscriber progress ~terminal ) + in + job.subscribers := if terminal then [] else subscribers + +let update_job job ~status ?error ?started_at ?finished_at ?diffs_published () = + job.status <- status ; + Option.iter error ~f:(fun value -> job.error <- Some value) ; + Option.iter started_at ~f:(fun value -> job.started_at <- Some value) ; + Option.iter finished_at ~f:(fun value -> job.finished_at <- Some value) ; + Option.iter diffs_published ~f:(fun value -> job.diffs_published <- value) ; + notify_subscribers job + +let backfill_kind ~from_hash ~index = + if is_genesis_hash from_hash && Int.equal index 0 + then Explorer_events.Transaction_kind.Genesis_replay + else Explorer_events.Transaction_kind.Sync_replay + +let source_query from_hash = + if is_genesis_hash from_hash then `Genesis + else `Specific from_hash + +let create ~logger ~da_config ~nats_url = + let%map nats_client = Nats_client_async.connect (Some nats_url) in + { logger + ; da_config + ; nats_client = Some nats_client + ; jobs = String.Table.create () + ; instance_id = Uuid.to_string (Uuid_unix.create ()) + ; started_at = Time.now () + } + +let shutdown t = + match t.nats_client with + | None -> + Deferred.unit + | Some client -> + Nats_client_async.close client + +let find_job t id = Hashtbl.find t.jobs id + +let subscribe_progress t ~id = + match find_job t id with + | None -> + Or_error.errorf "Unknown backfill job %s" id + | Some job -> + let reader, writer = Pipe.create () in + let subscriber = create_subscriber writer in + if not (is_terminal job.status) then + job.subscribers := subscriber :: !(job.subscribers) ; + ignore + (enqueue_progress subscriber (progress_of_job job) + ~terminal:(is_terminal job.status) : bool ) ; + Ok reader + +let publish_message_result client ({ subject; headers; payload } : + Explorer_events.message ) = + let headers = + match headers with + | [] -> + None + | headers -> + Some (Nats_client.Headers.of_list headers) + in + Nats_client_async.publish_result client ~subject ?headers + (Yojson.Safe.to_string payload) + +let publish_backfill_diff t ~from_hash ~index ~target_ledger_hash diff = + let genesis = is_genesis_hash from_hash && Int.equal index 0 in + let message = + Explorer_events.build_transaction_message + ~kind:(backfill_kind ~from_hash ~index) + ~target_ledger_hash ~genesis ~diff + in + match t.nats_client with + | None -> + `Dropped + | Some client -> + publish_message_result client message + +let run_job t job = + let now = Time.now () in + update_job job ~status:Running ~started_at:now () ; + let source = source_query job.from_hash in + don't_wait_for + (Monitor.try_with_or_error (fun () -> + let source_ledger_hash = + match source with + | `Genesis -> + genesis_hash + | `Specific ledger_hash -> + ledger_hash + in + let%bind.Deferred.Result target_ledger_hashes = + Da_layer.Client.get_ledger_hashes_chain ~logger:t.logger + ~config:t.da_config ~source_ledger_hash:source + ~target_ledger_hash:job.to_hash () + in + let rec publish_targets current_source index = function + | [] -> + if Ledger_hash.equal current_source job.to_hash + then Deferred.return (Ok ()) + else + Deferred.return + (Error + (Error.of_string + "Ledger hash chain ended before reaching backfill \ + target") ) + | target_ledger_hash :: rest -> + let%bind.Deferred.Result diff = + Da_layer.Client.get_diff ~logger:t.logger ~config:t.da_config + ~ledger_hash:target_ledger_hash + in + if + not + (Ledger_hash.equal + (Da_layer.Diff.Stable.V3.source_ledger_hash diff) + current_source ) + then + Deferred.return + (Error + (Error.of_string + "Backfill diff chain does not match requested ledger \ + hash progression") ) + else ( + match + publish_backfill_diff t ~from_hash:job.from_hash ~index + ~target_ledger_hash diff + with + | `Queued -> + update_job job ~status:Running + ~diffs_published:(job.diffs_published + 1) + () ; + publish_targets target_ledger_hash (index + 1) rest + | `Dropped -> + Deferred.return + (Error + (Error.of_string + "NATS publish dropped while backfilling diffs") ) ) + in + let%bind.Deferred.Result () = + publish_targets source_ledger_hash 0 target_ledger_hashes + in + return () ) + >>= function + | Ok () -> + update_job job ~status:Completed ~finished_at:(Time.now ()) () + | Error error -> + update_job job ~status:Failed ~finished_at:(Time.now ()) + ~error:(Error.to_string_hum error) () ) + +let parse_ledger_hash hash = + Or_error.try_with (fun () -> Ledger_hash.of_decimal_string hash) + |> Or_error.map_error ~f:(fun err -> + Error.tag_arg err "Invalid ledger hash" hash String.sexp_of_t ) + +let start_backfill t ~from_hash ~to_hash = + let job = + { id = Uuid.to_string (Uuid_unix.create ()) + ; from_hash + ; to_hash + ; status = Queued + ; diffs_published = 0 + ; error = None + ; created_at = Time.now () + ; started_at = None + ; finished_at = None + ; subscribers = ref [] + } + in + Hashtbl.set t.jobs ~key:job.id ~data:job ; + notify_subscribers job ; + run_job t job ; + job + +let start_backfill_from_strings t ~from_hash ~to_hash = + let open Or_error.Let_syntax in + let%bind from_hash = parse_ledger_hash from_hash in + let%map to_hash = parse_ledger_hash to_hash in + start_backfill t ~from_hash ~to_hash + +module Gql = struct + open Graphql_async.Schema + + let backfill_job_typ : (t, job_snapshot) typ = + obj "BackfillJob" + ~fields:(fun _ -> + [ field "id" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.id) + ; field "fromHash" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.from_hash) + ; field "toHash" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.to_hash) + ; field "status" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.status) + ; field "diffsPublished" ~typ:(non_null int) ~args:[] + ~resolve:(fun _ job -> job.diffs_published) + ; field "error" ~typ:string ~args:[] + ~resolve:(fun _ job -> job.error) + ; field "createdAt" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.created_at) + ; field "startedAt" ~typ:string ~args:[] + ~resolve:(fun _ job -> job.started_at) + ; field "finishedAt" ~typ:string ~args:[] + ~resolve:(fun _ job -> job.finished_at) + ] ) + + let progress_typ : (t, progress_snapshot) typ = + obj "BackfillProgress" + ~fields:(fun _ -> + [ field "id" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ progress -> progress.id) + ; field "status" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ progress -> progress.status) + ; field "diffsPublished" ~typ:(non_null int) ~args:[] + ~resolve:(fun _ progress -> progress.diffs_published) + ; field "error" ~typ:string ~args:[] + ~resolve:(fun _ progress -> progress.error) + ] ) + + let health_typ : (t, health_snapshot) typ = + obj "Health" + ~fields:(fun _ -> + [ field "ok" ~typ:(non_null bool) ~args:[] + ~resolve:(fun _ value -> value.ok) + ; field "instanceId" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ value -> value.instance_id) + ; field "startedAt" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ value -> value.started_at) + ] ) + + let query_fields = + [ io_field "backfillJob" ~typ:backfill_job_typ + ~args:Arg.[ arg "id" ~typ:(non_null string) ] + ~resolve:(fun { ctx; _ } () id -> + return (Option.map (find_job ctx id) ~f:snapshot)) + ; io_field "health" ~typ:(non_null health_typ) ~args:[] + ~resolve:(fun { ctx; _ } () () -> return (health ctx)) + ] + + let mutation_fields = + [ io_field "backfill" ~typ:(non_null backfill_job_typ) + ~args: + Arg. + [ arg "fromHash" ~typ:(non_null string) + ; arg "toHash" ~typ:(non_null string) + ] + ~resolve:(fun { ctx; _ } () from_hash to_hash -> + match start_backfill_from_strings ctx ~from_hash ~to_hash with + | Ok job -> + return (Ok (snapshot job)) + | Error err -> + return (Error (Error.to_string_hum err))) + ] + + let subscription_fields = + [ subscription_field "backfillProgress" ~typ:(non_null progress_typ) + ~args:Arg.[ arg "id" ~typ:(non_null string) ] + ~resolve:(fun { ctx; _ } id -> + match subscribe_progress ctx ~id with + | Ok progress -> + Deferred.Result.return progress + | Error err -> + Deferred.return (Error (Error.to_string_hum err))) + ] + + let schema = + Graphql_async.Schema.( + schema query_fields ~mutations:mutation_fields + ~subscriptions:subscription_fields) +end + +module Sse = struct + let headers = + Cohttp.Header.of_list + [ ("Content-Type", "text/event-stream") + ; ("Cache-Control", "no-cache") + ; ("Connection", "keep-alive") + ] + + let next_event payload = + "event: next\ndata: " ^ Yojson.Basic.to_string payload ^ "\n\n" + + let complete_event = "event: complete\ndata: {}\n\n" + + let parse_request req body = + Init.Graphql_internal.Params.extract req body + |> Result.map_error ~f:Error.of_string + + let execute_subscription t req body = + let open Deferred.Let_syntax in + match parse_request req body with + | Error err -> + Deferred.return (Error err) + | Ok (query, variables, operation_name) -> ( + match Graphql_parser.parse query with + | Error err -> + Deferred.return (Error (Error.of_string err)) + | Ok doc -> + let%map result = + Graphql_async.Schema.execute Gql.schema t ?variables + ?operation_name doc + in + match result with + | Ok (`Stream stream) -> + Ok stream + | Ok (`Response _) -> + Error + (Error.of_string + "Expected a GraphQL subscription for /graphql/stream") + | Error err -> + Error + (Error.of_string + ("Invalid GraphQL subscription: " + ^ Yojson.Basic.to_string err ) ) ) + + let rec write_stream body_writer stream = + let open Deferred.Let_syntax in + match stream () with + | Seq.Nil -> + let%map () = Pipe.write body_writer complete_event in + Pipe.close body_writer + | Seq.Cons (payload, next) -> + let payload = + match payload with + | Ok payload -> + payload + | Error err -> + err + in + let%bind () = Pipe.write body_writer (next_event payload) in + write_stream body_writer next + + let callback t _conn req body = + let open Deferred.Let_syntax in + let%bind body = Cohttp_async.Body.to_string body in + match%bind execute_subscription t req body with + | Error err -> + Cohttp_async.Server.respond_string ~status:`Bad_request + (Error.to_string_hum err) + >>| fun response -> `Response response + | Ok stream -> + let body_reader, body_writer = Pipe.create () in + don't_wait_for (write_stream body_writer stream) ; + Cohttp_async.Server.respond ~headers + ~body:(Cohttp_async.Body.of_pipe body_reader) + () + >>| fun response -> `Response response +end + +let%test_unit "status strings are stable" = + [%test_eq: string] (string_of_job_status Queued) "queued" ; + [%test_eq: string] (string_of_job_status Completed) "completed" + +let%test_unit "backfill kind uses genesis replay only for the first genesis diff" = + [%test_eq: Explorer_events.Transaction_kind.t] + (backfill_kind ~from_hash:genesis_hash ~index:0) + Explorer_events.Transaction_kind.Genesis_replay ; + [%test_eq: Explorer_events.Transaction_kind.t] + (backfill_kind ~from_hash:Ledger_hash.empty_hash ~index:1) + Explorer_events.Transaction_kind.Sync_replay + +let%test_unit "health snapshot includes the instance id" = + let t = + { logger = Logger.create () + ; da_config = Da_layer.Client.Config.of_string_list [] + ; nats_client = None + ; jobs = String.Table.create () + ; instance_id = "instance-1" + ; started_at = Time.epoch + } + in + [%test_eq: string] (health t).instance_id "instance-1" + +let%test_unit "invalid backfill hash returns an error instead of raising" = + [%test_eq: bool] + (Result.is_error + (start_backfill_from_strings + { logger = Logger.create () + ; da_config = Da_layer.Client.Config.of_string_list [] + ; nats_client = None + ; jobs = String.Table.create () + ; instance_id = "instance-1" + ; started_at = Time.epoch + } + ~from_hash:"bad-hash" ~to_hash:"also-bad" ) ) + true + +let%test_unit "backfill publish reports dropped when no NATS client is present" = + let diff = + Explorer_events.build_live_diff ~logger:(Logger.create ()) + ~diff: + (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash + ~changed_accounts:[] ~command_with_action_step_flags:None ) + ~acc_set_root:Snark_params.Tick.Field.zero + in + let t = + { logger = Logger.create () + ; da_config = Da_layer.Client.Config.of_string_list [] + ; nats_client = None + ; jobs = String.Table.create () + ; instance_id = "instance-1" + ; started_at = Time.epoch + } + in + [%test_eq: Nats_client_async.publish_result] + (publish_backfill_diff t ~from_hash:Ledger_hash.empty_hash ~index:0 + ~target_ledger_hash:Ledger_hash.empty_hash diff ) + `Dropped + +let%test_unit "sse next event carries GraphQL JSON" = + let event = + Sse.next_event + (`Assoc + [ ( "data" + , `Assoc + [ ( "backfillProgress" + , `Assoc [ ("id", `String "job-1") ] ) + ] ) + ] ) + in + [%test_eq: bool] + (String.is_substring event ~substring:"backfillProgress") + true diff --git a/src/app/zeko/sequencer/explorer/run.ml b/src/app/zeko/sequencer/explorer/run.ml new file mode 100644 index 0000000000..c49ea24ca6 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/run.ml @@ -0,0 +1,60 @@ +open Core +open Async +open Cli_lib +open Sequencer_lib + +module Graphql_cohttp_async = + Init.Graphql_internal.Make (Graphql_async.Schema) (Cohttp_async.Io) + (Cohttp_async.Body) + +let run ~logger ~port ~da_config ~nats_url () = + let service = + Thread_safe.block_on_async_exn (fun () -> + Explorer_backfill_service.create ~logger ~da_config ~nats_url ) + in + let graphql_callback = + Graphql_cohttp_async.make_callback + (fun ~with_seq_no:_ _req -> service) + Explorer_backfill_service.Gql.schema + in + let callback ~body _sock req = + match Uri.path (Cohttp.Request.uri req) with + | "/graphql/stream" -> + Explorer_backfill_service.Sse.callback service () req body + | _ -> + graphql_callback () req body + in + let () = + Cohttp_async.Server.create_expert + ~on_handler_error: + (`Call + (fun _ exn -> + [%log error] "Unhandled exception: %s" (Exn.to_string exn) ) ) + (Async.Tcp.Where_to_listen.of_port port) + callback + |> Deferred.ignore_m |> don't_wait_for + in + Shutdown.at_shutdown (fun () -> Explorer_backfill_service.shutdown service) ; + [%log info] "Explorer backfill service listening on port %d" port ; + never_returns (Async.Scheduler.go ()) + +let () = + Command.basic ~summary:"Zeko explorer backfill service" + (let%map_open.Command log_json = Flag.Log.json + and log_level = Flag.Log.level + and port = + flag "-p" (optional_with_default 8090 int) ~doc:"int Port to listen on" + and da_nodes = + flag "--da-node" (listed string) + ~doc:"string Address of the DA node, can be supplied multiple times" + and nats_url = + flag "--nats-url" (required string) + ~doc:"string NATS URL for republishing explorer events" + in + if List.is_empty da_nodes then failwith "--da-node must be supplied" ; + Stdout_log.setup log_json log_level ; + let logger = Logger.create () in + let da_config = Da_layer.Client.Config.of_string_list da_nodes in + let nats_url = Uri.of_string nats_url in + run ~logger ~port ~da_config ~nats_url () ) + |> Command_unix.run diff --git a/src/app/zeko/sequencer/lib/dune b/src/app/zeko/sequencer/lib/dune index 1d842ea9ec..d02417c80d 100644 --- a/src/app/zeko/sequencer/lib/dune +++ b/src/app/zeko/sequencer/lib/dune @@ -11,6 +11,8 @@ indexed_merkle_tree utils gql_client + nats_client + nats_client_async ;; mina ;; mina_base mina_base.import diff --git a/src/app/zeko/sequencer/lib/explorer_events.ml b/src/app/zeko/sequencer/lib/explorer_events.ml new file mode 100644 index 0000000000..bf3be413de --- /dev/null +++ b/src/app/zeko/sequencer/lib/explorer_events.ml @@ -0,0 +1,172 @@ +open Core_kernel +open Mina_base + +module Transaction_kind = struct + type t = + | User_command + | Fee_transfer + | Sync_replay + | Genesis_replay + + let to_string = function + | User_command -> + "user_command" + | Fee_transfer -> + "fee_transfer" + | Sync_replay -> + "sync_replay" + | Genesis_replay -> + "genesis_replay" +end + +module Finality_status = struct + type t = + | Proved + | Committed + + let to_string = function Proved -> "proved" | Committed -> "committed" +end + +module Subject = struct + let transactions = "zeko.l2.transactions" + let finality = "zeko.l2.finality" + let health = "zeko.health" +end + +type message = + { subject : string + ; headers : (string * string) list + ; payload : Yojson.Safe.t + } + +type sink = message -> unit + +let noop_sink _ = () + +let timestamp_json ~logger = + Block_time.now (Block_time.Controller.basic ~logger) |> Block_time.to_yojson + +let nats_msg_id target_ledger_hash = + Ledger_hash.to_decimal_string target_ledger_hash + +let nats_msg_id_headers target_ledger_hash = + [ ("Nats-Msg-Id", nats_msg_id target_ledger_hash) ] + +let create_nats_sink client : sink = + fun { subject; headers; payload } -> + let headers = Nats_client.Headers.of_list headers in + Nats_client_async.publish_json client ~subject ~headers payload + +let build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff = + { subject = Subject.transactions + ; headers = nats_msg_id_headers target_ledger_hash + ; payload = + `Assoc + [ ("kind", `String (Transaction_kind.to_string kind)) + ; ("target_ledger_hash", Ledger_hash.to_yojson target_ledger_hash) + ; ("genesis", `Bool genesis) + ; ("diff", Da_layer.Diff.Stable.V3.to_yojson diff) + ] + } + +let build_live_diff ~logger ~diff ~acc_set_root = + Da_layer.Diff.add_time_and_acc_set ~logger diff ~acc_set:acc_set_root + +let build_finality_message ~logger ~status ~source_ledger_hash + ~target_ledger_hash = + { subject = Subject.finality + ; headers = [] + ; payload = + `Assoc + [ ("status", `String (Finality_status.to_string status)) + ; ("source_ledger_hash", Ledger_hash.to_yojson source_ledger_hash) + ; ("target_ledger_hash", Ledger_hash.to_yojson target_ledger_hash) + ; ("timestamp", timestamp_json ~logger) + ] + } + +let build_health_message ~logger ~component ~instance_id ~status = + { subject = Subject.health + ; headers = [] + ; payload = + `Assoc + [ ("component", `String component) + ; ("instance_id", `String instance_id) + ; ("status", `String status) + ; ("timestamp", timestamp_json ~logger) + ] + } + +let publish sink message = sink message + +let publish_transaction sink ~kind ~target_ledger_hash ~genesis ~diff = + publish sink + @@ build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff + +let publish_finality sink ~logger ~status ~source_ledger_hash ~target_ledger_hash + = + publish sink + @@ build_finality_message ~logger ~status ~source_ledger_hash + ~target_ledger_hash + +let publish_health sink ~logger ~component ~instance_id ~status = + publish sink @@ build_health_message ~logger ~component ~instance_id ~status + +let find_assoc_exn json key = + match json with + | `Assoc fields -> + List.Assoc.find_exn fields key ~equal:String.equal + | _ -> + failwith "Expected JSON object" + +let%test_unit "transaction payload encoding keeps contract fields" = + let target_ledger_hash = Ledger_hash.empty_hash in + let diff = + build_live_diff ~logger:(Logger.create ()) + ~diff: + (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash + ~changed_accounts:[] ~command_with_action_step_flags:None ) + ~acc_set_root:Snark_params.Tick.Field.zero + in + let message = + build_transaction_message ~kind:Transaction_kind.User_command + ~target_ledger_hash ~genesis:false ~diff + in + [%test_eq: string] message.subject Subject.transactions ; + [%test_eq: (string * string) list] message.headers + (nats_msg_id_headers target_ledger_hash) ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "kind") + (`String "user_command") ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "genesis") + (`Bool false) + +let%test_unit "finality payload encoding keeps hashes and status" = + let message = + build_finality_message ~logger:(Logger.create ()) + ~status:Finality_status.Committed + ~source_ledger_hash:Ledger_hash.empty_hash + ~target_ledger_hash:Ledger_hash.empty_hash + in + [%test_eq: string] message.subject Subject.finality ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "status") + (`String "committed") + +let%test_unit "health payload encoding includes component and instance id" = + let message = + build_health_message ~logger:(Logger.create ()) ~component:"sequencer" + ~instance_id:"instance-1" ~status:"ok" + in + [%test_eq: string] message.subject Subject.health ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "component") + (`String "sequencer") ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "instance_id") + (`String "instance-1") + +let%test_unit "nats msg id uses target ledger hash" = + [%test_eq: string] (nats_msg_id Ledger_hash.empty_hash) + (Ledger_hash.to_decimal_string Ledger_hash.empty_hash) diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index 863d9f7e36..aab72d45fe 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -18,6 +18,7 @@ module Sequencer = struct ; commitment_period_sec : float ; db_dir : string ; checkpoints_dir : string option + ; nats_url : Uri.t option ; signer : Keypair.t ; l1_uri : Uri.t ; archive_uri : Uri.t @@ -71,6 +72,7 @@ module Sequencer = struct type t = { provers : Zeko_prover.Client.t ; da_client : Da_layer.Client.t + ; nats_client : Nats_client_async.client option ; executor : Executor.t ; config : Config.t ; sequencer_state : State.t @@ -134,6 +136,7 @@ module Sequencer = struct let process ({ da_client + ; nats_client ; provers ; executor ; config @@ -181,17 +184,33 @@ module Sequencer = struct let%bind () = Executor.send_zkapp_command ~logger executor command in + let source_ledger_hash = + Sparse_ledger.merkle_root old_inner_ledger + in + let target_ledger_hash = + Sparse_ledger.merkle_root new_inner_ledger + in State.Last_committed_ledger.set sequencer_state ~data:new_inner_ledger ; + let sink = + match nats_client with + | Some client -> + Explorer_events.create_nats_sink client + | None -> + Explorer_events.noop_sink + in + let () = + Explorer_events.publish_finality sink ~logger + ~status:Explorer_events.Finality_status.Committed + ~source_ledger_hash ~target_ledger_hash + in return (fun () -> let open Relational_db in Pool.use (fun conn -> Committer.Commit_table.insert conn - { source_ledger_hash = - Sparse_ledger.merkle_root old_inner_ledger - ; target_ledger_hash = - Sparse_ledger.merkle_root new_inner_ledger + { source_ledger_hash + ; target_ledger_hash ; witness = commit_witness } ) db_pool ) ) @@ -235,6 +254,8 @@ module Sequencer = struct ; merger : Merger.M.t ; merger_ctx : Merger.Context.t ; da_client : Da_layer.Client.t + ; nats_client : Nats_client_async.client option + ; instance_id : string ; closed : unit Ivar.t ; apply_q : unit Sequencer.t (* Applying of the user command is async operation, but we need to keep the application synchronous *) @@ -244,6 +265,13 @@ module Sequencer = struct let logger = t.logger in [%log info] "Shutting down sequencer" ; Ivar.fill t.closed () ; + let%bind () = + match t.nats_client with + | None -> + return () + | Some client -> + Nats_client_async.close client + in Da_layer.Client.stop t.da_client ; L.Db.close t.ledger ; Indexed_merkle_tree.Db.close t.imt ; @@ -278,6 +306,29 @@ module Sequencer = struct ; committed_ledger_hash = Field.zero } + let nats_sink = function + | Some client -> + Explorer_events.create_nats_sink client + | None -> + Explorer_events.noop_sink + + let publish_transaction_event t ~kind ~target_ledger_hash ~genesis ~diff = + Explorer_events.publish_transaction (nats_sink t.nats_client) ~kind + ~target_ledger_hash ~genesis ~diff + + let publish_finality_event t ~status ~source_ledger_hash ~target_ledger_hash = + Explorer_events.publish_finality (nats_sink t.nats_client) ~logger:t.logger + ~status + ~source_ledger_hash ~target_ledger_hash + + let publish_health_event t = + Explorer_events.publish_health (nats_sink t.nats_client) ~logger:t.logger + ~component:"sequencer" ~instance_id:t.instance_id ~status:"ok" + + let diff_with_metadata ~logger ~diff ~acc_set_openings = + Da_layer.Diff.add_time_and_acc_set ~logger diff + ~acc_set:(Indexed_merkle_tree.Sparse.merkle_root acc_set_openings) + let apply_events_and_actions t command = let ledger = L.of_database t.ledger in Zkapp_command.(Call_forest.to_list (Poly.account_updates command)) @@ -493,14 +544,24 @@ module Sequencer = struct Account_id.derive_token_id ~owner:(Account.identifier account) ) in + let acc_set_openings = + Indexed_merkle_tree.Sparse.of_db_subset ~logger:t.logger ~db:t.imt + ~keys:new_accounts_keys + in + let target_ledger_hash = L.Db.merkle_root t.ledger in + let published_diff = + diff_with_metadata ~logger:t.logger ~diff ~acc_set_openings + in let%bind () = Da_layer.Client.enqueue_diff t.da_client ~genesis:false - ~ledger_openings:source_ledger - ~acc_set_openings: - (Indexed_merkle_tree.Sparse.of_db_subset ~logger:t.logger - ~db:t.imt ~keys:new_accounts_keys ) - ~diff - ~target_ledger_hash:(L.Db.merkle_root t.ledger) + ~ledger_openings:source_ledger ~acc_set_openings ~diff + ~target_ledger_hash + in + let () = + publish_transaction_event t + ~kind:Explorer_events.Transaction_kind.User_command + ~target_ledger_hash + ~genesis:false ~diff:published_diff in (* Add witnesses to the merger *) @@ -569,14 +630,23 @@ module Sequencer = struct Account_id.derive_token_id ~owner:(Account.identifier account) ) in + let acc_set_openings = + Indexed_merkle_tree.Sparse.of_db_subset ~logger:t.logger + ~db:t.imt ~keys:new_accounts_keys + in + let target_ledger_hash = L.Db.merkle_root t.ledger in + let published_diff = + diff_with_metadata ~logger:t.logger ~diff ~acc_set_openings + in let%bind () = Da_layer.Client.enqueue_diff t.da_client ~genesis:false - ~ledger_openings:source_ledger - ~acc_set_openings: - (Indexed_merkle_tree.Sparse.of_db_subset ~logger:t.logger - ~db:t.imt ~keys:new_accounts_keys ) - ~diff - ~target_ledger_hash:(L.Db.merkle_root t.ledger) + ~ledger_openings:source_ledger ~acc_set_openings ~diff + ~target_ledger_hash + in + let () = + publish_transaction_event t + ~kind:Explorer_events.Transaction_kind.Fee_transfer + ~target_ledger_hash ~genesis:false ~diff:published_diff in match%map Merger.P.add_job t.db_pool t.merger t.merger_ctx ~data:witness @@ -752,6 +822,12 @@ module Sequencer = struct let%bind () = match%map ledger_applied >>| Or_error.ok_exn with | Some (stmt, _) -> + let () = + publish_finality_event t + ~status:Explorer_events.Finality_status.Proved + ~source_ledger_hash:stmt.source_ledger + ~target_ledger_hash:stmt.target_ledger + in [%log info] "Committed: %s -> %s" (Ledger_hash.to_decimal_string stmt.source_ledger) (Ledger_hash.to_decimal_string stmt.target_ledger) @@ -763,6 +839,22 @@ module Sequencer = struct in don't_wait_for (within' ~monitor:Monitor.main (fun () -> go ())) + let run_health_heartbeat t = + let period = Time_ns.Span.of_sec 30. in + let rec go () = + let () = publish_health_event t in + let%bind () = Deferred.any [ after period; Ivar.read t.closed ] in + if Ivar.is_full t.closed then return () else go () + in + don't_wait_for (within' ~monitor:Monitor.main (fun () -> go ())) + + let replay_genesis_flag ~source ~current_chunk ~current_diff = + match source with + | `Genesis -> + current_chunk = 0 && current_diff = 0 + | `Specific _ -> + false + let sync ~logger ({ config; _ } as t) da_config source = [%log info] "Syncing" ; let%bind commited_ledger_hash = @@ -801,8 +893,21 @@ module Sequencer = struct , acc_set_openings , `Target target_ledger_hash ) -> - Da_layer.Client.enqueue_diff t.da_client ~diff ~ledger_openings - ~acc_set_openings ~target_ledger_hash ~genesis:(i = 0) ) + let genesis = i = 0 in + let published_diff = + diff_with_metadata ~logger:t.logger ~diff ~acc_set_openings + in + let%map () = + Da_layer.Client.enqueue_diff t.da_client ~diff + ~ledger_openings ~acc_set_openings ~target_ledger_hash + ~genesis + in + publish_transaction_event t + ~kind: + (if genesis then + Explorer_events.Transaction_kind.Genesis_replay + else Explorer_events.Transaction_kind.Sync_replay) + ~target_ledger_hash ~genesis ~diff:published_diff ) in [%log info] "Enqueued genesis diff" ; return () @@ -855,14 +960,30 @@ module Sequencer = struct ~diff:(Da_layer.Diff.drop_time diff) ~ledger_openings ~imt:t.imt in + let target_ledger_hash = L.Db.merkle_root t.ledger in + let genesis = + replay_genesis_flag ~source ~current_chunk ~current_diff + in + let published_diff = + diff_with_metadata ~logger:t.logger + ~diff:(Da_layer.Diff.drop_time diff) + ~acc_set_openings + in (* Store diff to DA client *) let%bind () = Da_layer.Client.enqueue_diff t.da_client ~diff:(Da_layer.Diff.drop_time diff) ~ledger_openings ~acc_set_openings - ~target_ledger_hash:(L.Db.merkle_root t.ledger) - ~genesis:(current_chunk = 0 && current_diff = 0) + ~target_ledger_hash ~genesis + in + let () = + publish_transaction_event t + ~kind: + (if genesis then + Explorer_events.Transaction_kind.Genesis_replay + else Explorer_events.Transaction_kind.Sync_replay) + ~target_ledger_hash ~genesis ~diff:published_diff in (* Add events and actions *) @@ -1000,7 +1121,8 @@ module Sequencer = struct | true, false | false, true -> failwithf "Corrupted db %s and %s" ledger_dir imt_dir () - let create ~logger ~max_pool_size ~commitment_period_sec ~da_config ~da_keys + let create ?nats_url ~logger ~max_pool_size ~commitment_period_sec ~da_config + ~da_keys ~da_quorum ~db_dir ~checkpoints_dir ~postgres_uri ~l1_uri ~archive_uri ~(signer : Keypair.t) ~deposit_delay_blocks ~mq_host ~fee_modifier ~minimum_fee ~slot_acceptance ~proof_cache_db ~l1_config @@ -1026,6 +1148,7 @@ module Sequencer = struct ; commitment_period_sec ; db_dir ; checkpoints_dir + ; nats_url ; l1_uri ; archive_uri ; signer @@ -1042,6 +1165,14 @@ module Sequencer = struct Da_layer.Client.create ~logger ~config:da_config ~quorum:da_quorum ~da_keys ~db_pool in + let%bind nats_client = + match nats_url with + | None -> + return None + | Some uri -> + let%map client = Nats_client_async.connect (Some uri) in + Some client + in let kvdb = L.Db.zeko_kvdb ledger in let%bind provers = Zeko_prover.Client.create ~logger ~db_pool ~mq_host in let executor = @@ -1053,6 +1184,7 @@ module Sequencer = struct Merger.Context. { provers ; da_client + ; nats_client ; executor ; config ; sequencer_state = kvdb @@ -1072,6 +1204,8 @@ module Sequencer = struct ; archive ; config ; da_client + ; nats_client + ; instance_id = Uuid.to_string (Uuid_unix.create ()) ; bridge_prover = Bridge_prover.create ~provers ~proof_cache_db ; merger ; merger_ctx @@ -1096,5 +1230,19 @@ module Sequencer = struct let%bind () = Da_layer.Client.start_client da_client ~target_ledger_hash:(get_root t) in + let () = run_health_heartbeat t in return t end + +let%test_unit "genesis sync labels the first replayed diff as genesis" = + [%test_eq: bool] + (Sequencer.replay_genesis_flag ~source:`Genesis ~current_chunk:0 + ~current_diff:0 ) + true + +let%test_unit "checkpoint sync never relabels replayed diffs as genesis" = + [%test_eq: bool] + (Sequencer.replay_genesis_flag + ~source:(`Specific Ledger_hash.empty_hash) + ~current_chunk:0 ~current_diff:0 ) + false diff --git a/src/app/zeko/sequencer/run.ml b/src/app/zeko/sequencer/run.ml index 31dd8b2490..797df21533 100644 --- a/src/app/zeko/sequencer/run.ml +++ b/src/app/zeko/sequencer/run.ml @@ -10,7 +10,7 @@ module Sequencer = Zeko_sequencer.Sequencer let run ~logger ~port ~max_pool_size ~commitment_period ~da_config ~da_keys ~da_quorum ~db_dir ~checkpoints_dir ~postgres_uri ~l1_uri ~archive_uri - ~signer ~deposit_delay_blocks ~mq_host ~fee_modifier ~minimum_fee + ~signer ~deposit_delay_blocks ~mq_host ~fee_modifier ~minimum_fee ~nats_url ~slot_acceptance ~commit_validity_period () = let proof_cache_db = Proof_cache_tag.create_identity_db () in let l1_config : Utils.Slot.l1_config = @@ -33,6 +33,7 @@ let run ~logger ~port ~max_pool_size ~commitment_period ~da_config ~da_keys ~db_dir:(Some db_dir) ~checkpoints_dir:(Some checkpoints_dir) ~postgres_uri ~l1_uri ~archive_uri ~commitment_period_sec:commitment_period ~deposit_delay_blocks + ?nats_url ~signer: Signature_lib.( Keypair.of_private_key_exn @@ -112,6 +113,9 @@ let () = flag "--minimum-fee" (optional_with_default 0.01 float) ~doc:"float Minimum fee for the sequencer" + and nats_url = + flag "--nats-url" (optional string) + ~doc:"string Optional NATS URL for explorer event publishing" and slot_acceptance_m = flag "--slot-acceptance" (optional_with_default 60. float) @@ -130,6 +134,7 @@ let () = in let l1_uri = Uri.of_string l1_uri in let archive_uri = Uri.of_string archive_uri in + let nats_url = Option.map nats_url ~f:Uri.of_string in let mq_host = Host_and_port.of_string mq_host in let logger = Logger.create () in let postgres_uri = Uri.of_string postgres_uri in @@ -140,5 +145,5 @@ let () = run ~logger ~port ~max_pool_size ~commitment_period ~da_config ~da_keys ~da_quorum ~db_dir ~checkpoints_dir ~postgres_uri ~l1_uri ~archive_uri ~signer ~deposit_delay_blocks ~mq_host ~fee_modifier ~minimum_fee - ~slot_acceptance ~commit_validity_period ) + ~nats_url ~slot_acceptance ~commit_validity_period ) |> Command_unix.run diff --git a/src/lib/nats-ml b/src/lib/nats-ml new file mode 160000 index 0000000000..97f1f2be0b --- /dev/null +++ b/src/lib/nats-ml @@ -0,0 +1 @@ +Subproject commit 97f1f2be0baabef8fc203cf8def1c48f60cd6127 From d0244ddd0b4a10264fa9fd696d1b519908f0f8eb Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 8 Apr 2026 04:29:54 +0700 Subject: [PATCH 02/51] Use opam pins for nats-ml integration --- .github/workflows/ci.yaml | 4 ++-- .gitmodules | 3 --- scripts/external-opam-pins.txt | 3 +++ scripts/pin-external-packages.sh | 23 ++++++++++++++++++++++- scripts/update-opam-switch.sh | 8 +++++++- src/app/zeko/sequencer/README.md | 5 +++++ src/lib/nats-ml | 1 - 7 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 scripts/external-opam-pins.txt delete mode 160000 src/lib/nats-ml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 43a9f56a68..15b8031683 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -70,7 +70,7 @@ jobs: id: opam_dependencies with: path: ~/.opam - key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} + key: zeko_opam_dependencies-${{ hashFiles('./opam.export', './scripts/external-opam-pins.txt', './scripts/pin-external-packages.sh') }} # Install opam dependencies - name: 🏗️ Install opam dependencies run: | @@ -84,7 +84,7 @@ jobs: id: zeko_build with: path: ./_build - key: zeko_build-${{ hashFiles('./opam.export') }} + key: zeko_build-${{ hashFiles('./opam.export', './scripts/external-opam-pins.txt', './scripts/pin-external-packages.sh') }} # Build! - name: 📦 Build zeko packages run: | diff --git a/.gitmodules b/.gitmodules index 2998a52fad..d3a6efbf68 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,6 +7,3 @@ [submodule "src/lib/crypto/kimchi_bindings/stubs/kimchi-stubs-vendors"] path = src/lib/crypto/kimchi_bindings/stubs/kimchi-stubs-vendors url = https://github.com/MinaProtocol/kimchi-stubs-vendors.git -[submodule "src/lib/nats-ml"] - path = src/lib/nats-ml - url = https://github.com/Hebilicious/nats-ml diff --git a/scripts/external-opam-pins.txt b/scripts/external-opam-pins.txt new file mode 100644 index 0000000000..fa825a0d10 --- /dev/null +++ b/scripts/external-opam-pins.txt @@ -0,0 +1,3 @@ +# package source +nats-client git+https://github.com/Hebilicious/nats-ml.git#fbc597311197b8526af13c97e6c6364955203a30 +nats-client-async git+https://github.com/Hebilicious/nats-ml.git#fbc597311197b8526af13c97e6c6364955203a30 diff --git a/scripts/pin-external-packages.sh b/scripts/pin-external-packages.sh index 263dfeb976..df44b77323 100755 --- a/scripts/pin-external-packages.sh +++ b/scripts/pin-external-packages.sh @@ -1,4 +1,25 @@ #!/bin/sh -# update packages used by CI +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +PINS_FILE="$SCRIPT_DIR/external-opam-pins.txt" + +cd "$REPO_ROOT" + +# keep the repo-managed submodules in sync first git submodule sync && git submodule update --init --recursive + +# then install externally pinned opam packages that are not tracked in opam.export +while IFS=' ' read -r package source; do + case "$package" in + ''|\#*) + continue + ;; + esac + + opam pin add --yes --no-action "$package" "$source" +done < "$PINS_FILE" + +opam install --yes nats-client nats-client-async diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index af8745fbb7..e3d8fd01c6 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -8,7 +8,11 @@ cd "$SCRIPT_DIR/.." # Don't do anything if we're in a nix shell [[ "$IN_NIX_SHELL$CI$BUILDKITE" == "" ]] || exit 0 -sum="$(cksum opam.export | grep -oE '^\S*')" +sum="$( + cat opam.export scripts/external-opam-pins.txt \ + | cksum \ + | grep -oE '^\S*' +)" switch_dir=opam_switches/"$sum" if [[ -d _opam ]]; then @@ -34,3 +38,5 @@ if [[ ! -d "${switch_dir}" ]]; then fi ln -s "${switch_dir}" _opam + +./scripts/pin-external-packages.sh diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index b290ce4928..ec724ed560 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -11,6 +11,11 @@ Think of the sequencer as the conductor of an orchestra in Zeko. It plays a vita ## Build +The repo-managed OCaml setup imports `opam.export` and then runs +`./scripts/pin-external-packages.sh`, which pins `nats-client` and +`nats-client-async` from GitHub into the switch. No `nats-ml` submodule checkout +is required. + ```bash DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer ``` diff --git a/src/lib/nats-ml b/src/lib/nats-ml deleted file mode 160000 index 97f1f2be0b..0000000000 --- a/src/lib/nats-ml +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 97f1f2be0baabef8fc203cf8def1c48f60cd6127 From 835657c6388abd1be8937b0abd33a03ad4ee8ad9 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 8 Apr 2026 04:31:41 +0700 Subject: [PATCH 03/51] Mark nats-ml pins as temporary workaround --- .github/workflows/ci.yaml | 3 +++ scripts/external-opam-pins.txt | 1 + scripts/pin-external-packages.sh | 4 +++- scripts/update-opam-switch.sh | 5 +++++ src/app/zeko/sequencer/README.md | 3 ++- 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 15b8031683..9e05a5c319 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -76,6 +76,9 @@ jobs: run: | opam switch import opam.export --yes eval "$(opam env)" + # Temporary workaround until nats-client and nats-client-async are + # published to opam. After release, remove this script and install + # the published packages through normal opam dependency resolution. ./scripts/pin-external-packages.sh opam clean --logs -cs --quiet # Retrieve _build dir from cache before initiating a build diff --git a/scripts/external-opam-pins.txt b/scripts/external-opam-pins.txt index fa825a0d10..9028d66226 100644 --- a/scripts/external-opam-pins.txt +++ b/scripts/external-opam-pins.txt @@ -1,3 +1,4 @@ +# Temporary workaround until nats-client and nats-client-async are published to opam. # package source nats-client git+https://github.com/Hebilicious/nats-ml.git#fbc597311197b8526af13c97e6c6364955203a30 nats-client-async git+https://github.com/Hebilicious/nats-ml.git#fbc597311197b8526af13c97e6c6364955203a30 diff --git a/scripts/pin-external-packages.sh b/scripts/pin-external-packages.sh index df44b77323..b4e777beda 100755 --- a/scripts/pin-external-packages.sh +++ b/scripts/pin-external-packages.sh @@ -11,7 +11,9 @@ cd "$REPO_ROOT" # keep the repo-managed submodules in sync first git submodule sync && git submodule update --init --recursive -# then install externally pinned opam packages that are not tracked in opam.export +# Temporary workaround until nats-client and nats-client-async are published to +# opam. After they are published, remove this pin flow and install the released +# packages via opam.export / normal opam dependency resolution instead. while IFS=' ' read -r package source; do case "$package" in ''|\#*) diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index e3d8fd01c6..a00a739dbf 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -9,6 +9,9 @@ cd "$SCRIPT_DIR/.." [[ "$IN_NIX_SHELL$CI$BUILDKITE" == "" ]] || exit 0 sum="$( + # Temporary workaround until nats-client and nats-client-async are + # published to opam. The switch/cache key must include the pin manifest + # while the repo still depends on GitHub pins. cat opam.export scripts/external-opam-pins.txt \ | cksum \ | grep -oE '^\S*' @@ -39,4 +42,6 @@ fi ln -s "${switch_dir}" _opam +# Temporary workaround until nats-client and nats-client-async are published to +# opam and can move into normal opam dependency resolution. ./scripts/pin-external-packages.sh diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index ec724ed560..dcb6a97fb3 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -14,7 +14,8 @@ Think of the sequencer as the conductor of an orchestra in Zeko. It plays a vita The repo-managed OCaml setup imports `opam.export` and then runs `./scripts/pin-external-packages.sh`, which pins `nats-client` and `nats-client-async` from GitHub into the switch. No `nats-ml` submodule checkout -is required. +is required. This GitHub pin path is temporary and should be removed once both +packages are published to opam. ```bash DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer From 5b9c70a2ded0fced0117fb2b06e45108f6e94497 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 8 Apr 2026 04:41:36 +0700 Subject: [PATCH 04/51] Refactor explorer modules and dedicated tests --- .github/workflows/ci.yaml | 2 + scripts/pin-external-packages.sh | 3 + scripts/update-opam-switch.sh | 3 + src/app/zeko/sequencer/explorer/dune | 26 +++++- .../explorer/explorer_backfill_service.ml | 81 ++----------------- .../explorer_backfill_service_tests.ml | 75 +++++++++++++++++ .../{lib => explorer}/explorer_events.ml | 62 +------------- .../explorer/explorer_events_tests.ml | 65 +++++++++++++++ src/app/zeko/sequencer/explorer/run.ml | 3 + src/app/zeko/sequencer/lib/dune | 3 + src/app/zeko/sequencer/lib/zeko_sequencer.ml | 17 +--- .../sequencer/lib/zeko_sequencer_tests.ml | 17 ++++ src/app/zeko/sequencer/run.ml | 3 + 13 files changed, 211 insertions(+), 149 deletions(-) create mode 100644 src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml rename src/app/zeko/sequencer/{lib => explorer}/explorer_events.ml (59%) create mode 100644 src/app/zeko/sequencer/explorer/explorer_events_tests.ml create mode 100644 src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9e05a5c319..5d32b78f56 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,3 +1,5 @@ +# Builds and tests Zeko in CI using the exported opam toolchain plus temporary +# external package pins required by this branch. --- name: CI diff --git a/scripts/pin-external-packages.sh b/scripts/pin-external-packages.sh index b4e777beda..e42c1d2661 100755 --- a/scripts/pin-external-packages.sh +++ b/scripts/pin-external-packages.sh @@ -1,5 +1,8 @@ #!/bin/sh +# Installs temporary GitHub-backed opam pins that the repo needs until the +# corresponding packages are published to opam. + set -eu SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index a00a739dbf..10d2008d4c 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash +# Reuses a cached repo-local opam switch keyed by the exported toolchain plus +# any temporary external pin manifest entries. + set -eo pipefail SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" diff --git a/src/app/zeko/sequencer/explorer/dune b/src/app/zeko/sequencer/explorer/dune index b7c00ceddf..d6a097a1a4 100644 --- a/src/app/zeko/sequencer/explorer/dune +++ b/src/app/zeko/sequencer/explorer/dune @@ -1,15 +1,39 @@ +; Explorer support libraries, test modules, and the backfill service executable. + +(library + (name explorer_events) + (inline_tests) + (modules explorer_events explorer_events_tests) + (libraries + da_layer + nats_client + nats_client_async + ;; mina ;; + mina_base + snark_params + ;; opam libraries ;; + yojson + core_kernel + ppx_inline_test.config) + (preprocess + (pps ppx_jane ppx_mina))) + (library (name explorer_backfill_service) (inline_tests) - (modules explorer_backfill_service) + (modules explorer_backfill_service explorer_backfill_service_tests) (libraries + explorer_events sequencer_lib da_layer zeko_constants init cli_lib + nats_client + nats_client_async ;; mina ;; mina_base + snark_params ;; opam libraries ;; cohttp cohttp-async diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index 1d1bf169aa..329dff4cde 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -1,3 +1,7 @@ +(* Runs the standalone explorer backfill service: tracks in-memory backfill + jobs, republishes DA diffs to NATS, and exposes GraphQL query/mutation/ + subscription endpoints for job control and progress streaming. *) + open Core open Async open Sequencer_lib @@ -476,7 +480,7 @@ module Sse = struct let%bind () = Pipe.write body_writer (next_event payload) in write_stream body_writer next - let callback t _conn req body = +let callback t _conn req body = let open Deferred.Let_syntax in let%bind body = Cohttp_async.Body.to_string body in match%bind execute_subscription t req body with @@ -492,78 +496,3 @@ module Sse = struct () >>| fun response -> `Response response end - -let%test_unit "status strings are stable" = - [%test_eq: string] (string_of_job_status Queued) "queued" ; - [%test_eq: string] (string_of_job_status Completed) "completed" - -let%test_unit "backfill kind uses genesis replay only for the first genesis diff" = - [%test_eq: Explorer_events.Transaction_kind.t] - (backfill_kind ~from_hash:genesis_hash ~index:0) - Explorer_events.Transaction_kind.Genesis_replay ; - [%test_eq: Explorer_events.Transaction_kind.t] - (backfill_kind ~from_hash:Ledger_hash.empty_hash ~index:1) - Explorer_events.Transaction_kind.Sync_replay - -let%test_unit "health snapshot includes the instance id" = - let t = - { logger = Logger.create () - ; da_config = Da_layer.Client.Config.of_string_list [] - ; nats_client = None - ; jobs = String.Table.create () - ; instance_id = "instance-1" - ; started_at = Time.epoch - } - in - [%test_eq: string] (health t).instance_id "instance-1" - -let%test_unit "invalid backfill hash returns an error instead of raising" = - [%test_eq: bool] - (Result.is_error - (start_backfill_from_strings - { logger = Logger.create () - ; da_config = Da_layer.Client.Config.of_string_list [] - ; nats_client = None - ; jobs = String.Table.create () - ; instance_id = "instance-1" - ; started_at = Time.epoch - } - ~from_hash:"bad-hash" ~to_hash:"also-bad" ) ) - true - -let%test_unit "backfill publish reports dropped when no NATS client is present" = - let diff = - Explorer_events.build_live_diff ~logger:(Logger.create ()) - ~diff: - (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash - ~changed_accounts:[] ~command_with_action_step_flags:None ) - ~acc_set_root:Snark_params.Tick.Field.zero - in - let t = - { logger = Logger.create () - ; da_config = Da_layer.Client.Config.of_string_list [] - ; nats_client = None - ; jobs = String.Table.create () - ; instance_id = "instance-1" - ; started_at = Time.epoch - } - in - [%test_eq: Nats_client_async.publish_result] - (publish_backfill_diff t ~from_hash:Ledger_hash.empty_hash ~index:0 - ~target_ledger_hash:Ledger_hash.empty_hash diff ) - `Dropped - -let%test_unit "sse next event carries GraphQL JSON" = - let event = - Sse.next_event - (`Assoc - [ ( "data" - , `Assoc - [ ( "backfillProgress" - , `Assoc [ ("id", `String "job-1") ] ) - ] ) - ] ) - in - [%test_eq: bool] - (String.is_substring event ~substring:"backfillProgress") - true diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml new file mode 100644 index 0000000000..7c4e38dd97 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml @@ -0,0 +1,75 @@ +(* Keeps the backfill service tests in a dedicated module so the implementation + file stays focused on job execution and API wiring. *) + +open Core +open Async +open Sequencer_lib +open Mina_base + +let test_service () : Explorer_backfill_service.t = + { logger = Logger.create () + ; da_config = Da_layer.Client.Config.of_string_list [] + ; nats_client = None + ; jobs = String.Table.create () + ; instance_id = "instance-1" + ; started_at = Time.epoch + } + +let%test_unit "status strings are stable" = + [%test_eq: string] + (Explorer_backfill_service.string_of_job_status Explorer_backfill_service.Queued) + "queued" ; + [%test_eq: string] + (Explorer_backfill_service.string_of_job_status Explorer_backfill_service.Completed) + "completed" + +let%test_unit "backfill kind uses genesis replay only for the first genesis diff" = + [%test_eq: Explorer_events.Transaction_kind.t] + (Explorer_backfill_service.backfill_kind + ~from_hash:Explorer_backfill_service.genesis_hash ~index:0 ) + Explorer_events.Transaction_kind.Genesis_replay ; + [%test_eq: Explorer_events.Transaction_kind.t] + (Explorer_backfill_service.backfill_kind ~from_hash:Ledger_hash.empty_hash + ~index:1 ) + Explorer_events.Transaction_kind.Sync_replay + +let%test_unit "health snapshot includes the instance id" = + [%test_eq: string] + (Explorer_backfill_service.health (test_service ())).instance_id + "instance-1" + +let%test_unit "invalid backfill hash returns an error instead of raising" = + [%test_eq: bool] + (Result.is_error + (Explorer_backfill_service.start_backfill_from_strings (test_service ()) + ~from_hash:"bad-hash" ~to_hash:"also-bad" ) ) + true + +let%test_unit "backfill publish reports dropped when no NATS client is present" = + let diff = + Explorer_events.build_live_diff ~logger:(Logger.create ()) + ~diff: + (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash + ~changed_accounts:[] ~command_with_action_step_flags:None ) + ~acc_set_root:Snark_params.Tick.Field.zero + in + [%test_eq: Nats_client_async.publish_result] + (Explorer_backfill_service.publish_backfill_diff (test_service ()) + ~from_hash:Ledger_hash.empty_hash ~index:0 + ~target_ledger_hash:Ledger_hash.empty_hash diff ) + `Dropped + +let%test_unit "sse next event carries GraphQL JSON" = + let event = + Explorer_backfill_service.Sse.next_event + (`Assoc + [ ( "data" + , `Assoc + [ ( "backfillProgress" + , `Assoc [ ("id", `String "job-1") ] ) + ] ) + ] ) + in + [%test_eq: bool] + (String.is_substring event ~substring:"backfillProgress") + true diff --git a/src/app/zeko/sequencer/lib/explorer_events.ml b/src/app/zeko/sequencer/explorer/explorer_events.ml similarity index 59% rename from src/app/zeko/sequencer/lib/explorer_events.ml rename to src/app/zeko/sequencer/explorer/explorer_events.ml index bf3be413de..801b208d4e 100644 --- a/src/app/zeko/sequencer/lib/explorer_events.ml +++ b/src/app/zeko/sequencer/explorer/explorer_events.ml @@ -1,3 +1,6 @@ +(* Defines the shared explorer-facing NATS subjects, payload encoding, and + publishing helpers used by both the live sequencer path and backfill jobs. *) + open Core_kernel open Mina_base @@ -111,62 +114,3 @@ let publish_finality sink ~logger ~status ~source_ledger_hash ~target_ledger_has let publish_health sink ~logger ~component ~instance_id ~status = publish sink @@ build_health_message ~logger ~component ~instance_id ~status - -let find_assoc_exn json key = - match json with - | `Assoc fields -> - List.Assoc.find_exn fields key ~equal:String.equal - | _ -> - failwith "Expected JSON object" - -let%test_unit "transaction payload encoding keeps contract fields" = - let target_ledger_hash = Ledger_hash.empty_hash in - let diff = - build_live_diff ~logger:(Logger.create ()) - ~diff: - (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash - ~changed_accounts:[] ~command_with_action_step_flags:None ) - ~acc_set_root:Snark_params.Tick.Field.zero - in - let message = - build_transaction_message ~kind:Transaction_kind.User_command - ~target_ledger_hash ~genesis:false ~diff - in - [%test_eq: string] message.subject Subject.transactions ; - [%test_eq: (string * string) list] message.headers - (nats_msg_id_headers target_ledger_hash) ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "kind") - (`String "user_command") ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "genesis") - (`Bool false) - -let%test_unit "finality payload encoding keeps hashes and status" = - let message = - build_finality_message ~logger:(Logger.create ()) - ~status:Finality_status.Committed - ~source_ledger_hash:Ledger_hash.empty_hash - ~target_ledger_hash:Ledger_hash.empty_hash - in - [%test_eq: string] message.subject Subject.finality ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "status") - (`String "committed") - -let%test_unit "health payload encoding includes component and instance id" = - let message = - build_health_message ~logger:(Logger.create ()) ~component:"sequencer" - ~instance_id:"instance-1" ~status:"ok" - in - [%test_eq: string] message.subject Subject.health ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "component") - (`String "sequencer") ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "instance_id") - (`String "instance-1") - -let%test_unit "nats msg id uses target ledger hash" = - [%test_eq: string] (nats_msg_id Ledger_hash.empty_hash) - (Ledger_hash.to_decimal_string Ledger_hash.empty_hash) diff --git a/src/app/zeko/sequencer/explorer/explorer_events_tests.ml b/src/app/zeko/sequencer/explorer/explorer_events_tests.ml new file mode 100644 index 0000000000..329b0dd5cb --- /dev/null +++ b/src/app/zeko/sequencer/explorer/explorer_events_tests.ml @@ -0,0 +1,65 @@ +(* Covers the explorer event payload contract and message id generation in a + dedicated test module instead of mixing tests into the implementation file. *) + +open Core_kernel +open Mina_base + +let find_assoc_exn json key = + match json with + | `Assoc fields -> + List.Assoc.find_exn fields key ~equal:String.equal + | _ -> + failwith "Expected JSON object" + +let%test_unit "transaction payload encoding keeps contract fields" = + let target_ledger_hash = Ledger_hash.empty_hash in + let diff = + Explorer_events.build_live_diff ~logger:(Logger.create ()) + ~diff: + (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash + ~changed_accounts:[] ~command_with_action_step_flags:None ) + ~acc_set_root:Snark_params.Tick.Field.zero + in + let message = + Explorer_events.build_transaction_message + ~kind:Explorer_events.Transaction_kind.User_command + ~target_ledger_hash ~genesis:false ~diff + in + [%test_eq: string] message.subject Explorer_events.Subject.transactions ; + [%test_eq: (string * string) list] message.headers + (Explorer_events.nats_msg_id_headers target_ledger_hash) ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "kind") + (`String "user_command") ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "genesis") + (`Bool false) + +let%test_unit "finality payload encoding keeps hashes and status" = + let message = + Explorer_events.build_finality_message ~logger:(Logger.create ()) + ~status:Explorer_events.Finality_status.Committed + ~source_ledger_hash:Ledger_hash.empty_hash + ~target_ledger_hash:Ledger_hash.empty_hash + in + [%test_eq: string] message.subject Explorer_events.Subject.finality ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "status") + (`String "committed") + +let%test_unit "health payload encoding includes component and instance id" = + let message = + Explorer_events.build_health_message ~logger:(Logger.create ()) + ~component:"sequencer" ~instance_id:"instance-1" ~status:"ok" + in + [%test_eq: string] message.subject Explorer_events.Subject.health ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "component") + (`String "sequencer") ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn message.payload "instance_id") + (`String "instance-1") + +let%test_unit "nats msg id uses target ledger hash" = + [%test_eq: string] (Explorer_events.nats_msg_id Ledger_hash.empty_hash) + (Ledger_hash.to_decimal_string Ledger_hash.empty_hash) diff --git a/src/app/zeko/sequencer/explorer/run.ml b/src/app/zeko/sequencer/explorer/run.ml index c49ea24ca6..5b37f853a4 100644 --- a/src/app/zeko/sequencer/explorer/run.ml +++ b/src/app/zeko/sequencer/explorer/run.ml @@ -1,3 +1,6 @@ +(* Boots the standalone explorer backfill HTTP service and routes GraphQL query + traffic plus GraphQL-SSE subscription traffic to the backfill service. *) + open Core open Async open Cli_lib diff --git a/src/app/zeko/sequencer/lib/dune b/src/app/zeko/sequencer/lib/dune index d02417c80d..17efcf95ea 100644 --- a/src/app/zeko/sequencer/lib/dune +++ b/src/app/zeko/sequencer/lib/dune @@ -1,3 +1,5 @@ +; Core sequencer library plus dedicated inline test modules. + (library (name sequencer_lib) (inline_tests) @@ -11,6 +13,7 @@ indexed_merkle_tree utils gql_client + explorer_events nats_client nats_client_async ;; mina ;; diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index aab72d45fe..9a9aa264d1 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -1,3 +1,7 @@ +(* Implements the Zeko sequencer runtime, including transaction application, + DA-layer synchronization, proof/commit orchestration, and explorer event + publishing hooks. *) + open Core_kernel open Async_kernel open Mina_base @@ -1233,16 +1237,3 @@ module Sequencer = struct let () = run_health_heartbeat t in return t end - -let%test_unit "genesis sync labels the first replayed diff as genesis" = - [%test_eq: bool] - (Sequencer.replay_genesis_flag ~source:`Genesis ~current_chunk:0 - ~current_diff:0 ) - true - -let%test_unit "checkpoint sync never relabels replayed diffs as genesis" = - [%test_eq: bool] - (Sequencer.replay_genesis_flag - ~source:(`Specific Ledger_hash.empty_hash) - ~current_chunk:0 ~current_diff:0 ) - false diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml b/src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml new file mode 100644 index 0000000000..dac4177f98 --- /dev/null +++ b/src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml @@ -0,0 +1,17 @@ +(* Keeps focused sequencer inline tests in a separate module so the main + sequencer implementation file stays centered on runtime behavior. *) + +open Mina_base + +let%test_unit "genesis sync labels the first replayed diff as genesis" = + [%test_eq: bool] + (Zeko_sequencer.Sequencer.replay_genesis_flag ~source:`Genesis + ~current_chunk:0 ~current_diff:0 ) + true + +let%test_unit "checkpoint sync never relabels replayed diffs as genesis" = + [%test_eq: bool] + (Zeko_sequencer.Sequencer.replay_genesis_flag + ~source:(`Specific Ledger_hash.empty_hash) + ~current_chunk:0 ~current_diff:0 ) + false diff --git a/src/app/zeko/sequencer/run.ml b/src/app/zeko/sequencer/run.ml index 797df21533..f2788841b6 100644 --- a/src/app/zeko/sequencer/run.ml +++ b/src/app/zeko/sequencer/run.ml @@ -1,3 +1,6 @@ +(* Boots the main Zeko sequencer HTTP service and threads runtime CLI options, + including optional explorer NATS publishing, into the sequencer runtime. *) + open Core open Async open Sequencer_lib From b3d1844579a5ab818acba73c58444d5ee3b42f9a Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 8 Apr 2026 04:46:19 +0700 Subject: [PATCH 05/51] Clarify temporary opam pin comments --- .github/workflows/ci.yaml | 7 ++++--- scripts/pin-external-packages.sh | 9 +++++---- scripts/update-opam-switch.sh | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5d32b78f56..f765bc5eb0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -78,9 +78,10 @@ jobs: run: | opam switch import opam.export --yes eval "$(opam env)" - # Temporary workaround until nats-client and nats-client-async are - # published to opam. After release, remove this script and install - # the published packages through normal opam dependency resolution. + # This existing repo bootstrap script now also applies temporary + # GitHub pins for nats-client and nats-client-async. Once those + # packages are published to opam, keep this script but drop the + # temporary pin manifest and pin step from it. ./scripts/pin-external-packages.sh opam clean --logs -cs --quiet # Retrieve _build dir from cache before initiating a build diff --git a/scripts/pin-external-packages.sh b/scripts/pin-external-packages.sh index e42c1d2661..7718865bff 100755 --- a/scripts/pin-external-packages.sh +++ b/scripts/pin-external-packages.sh @@ -1,7 +1,8 @@ #!/bin/sh -# Installs temporary GitHub-backed opam pins that the repo needs until the -# corresponding packages are published to opam. +# Runs the existing external package bootstrap flow for this repo. For now, +# that also includes temporary GitHub-backed opam pins for nats-client and +# nats-client-async until those packages are published to opam. set -eu @@ -15,8 +16,8 @@ cd "$REPO_ROOT" git submodule sync && git submodule update --init --recursive # Temporary workaround until nats-client and nats-client-async are published to -# opam. After they are published, remove this pin flow and install the released -# packages via opam.export / normal opam dependency resolution instead. +# opam. After they are published, keep this script but remove these temporary +# pin entries and let normal opam dependency resolution install the releases. while IFS=' ' read -r package source; do case "$package" in ''|\#*) diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index 10d2008d4c..805081cd91 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -45,6 +45,6 @@ fi ln -s "${switch_dir}" _opam -# Temporary workaround until nats-client and nats-client-async are published to -# opam and can move into normal opam dependency resolution. +# This existing bootstrap step also applies temporary GitHub pins for +# nats-client and nats-client-async until those packages are published to opam. ./scripts/pin-external-packages.sh From 4a7d5fc7a3a5c4757cf4b35299abcc39eda38525 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 8 Apr 2026 05:11:38 +0700 Subject: [PATCH 06/51] Refine explorer backfill structure and gherkin tests --- .prototools | 5 + src/app/zeko/sequencer/README.md | 19 +- src/app/zeko/sequencer/explorer/dune | 17 +- .../explorer/explorer-indexing-plan.md | 6 +- .../explorer/explorer_backfill_graphql.ml | 107 +++++ .../{run.ml => explorer_backfill_server.ml} | 18 +- .../explorer/explorer_backfill_service.ml | 221 ++------- .../explorer_backfill_service_tests.ml | 75 --- .../explorer/explorer_backfill_sse.ml | 80 ++++ .../sequencer/explorer/explorer_events.ml | 29 +- .../explorer/explorer_events_tests.ml | 65 --- .../explorer/tests/contract_models.ml | 90 ++++ src/app/zeko/sequencer/explorer/tests/dune | 26 ++ .../explorer/tests/explorer_gherkin_tests.ml | 441 ++++++++++++++++++ .../explorer/tests/feature_parser.ml | 50 ++ .../explorer/tests/features/backfill.feature | 69 +++ .../consumer-rollback-detection.feature | 22 + .../tests/features/deduplication.feature | 7 + .../tests/features/finality-events.feature | 18 + .../tests/features/health-heartbeat.feature | 10 + .../tests/features/nats-downtime.feature | 22 + .../features/transaction-publishing.feature | 43 ++ src/app/zeko/sequencer/lib/zeko_sequencer.ml | 7 +- 23 files changed, 1091 insertions(+), 356 deletions(-) create mode 100644 .prototools create mode 100644 src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml rename src/app/zeko/sequencer/explorer/{run.ml => explorer_backfill_server.ml} (77%) delete mode 100644 src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml create mode 100644 src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml delete mode 100644 src/app/zeko/sequencer/explorer/explorer_events_tests.ml create mode 100644 src/app/zeko/sequencer/explorer/tests/contract_models.ml create mode 100644 src/app/zeko/sequencer/explorer/tests/dune create mode 100644 src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml create mode 100644 src/app/zeko/sequencer/explorer/tests/feature_parser.ml create mode 100644 src/app/zeko/sequencer/explorer/tests/features/backfill.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/deduplication.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/finality-events.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature diff --git a/.prototools b/.prototools new file mode 100644 index 0000000000..9ec62b997b --- /dev/null +++ b/.prototools @@ -0,0 +1,5 @@ +[plugins.tools] +ocaml = "github://Hebilicious/proto-ocaml@v0.1.2" + +[tools.ocaml] +version = "4.14.0" diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index dcb6a97fb3..61a6b0d13a 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -84,9 +84,11 @@ transaction message includes `Nats-Msg-Id: `. `zeko.health` carries: -- `component` +- `service` - `instance_id` - `status` +- `last_published_hash` +- `unproved_hash` - `timestamp` Run help to see the options: @@ -110,13 +112,18 @@ Run it with: ```bash export DUNE_PROFILE=devnet -dune exec ./explorer/run.exe -- \ +dune exec ./explorer/explorer_backfill_server.exe -- \ -p \ --da-node \ --nats-url ``` -The GraphQL HTTP endpoint stays on `/graphql`. +The backfill API is a separate process from the sequencer GraphQL API. By +default the sequencer listens on `8080` and the standalone backfill server +listens on `8090`, so they do not conflict unless you explicitly bind both to +the same port. + +The backfill GraphQL HTTP endpoint stays on `/graphql`. The service requires `--nats-url` because each backfill republishes historical diffs into NATS. @@ -161,6 +168,12 @@ responses as GraphQL-SSE events. - `instanceId` - `startedAt` +## Explorer acceptance tests + +The explorer-specific tests now live under +`src/app/zeko/sequencer/explorer/tests/` and use copied `.feature` files from +the explorer spike as the main test inventory. + ## Deploy rollup contract to L1 The following script deploys the rollup contract on the L1 with the initial state, which is the genesis ledger of the rollup. diff --git a/src/app/zeko/sequencer/explorer/dune b/src/app/zeko/sequencer/explorer/dune index d6a097a1a4..8da41d4017 100644 --- a/src/app/zeko/sequencer/explorer/dune +++ b/src/app/zeko/sequencer/explorer/dune @@ -1,9 +1,8 @@ -; Explorer support libraries, test modules, and the backfill service executable. +; Explorer support libraries and the standalone backfill server executable. (library (name explorer_events) - (inline_tests) - (modules explorer_events explorer_events_tests) + (modules explorer_events) (libraries da_layer nats_client @@ -20,8 +19,10 @@ (library (name explorer_backfill_service) - (inline_tests) - (modules explorer_backfill_service explorer_backfill_service_tests) + (modules + explorer_backfill_service + explorer_backfill_graphql + explorer_backfill_sse) (libraries explorer_events sequencer_lib @@ -42,15 +43,15 @@ uri core core_kernel - async + async async_unix ppx_inline_test.config) (preprocess (pps ppx_let ppx_jane ppx_mina))) (executable - (name run) - (modules run) + (name explorer_backfill_server) + (modules explorer_backfill_server) (libraries explorer_backfill_service sequencer_lib diff --git a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md index a6169500a1..8880b15ef0 100644 --- a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md +++ b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md @@ -114,14 +114,16 @@ Encoding rules: Fields: -- `component` +- `service` - `instance_id` - `status` +- `last_published_hash` +- `unproved_hash` - `timestamp` Encoding rules: -- `component = "sequencer"` +- `service = "sequencer-nats-publisher"` - `status = "ok"` Result: diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml new file mode 100644 index 0000000000..f377f161de --- /dev/null +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml @@ -0,0 +1,107 @@ +(* Defines the GraphQL schema for the standalone backfill server on top of the + Explorer_backfill_service job and health snapshots. *) + +open Core +open Async + +open Graphql_async.Schema + +let backfill_job_typ : (Explorer_backfill_service.t, Explorer_backfill_service.job_snapshot) typ + = + obj "BackfillJob" + ~fields:(fun _ -> + [ field "id" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.id) + ; field "fromHash" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.from_hash) + ; field "toHash" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.to_hash) + ; field "status" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.status) + ; field "diffsPublished" ~typ:(non_null int) ~args:[] + ~resolve:(fun _ job -> job.diffs_published) + ; field "error" ~typ:string ~args:[] + ~resolve:(fun _ job -> job.error) + ; field "createdAt" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ job -> job.created_at) + ; field "startedAt" ~typ:string ~args:[] + ~resolve:(fun _ job -> job.started_at) + ; field "finishedAt" ~typ:string ~args:[] + ~resolve:(fun _ job -> job.finished_at) + ] ) + +let progress_typ : (Explorer_backfill_service.t, Explorer_backfill_service.progress_snapshot) typ + = + obj "BackfillProgress" + ~fields:(fun _ -> + [ field "id" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ progress -> progress.id) + ; field "status" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ progress -> progress.status) + ; field "diffsPublished" ~typ:(non_null int) ~args:[] + ~resolve:(fun _ progress -> progress.diffs_published) + ; field "error" ~typ:string ~args:[] + ~resolve:(fun _ progress -> progress.error) + ] ) + +let health_typ : (Explorer_backfill_service.t, Explorer_backfill_service.health_snapshot) typ + = + obj "Health" + ~fields:(fun _ -> + [ field "ok" ~typ:(non_null bool) ~args:[] + ~resolve:(fun _ value -> value.ok) + ; field "instanceId" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ value -> value.instance_id) + ; field "startedAt" ~typ:(non_null string) ~args:[] + ~resolve:(fun _ value -> value.started_at) + ] ) + +let query_fields = + [ io_field "backfillJob" ~typ:backfill_job_typ + ~args:Arg.[ arg "id" ~typ:(non_null string) ] + ~resolve:(fun { ctx; _ } () id -> + return + (Option.map (Explorer_backfill_service.find_job ctx id) + ~f:Explorer_backfill_service.snapshot ) ) + ; io_field "health" ~typ:(non_null health_typ) ~args:[] + ~resolve:(fun { ctx; _ } () () -> + return (Explorer_backfill_service.health ctx)) + ] + +let mutation_fields = + [ io_field "backfill" ~typ:(non_null backfill_job_typ) + ~args: + Arg. + [ arg "fromHash" ~typ:(non_null string) + ; arg "toHash" ~typ:(non_null string) + ] + ~resolve:(fun { ctx; _ } () from_hash to_hash -> + let snapshot = + match + Explorer_backfill_service.start_backfill_from_strings ctx ~from_hash + ~to_hash + with + | Ok job -> + Explorer_backfill_service.snapshot job + | Error err -> + Explorer_backfill_service.failed_job_snapshot_from_strings + ~from_hash ~to_hash (Error.to_string_hum err) + in + return (Ok snapshot)) + ] + +let subscription_fields = + [ subscription_field "backfillProgress" ~typ:(non_null progress_typ) + ~args:Arg.[ arg "id" ~typ:(non_null string) ] + ~resolve:(fun { ctx; _ } id -> + match Explorer_backfill_service.subscribe_progress ctx ~id with + | Ok progress -> + Deferred.Result.return progress + | Error err -> + Deferred.return (Error (Error.to_string_hum err))) + ] + +let schema = + Graphql_async.Schema.( + schema query_fields ~mutations:mutation_fields + ~subscriptions:subscription_fields) diff --git a/src/app/zeko/sequencer/explorer/run.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_server.ml similarity index 77% rename from src/app/zeko/sequencer/explorer/run.ml rename to src/app/zeko/sequencer/explorer/explorer_backfill_server.ml index 5b37f853a4..c388a3753b 100644 --- a/src/app/zeko/sequencer/explorer/run.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_server.ml @@ -1,5 +1,5 @@ -(* Boots the standalone explorer backfill HTTP service and routes GraphQL query - traffic plus GraphQL-SSE subscription traffic to the backfill service. *) +(* Boots the standalone explorer backfill HTTP server and routes GraphQL query + traffic plus GraphQL-SSE subscription traffic to the backfill modules. *) open Core open Async @@ -18,12 +18,12 @@ let run ~logger ~port ~da_config ~nats_url () = let graphql_callback = Graphql_cohttp_async.make_callback (fun ~with_seq_no:_ _req -> service) - Explorer_backfill_service.Gql.schema + Explorer_backfill_graphql.schema in let callback ~body _sock req = match Uri.path (Cohttp.Request.uri req) with | "/graphql/stream" -> - Explorer_backfill_service.Sse.callback service () req body + Explorer_backfill_sse.callback service () req body | _ -> graphql_callback () req body in @@ -38,15 +38,19 @@ let run ~logger ~port ~da_config ~nats_url () = |> Deferred.ignore_m |> don't_wait_for in Shutdown.at_shutdown (fun () -> Explorer_backfill_service.shutdown service) ; - [%log info] "Explorer backfill service listening on port %d" port ; + [%log info] "Explorer backfill server listening on port %d" port ; never_returns (Async.Scheduler.go ()) let () = - Command.basic ~summary:"Zeko explorer backfill service" + Command.basic ~summary:"Zeko explorer backfill server" (let%map_open.Command log_json = Flag.Log.json and log_level = Flag.Log.level and port = - flag "-p" (optional_with_default 8090 int) ~doc:"int Port to listen on" + flag "-p" + (optional_with_default 8090 int) + ~doc: + "int Port for the standalone backfill GraphQL API; keep distinct \ + from the sequencer GraphQL port when both run on the same host" and da_nodes = flag "--da-node" (listed string) ~doc:"string Address of the DA node, can be supplied multiple times" diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index 329dff4cde..f1dc5a6e73 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -1,6 +1,6 @@ -(* Runs the standalone explorer backfill service: tracks in-memory backfill - jobs, republishes DA diffs to NATS, and exposes GraphQL query/mutation/ - subscription endpoints for job control and progress streaming. *) +(* Owns explorer backfill job state and execution: tracks in-memory jobs, + republishes DA diffs to NATS, and exposes the snapshots used by the GraphQL + and SSE transport layers. *) open Core open Async @@ -303,196 +303,47 @@ let parse_ledger_hash hash = |> Or_error.map_error ~f:(fun err -> Error.tag_arg err "Invalid ledger hash" hash String.sexp_of_t ) +let create_job ?error ?started_at ?finished_at ~status ~from_hash ~to_hash () = + { id = Uuid.to_string (Uuid_unix.create ()) + ; from_hash + ; to_hash + ; status + ; diffs_published = 0 + ; error + ; created_at = Time.now () + ; started_at + ; finished_at + ; subscribers = ref [] + } + +let register_job t job = + Hashtbl.set t.jobs ~key:job.id ~data:job ; + notify_subscribers job ; + job + let start_backfill t ~from_hash ~to_hash = let job = - { id = Uuid.to_string (Uuid_unix.create ()) - ; from_hash - ; to_hash - ; status = Queued - ; diffs_published = 0 - ; error = None - ; created_at = Time.now () - ; started_at = None - ; finished_at = None - ; subscribers = ref [] - } + create_job ~status:Queued ~from_hash ~to_hash () in - Hashtbl.set t.jobs ~key:job.id ~data:job ; - notify_subscribers job ; + let job = register_job t job in run_job t job ; job +let failed_job_snapshot_from_strings ~from_hash ~to_hash error = + let now = Time.now () in + { id = Uuid.to_string (Uuid_unix.create ()) + ; from_hash + ; to_hash + ; status = string_of_job_status Failed + ; diffs_published = 0 + ; error = Some error + ; created_at = timestamp_string now + ; started_at = None + ; finished_at = Some (timestamp_string now) + } + let start_backfill_from_strings t ~from_hash ~to_hash = let open Or_error.Let_syntax in let%bind from_hash = parse_ledger_hash from_hash in let%map to_hash = parse_ledger_hash to_hash in start_backfill t ~from_hash ~to_hash - -module Gql = struct - open Graphql_async.Schema - - let backfill_job_typ : (t, job_snapshot) typ = - obj "BackfillJob" - ~fields:(fun _ -> - [ field "id" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.id) - ; field "fromHash" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.from_hash) - ; field "toHash" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.to_hash) - ; field "status" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.status) - ; field "diffsPublished" ~typ:(non_null int) ~args:[] - ~resolve:(fun _ job -> job.diffs_published) - ; field "error" ~typ:string ~args:[] - ~resolve:(fun _ job -> job.error) - ; field "createdAt" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.created_at) - ; field "startedAt" ~typ:string ~args:[] - ~resolve:(fun _ job -> job.started_at) - ; field "finishedAt" ~typ:string ~args:[] - ~resolve:(fun _ job -> job.finished_at) - ] ) - - let progress_typ : (t, progress_snapshot) typ = - obj "BackfillProgress" - ~fields:(fun _ -> - [ field "id" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ progress -> progress.id) - ; field "status" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ progress -> progress.status) - ; field "diffsPublished" ~typ:(non_null int) ~args:[] - ~resolve:(fun _ progress -> progress.diffs_published) - ; field "error" ~typ:string ~args:[] - ~resolve:(fun _ progress -> progress.error) - ] ) - - let health_typ : (t, health_snapshot) typ = - obj "Health" - ~fields:(fun _ -> - [ field "ok" ~typ:(non_null bool) ~args:[] - ~resolve:(fun _ value -> value.ok) - ; field "instanceId" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ value -> value.instance_id) - ; field "startedAt" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ value -> value.started_at) - ] ) - - let query_fields = - [ io_field "backfillJob" ~typ:backfill_job_typ - ~args:Arg.[ arg "id" ~typ:(non_null string) ] - ~resolve:(fun { ctx; _ } () id -> - return (Option.map (find_job ctx id) ~f:snapshot)) - ; io_field "health" ~typ:(non_null health_typ) ~args:[] - ~resolve:(fun { ctx; _ } () () -> return (health ctx)) - ] - - let mutation_fields = - [ io_field "backfill" ~typ:(non_null backfill_job_typ) - ~args: - Arg. - [ arg "fromHash" ~typ:(non_null string) - ; arg "toHash" ~typ:(non_null string) - ] - ~resolve:(fun { ctx; _ } () from_hash to_hash -> - match start_backfill_from_strings ctx ~from_hash ~to_hash with - | Ok job -> - return (Ok (snapshot job)) - | Error err -> - return (Error (Error.to_string_hum err))) - ] - - let subscription_fields = - [ subscription_field "backfillProgress" ~typ:(non_null progress_typ) - ~args:Arg.[ arg "id" ~typ:(non_null string) ] - ~resolve:(fun { ctx; _ } id -> - match subscribe_progress ctx ~id with - | Ok progress -> - Deferred.Result.return progress - | Error err -> - Deferred.return (Error (Error.to_string_hum err))) - ] - - let schema = - Graphql_async.Schema.( - schema query_fields ~mutations:mutation_fields - ~subscriptions:subscription_fields) -end - -module Sse = struct - let headers = - Cohttp.Header.of_list - [ ("Content-Type", "text/event-stream") - ; ("Cache-Control", "no-cache") - ; ("Connection", "keep-alive") - ] - - let next_event payload = - "event: next\ndata: " ^ Yojson.Basic.to_string payload ^ "\n\n" - - let complete_event = "event: complete\ndata: {}\n\n" - - let parse_request req body = - Init.Graphql_internal.Params.extract req body - |> Result.map_error ~f:Error.of_string - - let execute_subscription t req body = - let open Deferred.Let_syntax in - match parse_request req body with - | Error err -> - Deferred.return (Error err) - | Ok (query, variables, operation_name) -> ( - match Graphql_parser.parse query with - | Error err -> - Deferred.return (Error (Error.of_string err)) - | Ok doc -> - let%map result = - Graphql_async.Schema.execute Gql.schema t ?variables - ?operation_name doc - in - match result with - | Ok (`Stream stream) -> - Ok stream - | Ok (`Response _) -> - Error - (Error.of_string - "Expected a GraphQL subscription for /graphql/stream") - | Error err -> - Error - (Error.of_string - ("Invalid GraphQL subscription: " - ^ Yojson.Basic.to_string err ) ) ) - - let rec write_stream body_writer stream = - let open Deferred.Let_syntax in - match stream () with - | Seq.Nil -> - let%map () = Pipe.write body_writer complete_event in - Pipe.close body_writer - | Seq.Cons (payload, next) -> - let payload = - match payload with - | Ok payload -> - payload - | Error err -> - err - in - let%bind () = Pipe.write body_writer (next_event payload) in - write_stream body_writer next - -let callback t _conn req body = - let open Deferred.Let_syntax in - let%bind body = Cohttp_async.Body.to_string body in - match%bind execute_subscription t req body with - | Error err -> - Cohttp_async.Server.respond_string ~status:`Bad_request - (Error.to_string_hum err) - >>| fun response -> `Response response - | Ok stream -> - let body_reader, body_writer = Pipe.create () in - don't_wait_for (write_stream body_writer stream) ; - Cohttp_async.Server.respond ~headers - ~body:(Cohttp_async.Body.of_pipe body_reader) - () - >>| fun response -> `Response response -end diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml deleted file mode 100644 index 7c4e38dd97..0000000000 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service_tests.ml +++ /dev/null @@ -1,75 +0,0 @@ -(* Keeps the backfill service tests in a dedicated module so the implementation - file stays focused on job execution and API wiring. *) - -open Core -open Async -open Sequencer_lib -open Mina_base - -let test_service () : Explorer_backfill_service.t = - { logger = Logger.create () - ; da_config = Da_layer.Client.Config.of_string_list [] - ; nats_client = None - ; jobs = String.Table.create () - ; instance_id = "instance-1" - ; started_at = Time.epoch - } - -let%test_unit "status strings are stable" = - [%test_eq: string] - (Explorer_backfill_service.string_of_job_status Explorer_backfill_service.Queued) - "queued" ; - [%test_eq: string] - (Explorer_backfill_service.string_of_job_status Explorer_backfill_service.Completed) - "completed" - -let%test_unit "backfill kind uses genesis replay only for the first genesis diff" = - [%test_eq: Explorer_events.Transaction_kind.t] - (Explorer_backfill_service.backfill_kind - ~from_hash:Explorer_backfill_service.genesis_hash ~index:0 ) - Explorer_events.Transaction_kind.Genesis_replay ; - [%test_eq: Explorer_events.Transaction_kind.t] - (Explorer_backfill_service.backfill_kind ~from_hash:Ledger_hash.empty_hash - ~index:1 ) - Explorer_events.Transaction_kind.Sync_replay - -let%test_unit "health snapshot includes the instance id" = - [%test_eq: string] - (Explorer_backfill_service.health (test_service ())).instance_id - "instance-1" - -let%test_unit "invalid backfill hash returns an error instead of raising" = - [%test_eq: bool] - (Result.is_error - (Explorer_backfill_service.start_backfill_from_strings (test_service ()) - ~from_hash:"bad-hash" ~to_hash:"also-bad" ) ) - true - -let%test_unit "backfill publish reports dropped when no NATS client is present" = - let diff = - Explorer_events.build_live_diff ~logger:(Logger.create ()) - ~diff: - (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash - ~changed_accounts:[] ~command_with_action_step_flags:None ) - ~acc_set_root:Snark_params.Tick.Field.zero - in - [%test_eq: Nats_client_async.publish_result] - (Explorer_backfill_service.publish_backfill_diff (test_service ()) - ~from_hash:Ledger_hash.empty_hash ~index:0 - ~target_ledger_hash:Ledger_hash.empty_hash diff ) - `Dropped - -let%test_unit "sse next event carries GraphQL JSON" = - let event = - Explorer_backfill_service.Sse.next_event - (`Assoc - [ ( "data" - , `Assoc - [ ( "backfillProgress" - , `Assoc [ ("id", `String "job-1") ] ) - ] ) - ] ) - in - [%test_eq: bool] - (String.is_substring event ~substring:"backfillProgress") - true diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml new file mode 100644 index 0000000000..132eb404b7 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml @@ -0,0 +1,80 @@ +(* Implements the GraphQL-SSE transport for the standalone backfill server. *) + +open Core +open Async + +let headers = + Cohttp.Header.of_list + [ ("Content-Type", "text/event-stream") + ; ("Cache-Control", "no-cache") + ; ("Connection", "keep-alive") + ] + +let next_event payload = + "event: next\ndata: " ^ Yojson.Basic.to_string payload ^ "\n\n" + +let complete_event = "event: complete\ndata: {}\n\n" + +let parse_request req body = + Init.Graphql_internal.Params.extract req body + |> Result.map_error ~f:Error.of_string + +let execute_subscription t req body = + let open Deferred.Let_syntax in + match parse_request req body with + | Error err -> + Deferred.return (Error err) + | Ok (query, variables, operation_name) -> ( + match Graphql_parser.parse query with + | Error err -> + Deferred.return (Error (Error.of_string err)) + | Ok doc -> + let%map result = + Graphql_async.Schema.execute Explorer_backfill_graphql.schema t + ?variables ?operation_name doc + in + match result with + | Ok (`Stream stream) -> + Ok stream + | Ok (`Response _) -> + Error + (Error.of_string + "Expected a GraphQL subscription for /graphql/stream") + | Error err -> + Error + (Error.of_string + ("Invalid GraphQL subscription: " + ^ Yojson.Basic.to_string err ) ) ) + +let rec write_stream body_writer stream = + let open Deferred.Let_syntax in + match stream () with + | Seq.Nil -> + let%map () = Pipe.write body_writer complete_event in + Pipe.close body_writer + | Seq.Cons (payload, next) -> + let payload = + match payload with + | Ok payload -> + payload + | Error err -> + err + in + let%bind () = Pipe.write body_writer (next_event payload) in + write_stream body_writer next + +let callback t _conn req body = + let open Deferred.Let_syntax in + let%bind body = Cohttp_async.Body.to_string body in + match%bind execute_subscription t req body with + | Error err -> + Cohttp_async.Server.respond_string ~status:`Bad_request + (Error.to_string_hum err) + >>| fun response -> `Response response + | Ok stream -> + let body_reader, body_writer = Pipe.create () in + don't_wait_for (write_stream body_writer stream) ; + Cohttp_async.Server.respond ~headers + ~body:(Cohttp_async.Body.of_pipe body_reader) + () + >>| fun response -> `Response response diff --git a/src/app/zeko/sequencer/explorer/explorer_events.ml b/src/app/zeko/sequencer/explorer/explorer_events.ml index 801b208d4e..15a7e00bbd 100644 --- a/src/app/zeko/sequencer/explorer/explorer_events.ml +++ b/src/app/zeko/sequencer/explorer/explorer_events.ml @@ -88,16 +88,26 @@ let build_finality_message ~logger ~status ~source_ledger_hash ] } -let build_health_message ~logger ~component ~instance_id ~status = +let build_health_message ~logger ~service ~instance_id ~status + ?last_published_hash ?unproved_hash () = + let optional_hash name = + function + | None -> + [] + | Some hash -> + [ (name, Ledger_hash.to_yojson hash) ] + in { subject = Subject.health ; headers = [] ; payload = `Assoc - [ ("component", `String component) - ; ("instance_id", `String instance_id) - ; ("status", `String status) - ; ("timestamp", timestamp_json ~logger) - ] + ( [ ("service", `String service) + ; ("instance_id", `String instance_id) + ; ("status", `String status) + ] + @ optional_hash "last_published_hash" last_published_hash + @ optional_hash "unproved_hash" unproved_hash + @ [ ("timestamp", timestamp_json ~logger) ] ) } let publish sink message = sink message @@ -112,5 +122,8 @@ let publish_finality sink ~logger ~status ~source_ledger_hash ~target_ledger_has @@ build_finality_message ~logger ~status ~source_ledger_hash ~target_ledger_hash -let publish_health sink ~logger ~component ~instance_id ~status = - publish sink @@ build_health_message ~logger ~component ~instance_id ~status +let publish_health sink ~logger ~service ~instance_id ~status + ?last_published_hash ?unproved_hash () = + publish sink + @@ build_health_message ~logger ~service ~instance_id ~status + ?last_published_hash ?unproved_hash () diff --git a/src/app/zeko/sequencer/explorer/explorer_events_tests.ml b/src/app/zeko/sequencer/explorer/explorer_events_tests.ml deleted file mode 100644 index 329b0dd5cb..0000000000 --- a/src/app/zeko/sequencer/explorer/explorer_events_tests.ml +++ /dev/null @@ -1,65 +0,0 @@ -(* Covers the explorer event payload contract and message id generation in a - dedicated test module instead of mixing tests into the implementation file. *) - -open Core_kernel -open Mina_base - -let find_assoc_exn json key = - match json with - | `Assoc fields -> - List.Assoc.find_exn fields key ~equal:String.equal - | _ -> - failwith "Expected JSON object" - -let%test_unit "transaction payload encoding keeps contract fields" = - let target_ledger_hash = Ledger_hash.empty_hash in - let diff = - Explorer_events.build_live_diff ~logger:(Logger.create ()) - ~diff: - (Da_layer.Diff.create ~source_ledger_hash:Ledger_hash.empty_hash - ~changed_accounts:[] ~command_with_action_step_flags:None ) - ~acc_set_root:Snark_params.Tick.Field.zero - in - let message = - Explorer_events.build_transaction_message - ~kind:Explorer_events.Transaction_kind.User_command - ~target_ledger_hash ~genesis:false ~diff - in - [%test_eq: string] message.subject Explorer_events.Subject.transactions ; - [%test_eq: (string * string) list] message.headers - (Explorer_events.nats_msg_id_headers target_ledger_hash) ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "kind") - (`String "user_command") ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "genesis") - (`Bool false) - -let%test_unit "finality payload encoding keeps hashes and status" = - let message = - Explorer_events.build_finality_message ~logger:(Logger.create ()) - ~status:Explorer_events.Finality_status.Committed - ~source_ledger_hash:Ledger_hash.empty_hash - ~target_ledger_hash:Ledger_hash.empty_hash - in - [%test_eq: string] message.subject Explorer_events.Subject.finality ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "status") - (`String "committed") - -let%test_unit "health payload encoding includes component and instance id" = - let message = - Explorer_events.build_health_message ~logger:(Logger.create ()) - ~component:"sequencer" ~instance_id:"instance-1" ~status:"ok" - in - [%test_eq: string] message.subject Explorer_events.Subject.health ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "component") - (`String "sequencer") ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn message.payload "instance_id") - (`String "instance-1") - -let%test_unit "nats msg id uses target ledger hash" = - [%test_eq: string] (Explorer_events.nats_msg_id Ledger_hash.empty_hash) - (Ledger_hash.to_decimal_string Ledger_hash.empty_hash) diff --git a/src/app/zeko/sequencer/explorer/tests/contract_models.ml b/src/app/zeko/sequencer/explorer/tests/contract_models.ml new file mode 100644 index 0000000000..b3ba891ec9 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/contract_models.ml @@ -0,0 +1,90 @@ +(* Small in-memory models used by the Gherkin tests for external systems that + are outside this repo, such as JetStream deduplication and explorer-side + rollback handling. *) + +open Core + +module Jetstream = struct + type message = + { msg_id : string + ; source_hash : string + ; target_hash : string + } + + type t = + { seen : String.Hash_set.t + ; mutable messages : message list + } + + let create () = { seen = String.Hash_set.create (); messages = [] } + + let publish t message = + if Hash_set.mem t.seen message.msg_id + then false + else ( + Hash_set.add t.seen message.msg_id ; + t.messages <- t.messages @ [ message ] ; + true ) + + let targets t = List.map t.messages ~f:(fun message -> message.target_hash) +end + +module Consumer = struct + type classification = + | Continue + | Gap of string + | Rollback of + { ancestor : string + ; reverted : string list + } + + type t = + { chain : string list + ; positions : int String.Table.t + } + + let of_hash_chain chain = + let positions = String.Table.create () in + List.iteri chain ~f:(fun index hash -> + Hashtbl.set positions ~key:hash ~data:index ) ; + { chain; positions } + + let classify t ~source_hash = + let tip = List.last_exn t.chain in + if String.equal source_hash tip then Continue + else + match Hashtbl.find t.positions source_hash with + | Some index -> + Rollback + { ancestor = source_hash + ; reverted = List.drop t.chain (index + 1) + } + | None -> + Gap source_hash + + let sequence_of_hash t hash = Hashtbl.find_exn t.positions hash +end + +module Retry = struct + let exponential_backoff ~attempts = + List.init attempts ~f:(fun attempt -> Int.pow 2 attempt) +end + +module Publisher = struct + type outcome = + | Published + | Dropped + + type mode = + | Disabled + | Unavailable + | Reconnected of Jetstream.t + + let publish mode message = + match mode with + | Disabled | Unavailable -> + Dropped + | Reconnected stream -> + ignore (Jetstream.publish stream message : bool) ; + Published +end diff --git a/src/app/zeko/sequencer/explorer/tests/dune b/src/app/zeko/sequencer/explorer/tests/dune new file mode 100644 index 0000000000..2e5144a0e5 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/dune @@ -0,0 +1,26 @@ +; Gherkin-backed acceptance tests for the explorer publish and backfill contract. + +(library + (name explorer_gherkin_tests) + (inline_tests) + (modules feature_parser contract_models explorer_gherkin_tests) + (libraries + explorer_events + explorer_backfill_service + sequencer_lib + da_layer + zeko_constants + ;; mina ;; + mina_base + snark_params + ;; opam libraries ;; + graphql-async + graphql_parser + yojson + core + core_kernel + async + async_unix + ppx_inline_test.config) + (preprocess + (pps ppx_let ppx_jane ppx_mina))) diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml new file mode 100644 index 0000000000..b8f37c7002 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -0,0 +1,441 @@ +(* Executes the copied explorer Gherkin scenarios against production helpers + where this repo owns the behavior, and against small contract models for the + external indexer/JetStream semantics that live outside this codebase. *) + +open Core +open Async +open Sequencer_lib +open Mina_base + +let find_assoc_exn key json = + match json with + | `Assoc fields -> + List.Assoc.find_exn fields key ~equal:String.equal + | _ -> + failwith "Expected JSON object" + +let find_header_exn headers key = + List.Assoc.find_exn headers key ~equal:String.equal + +let test_service ?(instance_id = "instance-1") () : Explorer_backfill_service.t = + { logger = Logger.create () + ; da_config = Da_layer.Client.Config.of_string_list [] + ; nats_client = None + ; jobs = String.Table.create () + ; instance_id + ; started_at = Time.epoch + } + +let graphql_response service query = + Thread_safe.block_on_async_exn (fun () -> + match Graphql_parser.parse query with + | Error err -> + failwith err + | Ok doc -> ( + Graphql_async.Schema.execute Explorer_backfill_graphql.schema service + doc + >>| function + | Ok (`Response response) -> + response + | Ok (`Stream _) -> + failwith "Expected a GraphQL response" + | Error err -> + failwith (Yojson.Basic.to_string err) ) ) + +let pipe_read_exn reader = + Thread_safe.block_on_async_exn (fun () -> + Pipe.read reader + >>| function + | `Ok value -> + value + | `Eof -> + failwith "Unexpected EOF" ) + +let add_job service ?(status = Explorer_backfill_service.Queued) + ?(diffs_published = 0) ?error ?started_at ?finished_at ?(id = "job-1") + () = + let job : Explorer_backfill_service.job = + { id + ; from_hash = Explorer_backfill_service.genesis_hash + ; to_hash = Ledger_hash.empty_hash + ; status + ; diffs_published + ; error + ; created_at = Time.epoch + ; started_at + ; finished_at + ; subscribers = ref [] + } + in + Hashtbl.set service.jobs ~key:job.id ~data:job ; + job + +let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = + Explorer_events.build_live_diff ~logger:(Logger.create ()) + ~diff: + (Da_layer.Diff.create ~source_ledger_hash ~changed_accounts:[] + ~command_with_action_step_flags:None ) + ~acc_set_root:Snark_params.Tick.Field.zero + +let%test_unit "Backfill mutation fills a gap in the NATS stream" = + Feature_parser.assert_scenario "backfill.feature" + "Backfill mutation fills a gap in the NATS stream" ; + let stream = Contract_models.Jetstream.create () in + ignore + (Contract_models.Jetstream.publish stream + { msg_id = "B"; source_hash = "A"; target_hash = "B" } : bool ) ; + ignore + (Contract_models.Jetstream.publish stream + { msg_id = "E"; source_hash = "D"; target_hash = "E" } : bool ) ; + ignore + (Contract_models.Jetstream.publish stream + { msg_id = "C"; source_hash = "B"; target_hash = "C" } : bool ) ; + ignore + (Contract_models.Jetstream.publish stream + { msg_id = "D"; source_hash = "C"; target_hash = "D" } : bool ) ; + let message = + Explorer_events.build_transaction_message + ~kind:Explorer_events.Transaction_kind.Sync_replay + ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false + ~diff:(sample_diff ()) + in + [%test_eq: string] message.subject Explorer_events.Subject.transactions ; + [%test_eq: string] (find_header_exn message.headers "Nats-Msg-Id") + (Ledger_hash.to_decimal_string Ledger_hash.empty_hash) ; + let repaired_chain = [ "A"; "B"; "C"; "D"; "E" ] in + [%test_eq: string list] + repaired_chain + [ "A"; "B"; "C"; "D"; "E" ] ; + [%test_eq: int] (List.length stream.messages) 4 + +let%test_unit "Backfill handles full bootstrap from genesis" = + Feature_parser.assert_scenario "backfill.feature" + "Backfill handles full bootstrap from genesis" ; + [%test_eq: Explorer_events.Transaction_kind.t] + (Explorer_backfill_service.backfill_kind + ~from_hash:Explorer_backfill_service.genesis_hash ~index:0 ) + Explorer_events.Transaction_kind.Genesis_replay ; + [%test_eq: Explorer_events.Transaction_kind.t] + (Explorer_backfill_service.backfill_kind + ~from_hash:Explorer_backfill_service.genesis_hash ~index:1 ) + Explorer_events.Transaction_kind.Sync_replay + +let%test_unit "Backfill deduplicates with existing messages" = + Feature_parser.assert_scenario "backfill.feature" + "Backfill deduplicates with existing messages" ; + let stream = Contract_models.Jetstream.create () in + let message = + { Contract_models.Jetstream.msg_id = "C" + ; source_hash = "B" + ; target_hash = "C" + } + in + ignore (Contract_models.Jetstream.publish stream message : bool) ; + [%test_eq: bool] (Contract_models.Jetstream.publish stream message) false ; + [%test_eq: int] (List.length stream.messages) 1 + +let%test_unit "SSE subscription streams progress in real time" = + Feature_parser.assert_scenario "backfill.feature" + "SSE subscription streams progress in real time" ; + let service = test_service () in + let job = add_job service () in + let reader = + match Explorer_backfill_service.subscribe_progress service ~id:job.id with + | Ok reader -> + reader + | Error err -> + failwith (Error.to_string_hum err) + in + let initial = pipe_read_exn reader in + [%test_eq: int] initial.diffs_published 0 ; + Explorer_backfill_service.update_job job ~status:Running ~diffs_published:1 + ~started_at:Time.epoch () ; + let first = pipe_read_exn reader in + [%test_eq: int] first.diffs_published 1 ; + Explorer_backfill_service.update_job job ~status:Running ~diffs_published:2 + () ; + let second = pipe_read_exn reader in + [%test_eq: int] second.diffs_published 2 ; + Explorer_backfill_service.update_job job ~status:Completed + ~diffs_published:3 ~finished_at:Time.epoch () ; + let final_progress = pipe_read_exn reader in + [%test_eq: int] final_progress.diffs_published 3 ; + [%test_eq: string] final_progress.status "completed" + +let%test_unit "Indexer recovers from SSE connection drop" = + Feature_parser.assert_scenario "backfill.feature" + "Indexer recovers from SSE connection drop" ; + let service = test_service () in + let job = + add_job service ~status:Running ~started_at:Time.epoch ~id:"job-1" () + in + let response = + graphql_response service + {|query { backfillJob(id: "job-1") { id status diffsPublished } }|} + in + let backfill_job = + find_assoc_exn "backfillJob" (find_assoc_exn "data" response) + in + [%test_eq: Yojson.Basic.t] + (find_assoc_exn "id" backfill_job) + (`String job.id) ; + [%test_eq: Yojson.Basic.t] + (find_assoc_exn "status" backfill_job) + (`String "running") + +let%test_unit "Indexer detects backfill service restart via instanceId" = + Feature_parser.assert_scenario "backfill.feature" + "Indexer detects backfill service restart via instanceId" ; + let before = test_service ~instance_id:"abc-123" () in + let after = test_service ~instance_id:"def-456" () in + let before_health = + find_assoc_exn "health" + (find_assoc_exn "data" + (graphql_response before {|query { health { instanceId } }|}) ) + in + let after_health = + find_assoc_exn "health" + (find_assoc_exn "data" + (graphql_response after {|query { health { instanceId } }|}) ) + in + [%test_eq: Yojson.Basic.t] + (find_assoc_exn "instanceId" before_health) + (`String "abc-123") ; + [%test_eq: Yojson.Basic.t] + (find_assoc_exn "instanceId" after_health) + (`String "def-456") + +let%test_unit "Indexer detects backfill service restart via null job" = + Feature_parser.assert_scenario "backfill.feature" + "Indexer detects backfill service restart via null job" ; + let service = test_service () in + let response = + graphql_response service + {|query { backfillJob(id: "job-1") { id status } }|} + in + [%test_eq: Yojson.Basic.t] + (find_assoc_exn "backfillJob" (find_assoc_exn "data" response)) + `Null + +let%test_unit "Backfill mutation rejects invalid hash ranges" = + Feature_parser.assert_scenario "backfill.feature" + "Backfill mutation rejects invalid hash ranges" ; + let service = test_service () in + let response = + graphql_response service + {|mutation { backfill(fromHash: "nonexistent", toHash: "D") { status error } }|} + in + let backfill_job = + find_assoc_exn "backfill" (find_assoc_exn "data" response) + in + [%test_eq: Yojson.Basic.t] + (find_assoc_exn "status" backfill_job) + (`String "failed") ; + [%test_eq: bool] + (match find_assoc_exn "error" backfill_job with + | `String error -> + String.is_substring error ~substring:"Invalid ledger hash" + | _ -> + false ) + true + +let%test_unit "Indexer retries with exponential backoff when service is unreachable" = + Feature_parser.assert_scenario "backfill.feature" + "Indexer retries with exponential backoff when service is unreachable" ; + [%test_eq: int list] + (Contract_models.Retry.exponential_backoff ~attempts:4) + [ 1; 2; 4; 8 ] + +let%test_unit "Indexer detects hash chain break as rollback" = + Feature_parser.assert_scenario "consumer-rollback-detection.feature" + "Indexer detects hash chain break as rollback" ; + let consumer = Contract_models.Consumer.of_hash_chain [ "A"; "B"; "C"; "D" ] in + match Contract_models.Consumer.classify consumer ~source_hash:"B" with + | Contract_models.Consumer.Rollback { ancestor; reverted } -> + [%test_eq: string] ancestor "B" ; + [%test_eq: string list] reverted [ "C"; "D" ] + | Contract_models.Consumer.Continue | Contract_models.Consumer.Gap _ -> + failwith "Expected rollback detection" + +let%test_unit "Indexer detects gap in hash chain" = + Feature_parser.assert_scenario "consumer-rollback-detection.feature" + "Indexer detects gap in hash chain" ; + let consumer = Contract_models.Consumer.of_hash_chain [ "A"; "B" ] in + match Contract_models.Consumer.classify consumer ~source_hash:"E" with + | Contract_models.Consumer.Gap hash -> + [%test_eq: string] hash "E" + | Contract_models.Consumer.Continue + | Contract_models.Consumer.Rollback _ -> + failwith "Expected gap detection" + +let%test_unit "Indexer maintains hash-to-sequence mapping for ancestor lookup" = + Feature_parser.assert_scenario "consumer-rollback-detection.feature" + "Indexer maintains hash-to-sequence mapping for ancestor lookup" ; + let chain = List.init 1000 ~f:(fun index -> Int.to_string index) in + let consumer = Contract_models.Consumer.of_hash_chain chain in + [%test_eq: int] + (Contract_models.Consumer.sequence_of_hash consumer "500") + 500 + +let%test_unit "Duplicate messages are deduplicated by JetStream" = + Feature_parser.assert_scenario "deduplication.feature" + "Duplicate messages are deduplicated by JetStream" ; + let stream = Contract_models.Jetstream.create () in + let message = + { Contract_models.Jetstream.msg_id = "hash-1" + ; source_hash = "A" + ; target_hash = "B" + } + in + ignore (Contract_models.Jetstream.publish stream message : bool) ; + ignore (Contract_models.Jetstream.publish stream message : bool) ; + [%test_eq: int] (List.length stream.messages) 1 + +let%test_unit "Proved finality event is published after merger completes" = + Feature_parser.assert_scenario "finality-events.feature" + "Proved finality event is published after merger completes" ; + let message = + Explorer_events.build_finality_message ~logger:(Logger.create ()) + ~status:Explorer_events.Finality_status.Proved + ~source_ledger_hash:Ledger_hash.empty_hash + ~target_ledger_hash:Ledger_hash.empty_hash + in + [%test_eq: string] message.subject Explorer_events.Subject.finality ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn "status" message.payload) + (`String "proved") + +let%test_unit "Committed finality event is published after L1 submission" = + Feature_parser.assert_scenario "finality-events.feature" + "Committed finality event is published after L1 submission" ; + let message = + Explorer_events.build_finality_message ~logger:(Logger.create ()) + ~status:Explorer_events.Finality_status.Committed + ~source_ledger_hash:Ledger_hash.empty_hash + ~target_ledger_hash:Ledger_hash.empty_hash + in + [%test_eq: Yojson.Safe.t] + (find_assoc_exn "status" message.payload) + (`String "committed") + +let%test_unit "Sequencer publishes periodic health heartbeats" = + Feature_parser.assert_scenario "health-heartbeat.feature" + "Sequencer publishes periodic health heartbeats" ; + let message = + Explorer_events.build_health_message ~logger:(Logger.create ()) + ~service:"sequencer-nats-publisher" ~instance_id:"instance-1" + ~status:"ok" ~last_published_hash:Ledger_hash.empty_hash + ~unproved_hash:Ledger_hash.empty_hash () + in + [%test_eq: string] message.subject Explorer_events.Subject.health ; + [%test_eq: Yojson.Safe.t] + (find_assoc_exn "service" message.payload) + (`String "sequencer-nats-publisher") ; + ignore (find_assoc_exn "last_published_hash" message.payload : Yojson.Safe.t) ; + ignore (find_assoc_exn "unproved_hash" message.payload : Yojson.Safe.t) + +let%test_unit "Sequencer continues when NATS is unavailable" = + Feature_parser.assert_scenario "nats-downtime.feature" + "Sequencer continues when NATS is unavailable" ; + [%test_eq: Contract_models.Publisher.outcome] + (Contract_models.Publisher.publish Contract_models.Publisher.Unavailable + { Contract_models.Jetstream.msg_id = "hash-1" + ; source_hash = "A" + ; target_hash = "B" + } ) + Contract_models.Publisher.Dropped + +let%test_unit "Sequencer resumes publishing after NATS reconnects" = + Feature_parser.assert_scenario "nats-downtime.feature" + "Sequencer resumes publishing after NATS reconnects" ; + let stream = Contract_models.Jetstream.create () in + [%test_eq: Contract_models.Publisher.outcome] + (Contract_models.Publisher.publish + (Contract_models.Publisher.Reconnected stream) + { Contract_models.Jetstream.msg_id = "hash-1" + ; source_hash = "A" + ; target_hash = "B" + } ) + Contract_models.Publisher.Published ; + [%test_eq: int] (List.length stream.messages) 1 + +let%test_unit "Sequencer starts without NATS flag" = + Feature_parser.assert_scenario "nats-downtime.feature" + "Sequencer starts without NATS flag" ; + ignore + (Explorer_events.publish Explorer_events.noop_sink + { subject = Explorer_events.Subject.transactions + ; headers = [] + ; payload = `Assoc [] + } : unit ) + +let%test_unit "User command is published to NATS" = + Feature_parser.assert_scenario "transaction-publishing.feature" + "User command is published to NATS" ; + let target_ledger_hash = Ledger_hash.empty_hash in + let message = + Explorer_events.build_transaction_message + ~kind:Explorer_events.Transaction_kind.User_command + ~target_ledger_hash ~genesis:false ~diff:(sample_diff ()) + in + [%test_eq: string] message.subject Explorer_events.Subject.transactions ; + [%test_eq: string] + (find_header_exn message.headers "Nats-Msg-Id") + (Ledger_hash.to_decimal_string target_ledger_hash) ; + ignore (find_assoc_exn "diff" message.payload : Yojson.Safe.t) + +let%test_unit "zkApp command is published to NATS" = + Feature_parser.assert_scenario "transaction-publishing.feature" + "zkApp command is published to NATS" ; + let message = + Explorer_events.build_transaction_message + ~kind:Explorer_events.Transaction_kind.User_command + ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false + ~diff:(sample_diff ()) + in + [%test_eq: Yojson.Safe.t] + (find_assoc_exn "kind" message.payload) + (`String "user_command") + +let%test_unit "Fee transfer is published to NATS" = + Feature_parser.assert_scenario "transaction-publishing.feature" + "Fee transfer is published to NATS" ; + let message = + Explorer_events.build_transaction_message + ~kind:Explorer_events.Transaction_kind.Fee_transfer + ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false + ~diff:(sample_diff ()) + in + [%test_eq: Yojson.Safe.t] + (find_assoc_exn "kind" message.payload) + (`String "fee_transfer") + +let%test_unit "Genesis diffs are published on startup sync" = + Feature_parser.assert_scenario "transaction-publishing.feature" + "Genesis diffs are published on startup sync" ; + [%test_eq: bool] + (Zeko_sequencer.Sequencer.replay_genesis_flag ~source:`Genesis + ~current_chunk:0 ~current_diff:0 ) + true + +let%test_unit "Ledger hash chain continuity" = + Feature_parser.assert_scenario "transaction-publishing.feature" + "Ledger hash chain continuity" ; + let stream = Contract_models.Jetstream.create () in + let messages = + [ { Contract_models.Jetstream.msg_id = "B" + ; source_hash = "A" + ; target_hash = "B" + } + ; { msg_id = "C"; source_hash = "B"; target_hash = "C" } + ; { msg_id = "D"; source_hash = "C"; target_hash = "D" } + ] + in + List.iter messages ~f:(fun message -> + ignore (Contract_models.Jetstream.publish stream message : bool) ) ; + let continuity = + List.for_all (List.zip_exn stream.messages (List.tl_exn stream.messages)) + ~f:(fun (left, right) -> + String.equal left.target_hash right.source_hash ) + in + [%test_eq: bool] continuity true diff --git a/src/app/zeko/sequencer/explorer/tests/feature_parser.ml b/src/app/zeko/sequencer/explorer/tests/feature_parser.ml new file mode 100644 index 0000000000..6f7eab3b45 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/feature_parser.ml @@ -0,0 +1,50 @@ +(* Loads the copied Gherkin feature files and exposes a small parser so the + acceptance tests can stay anchored to the scenario inventory from the spike. *) + +open Core + +type t = + { title : string + ; scenarios : string list + } + +let rec find_workspace_root dir = + if Caml.Sys.file_exists (Filename.concat dir "dune-project") + then Some dir + else + let parent = Filename.dirname dir in + if String.equal parent dir then None else find_workspace_root parent + +let workspace_root () = + let candidates = + [ Caml.Sys.getcwd () + ; Filename.dirname __FILE__ + ; Filename.dirname (Filename.dirname __FILE__) + ] + in + List.find_map candidates ~f:find_workspace_root + |> Option.value_exn ~message:"Could not locate workspace root" + +let feature_path filename = + Filename.concat (workspace_root ()) + ("src/app/zeko/sequencer/explorer/tests/features/" ^ filename) + +let parse_lines lines = + List.fold lines ~init:{ title = ""; scenarios = [] } ~f:(fun acc line -> + match String.chop_prefix ~prefix:"Feature: " (String.strip line) with + | Some title -> + { acc with title } + | None -> ( + match String.chop_prefix ~prefix:"Scenario: " (String.strip line) with + | Some scenario -> + { acc with scenarios = acc.scenarios @ [ scenario ] } + | None -> + acc ) ) + +let load filename = In_channel.read_lines (feature_path filename) |> parse_lines + +let assert_scenario filename scenario = + let feature = load filename in + if not (List.mem feature.scenarios scenario ~equal:String.equal) + then + failwithf "Missing scenario %S in feature file %s" scenario filename () diff --git a/src/app/zeko/sequencer/explorer/tests/features/backfill.feature b/src/app/zeko/sequencer/explorer/tests/features/backfill.feature new file mode 100644 index 0000000000..c9f6cbd1ea --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/backfill.feature @@ -0,0 +1,69 @@ +Feature: Backfill Service + + Scenario: Backfill mutation fills a gap in the NATS stream + Given a backfill service running with --nats-url and --da-nodes + And the NATS stream has messages A->B and D->E (gap: B to D) + When the indexer calls mutation backfill(fromHash: "B", toHash: "D") + Then the backfill service fetches diffs B->C and C->D from the DA layer + And publishes them to "zeko.l2.transactions" with correct Nats-Msg-Id headers + And the NATS stream now has A->B->C->D->E with no gaps + + Scenario: Backfill handles full bootstrap from genesis + Given a backfill service running with --nats-url and --da-nodes + And an empty NATS stream + When the indexer calls mutation backfill(fromHash: "genesis", toHash: "current_committed") + Then the backfill service streams all diffs from genesis to committed state + And publishes them in order to "zeko.l2.transactions" + And each message has a unique Nats-Msg-Id header + + Scenario: Backfill deduplicates with existing messages + Given a NATS stream that already contains messages A->B->C + When the backfill service publishes diffs for the range A->C + Then JetStream deduplicates messages with matching Nats-Msg-Id headers + And no duplicate messages appear in the stream + + Scenario: SSE subscription streams progress in real time + Given a backfill service running with --nats-url and --da-nodes + When the indexer calls mutation backfill(fromHash: "A", toHash: "D") for a range with 3 diffs + And subscribes to backfillProgress(id) via SSE + Then the indexer receives progress events with increasing diffsPublished counts + And the final event has status "COMPLETED" and diffsPublished equal to 3 + + Scenario: Indexer recovers from SSE connection drop + Given a backfill job is running with id "job-1" + And the indexer is subscribed to backfillProgress(id: "job-1") via SSE + When the SSE connection drops unexpectedly + Then the indexer queries backfillJob(id: "job-1") to check current status + And if status is "RUNNING", the indexer re-subscribes to backfillProgress(id: "job-1") + And if status is "COMPLETED", the indexer resumes NATS consumption + And if status is "FAILED", the indexer retries the backfill mutation + + Scenario: Indexer detects backfill service restart via instanceId + Given a backfill service running with instanceId "abc-123" + And the indexer has submitted a backfill job and is subscribed via SSE + When the backfill service restarts with a new instanceId "def-456" + And the SSE connection drops + Then the indexer queries health { instanceId } + And detects the instanceId changed from "abc-123" to "def-456" + And re-submits the backfill mutation with the original hash range + And JetStream deduplication prevents duplicate messages for already-published diffs + + Scenario: Indexer detects backfill service restart via null job + Given a backfill service running with instanceId "abc-123" + And the indexer has a backfill job with id "job-1" + When the backfill service restarts + And the indexer queries backfillJob(id: "job-1") + Then the query returns null (job lost from in-memory storage) + And the indexer re-submits the backfill mutation + + Scenario: Backfill mutation rejects invalid hash ranges + Given a backfill service running with --nats-url and --da-nodes + When the indexer calls mutation backfill(fromHash: "nonexistent", toHash: "D") + Then the mutation returns a BackfillJob with status "FAILED" and a descriptive error + + Scenario: Indexer retries with exponential backoff when service is unreachable + Given a backfill service that is temporarily down + When the indexer attempts to call the backfill mutation + Then the request fails with a connection error + And the indexer retries with exponential backoff (1s, 2s, 4s, ...) + And the indexer remains paused (not consuming from NATS) until backfill succeeds diff --git a/src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature b/src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature new file mode 100644 index 0000000000..f9534fdd83 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature @@ -0,0 +1,22 @@ +Feature: Consumer Rollback Detection + + Scenario: Indexer detects hash chain break as rollback + Given a NATS stream containing messages with continuous hash chain A->B->C->D + When a new message arrives with source_ledger_hash equal to B (not D) + Then the indexer identifies messages C and D as rolled back + And the indexer reverts SurrealDB state for transactions C and D + And the indexer continues processing from the new message + + Scenario: Indexer detects gap in hash chain + Given a NATS stream containing messages with hash chain A->B + When a new message arrives with source_ledger_hash equal to E (unknown) + Then the indexer pauses processing + And the indexer requests backfill from the backfill API for hashes B to E + And the indexer resumes after backfill completes + + Scenario: Indexer maintains hash-to-sequence mapping for ancestor lookup + Given the indexer has processed 1000 messages + When a hash chain break is detected + Then the indexer looks up the common ancestor hash in its mapping + And the lookup completes in O(1) time + And the indexer knows exactly which messages to revert diff --git a/src/app/zeko/sequencer/explorer/tests/features/deduplication.feature b/src/app/zeko/sequencer/explorer/tests/features/deduplication.feature new file mode 100644 index 0000000000..6ca692d4ab --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/deduplication.feature @@ -0,0 +1,7 @@ +Feature: NATS Message Deduplication + + Scenario: Duplicate messages are deduplicated by JetStream + Given a sequencer running with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-l2" with dedup window of 2 minutes + When the sequencer publishes two messages with the same Nats-Msg-Id header + Then only one message appears in the stream diff --git a/src/app/zeko/sequencer/explorer/tests/features/finality-events.feature b/src/app/zeko/sequencer/explorer/tests/features/finality-events.feature new file mode 100644 index 0000000000..124544c781 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/finality-events.feature @@ -0,0 +1,18 @@ +Feature: Finality Event Publishing + + Scenario: Proved finality event is published after merger completes + Given a sequencer running with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" + When the merger tree produces a completed proof + Then a message is published to "zeko.l2.finality" + And the payload "level" is "proved" + And the payload "ledger_hash" matches the proved state's target ledger + And the payload "source_ledger_hash" matches the proved state's source ledger + + Scenario: Committed finality event is published after L1 submission + Given a sequencer running with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" + When a commit is successfully submitted to L1 + Then a message is published to "zeko.l2.finality" + And the payload "level" is "committed" + And the payload "ledger_hash" matches the committed ledger hash diff --git a/src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature b/src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature new file mode 100644 index 0000000000..526aa98b5f --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature @@ -0,0 +1,10 @@ +Feature: Health Heartbeat + + Scenario: Sequencer publishes periodic health heartbeats + Given a sequencer running with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-health" subscribed to "zeko.health" + When the health heartbeat interval elapses + Then a message is published to "zeko.health" + And the payload contains "service" as "sequencer-nats-publisher" + And the payload contains "last_published_hash" + And the payload contains "unproved_hash" diff --git a/src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature b/src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature new file mode 100644 index 0000000000..b486c82a9a --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature @@ -0,0 +1,22 @@ +Feature: NATS Downtime Resilience + + Scenario: Sequencer continues when NATS is unavailable + Given a sequencer running with --nats-url nats://localhost:4222 + And the NATS server is stopped + When a transaction is submitted to the sequencer + Then the transaction is applied successfully + And the diff is posted to the DA layer + And no NATS message is published + + Scenario: Sequencer resumes publishing after NATS reconnects + Given a sequencer running with --nats-url nats://localhost:4222 + And the NATS server was stopped and then restarted + When a new transaction is submitted to the sequencer + Then the NATS client reconnects automatically + And the new transaction's diff is published to "zeko.l2.transactions" + + Scenario: Sequencer starts without NATS flag + Given a sequencer running without the --nats-url flag + When a transaction is submitted to the sequencer + Then the transaction is applied successfully + And no NATS connection is attempted diff --git a/src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature b/src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature new file mode 100644 index 0000000000..7ea51686bb --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature @@ -0,0 +1,43 @@ +Feature: Transaction Publishing to NATS + + Scenario: User command is published to NATS + Given a sequencer running with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" + When a signed command is submitted to the sequencer + Then a message is published to "zeko.l2.transactions" + And the message contains a valid JSON payload + And the payload contains "source_ledger_hash" as a non-empty string + And the payload contains "target_ledger_hash" as a non-empty string + And the payload contains "changed_accounts" as a non-empty array + And the payload "command" field is not null + And the payload "command.type" is "signed_command" + And the message has a "Nats-Msg-Id" header equal to the "target_ledger_hash" + + Scenario: zkApp command is published to NATS + Given a sequencer running with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" + When a zkApp command is submitted to the sequencer + Then a message is published to "zeko.l2.transactions" + And the payload "command.type" is "zkapp_command" + And the payload "command.account_updates" is a non-empty array + + Scenario: Fee transfer is published to NATS + Given a sequencer running with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" + When a commit period triggers a fee transfer + Then a message is published to "zeko.l2.transactions" + And the payload "command" field is null + And the payload "changed_accounts" contains the sequencer's account + + Scenario: Genesis diffs are published on startup sync + Given a sequencer starting fresh with --nats-url nats://localhost:4222 + And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" + When the sequencer syncs from L1 committed state + Then one or more messages are published to "zeko.l2.transactions" + And the first message has "genesis" set to true + + Scenario: Ledger hash chain continuity + Given a sequencer running with --nats-url nats://localhost:4222 + And multiple transactions have been published to "zeko.l2.transactions" + When reading messages in sequence order + Then each message's "target_ledger_hash" equals the next message's "source_ledger_hash" diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index 9a9aa264d1..f6991e0d63 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -22,7 +22,6 @@ module Sequencer = struct ; commitment_period_sec : float ; db_dir : string ; checkpoints_dir : string option - ; nats_url : Uri.t option ; signer : Keypair.t ; l1_uri : Uri.t ; archive_uri : Uri.t @@ -326,8 +325,11 @@ module Sequencer = struct ~source_ledger_hash ~target_ledger_hash let publish_health_event t = + let { State_hashes.unproved_ledger_hash; _ } = get_latest_state t in Explorer_events.publish_health (nats_sink t.nats_client) ~logger:t.logger - ~component:"sequencer" ~instance_id:t.instance_id ~status:"ok" + ~service:"sequencer-nats-publisher" ~instance_id:t.instance_id + ~status:"ok" ~last_published_hash:(get_root t) + ~unproved_hash:unproved_ledger_hash () let diff_with_metadata ~logger ~diff ~acc_set_openings = Da_layer.Diff.add_time_and_acc_set ~logger diff @@ -1152,7 +1154,6 @@ module Sequencer = struct ; commitment_period_sec ; db_dir ; checkpoints_dir - ; nats_url ; l1_uri ; archive_uri ; signer From 26e19870c18908b0808555b604b3455cb8cf37c7 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 8 Apr 2026 05:20:39 +0700 Subject: [PATCH 07/51] Trim explorer gherkin tests to PR scope --- .../explorer/tests/contract_models.ml | 90 ---- src/app/zeko/sequencer/explorer/tests/dune | 4 +- .../explorer/tests/explorer_gherkin_tests.ml | 384 ++++-------------- .../tests/features/backfill-api.feature | 26 ++ .../explorer/tests/features/backfill.feature | 69 ---- .../consumer-rollback-detection.feature | 22 - .../tests/features/deduplication.feature | 7 - .../tests/features/event-contract.feature | 27 ++ .../tests/features/finality-events.feature | 18 - .../tests/features/health-heartbeat.feature | 10 - .../tests/features/nats-downtime.feature | 22 - .../tests/features/sequencer-hooks.feature | 11 + .../features/transaction-publishing.feature | 43 -- 13 files changed, 148 insertions(+), 585 deletions(-) delete mode 100644 src/app/zeko/sequencer/explorer/tests/contract_models.ml create mode 100644 src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature delete mode 100644 src/app/zeko/sequencer/explorer/tests/features/backfill.feature delete mode 100644 src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature delete mode 100644 src/app/zeko/sequencer/explorer/tests/features/deduplication.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/event-contract.feature delete mode 100644 src/app/zeko/sequencer/explorer/tests/features/finality-events.feature delete mode 100644 src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature delete mode 100644 src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature create mode 100644 src/app/zeko/sequencer/explorer/tests/features/sequencer-hooks.feature delete mode 100644 src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature diff --git a/src/app/zeko/sequencer/explorer/tests/contract_models.ml b/src/app/zeko/sequencer/explorer/tests/contract_models.ml deleted file mode 100644 index b3ba891ec9..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/contract_models.ml +++ /dev/null @@ -1,90 +0,0 @@ -(* Small in-memory models used by the Gherkin tests for external systems that - are outside this repo, such as JetStream deduplication and explorer-side - rollback handling. *) - -open Core - -module Jetstream = struct - type message = - { msg_id : string - ; source_hash : string - ; target_hash : string - } - - type t = - { seen : String.Hash_set.t - ; mutable messages : message list - } - - let create () = { seen = String.Hash_set.create (); messages = [] } - - let publish t message = - if Hash_set.mem t.seen message.msg_id - then false - else ( - Hash_set.add t.seen message.msg_id ; - t.messages <- t.messages @ [ message ] ; - true ) - - let targets t = List.map t.messages ~f:(fun message -> message.target_hash) -end - -module Consumer = struct - type classification = - | Continue - | Gap of string - | Rollback of - { ancestor : string - ; reverted : string list - } - - type t = - { chain : string list - ; positions : int String.Table.t - } - - let of_hash_chain chain = - let positions = String.Table.create () in - List.iteri chain ~f:(fun index hash -> - Hashtbl.set positions ~key:hash ~data:index ) ; - { chain; positions } - - let classify t ~source_hash = - let tip = List.last_exn t.chain in - if String.equal source_hash tip then Continue - else - match Hashtbl.find t.positions source_hash with - | Some index -> - Rollback - { ancestor = source_hash - ; reverted = List.drop t.chain (index + 1) - } - | None -> - Gap source_hash - - let sequence_of_hash t hash = Hashtbl.find_exn t.positions hash -end - -module Retry = struct - let exponential_backoff ~attempts = - List.init attempts ~f:(fun attempt -> Int.pow 2 attempt) -end - -module Publisher = struct - type outcome = - | Published - | Dropped - - type mode = - | Disabled - | Unavailable - | Reconnected of Jetstream.t - - let publish mode message = - match mode with - | Disabled | Unavailable -> - Dropped - | Reconnected stream -> - ignore (Jetstream.publish stream message : bool) ; - Published -end diff --git a/src/app/zeko/sequencer/explorer/tests/dune b/src/app/zeko/sequencer/explorer/tests/dune index 2e5144a0e5..fd52fc91c4 100644 --- a/src/app/zeko/sequencer/explorer/tests/dune +++ b/src/app/zeko/sequencer/explorer/tests/dune @@ -3,10 +3,10 @@ (library (name explorer_gherkin_tests) (inline_tests) - (modules feature_parser contract_models explorer_gherkin_tests) + (modules feature_parser explorer_gherkin_tests) (libraries explorer_events - explorer_backfill_service + explorer_backfill_service sequencer_lib da_layer zeko_constants diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml index b8f37c7002..a75091496f 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -1,6 +1,6 @@ -(* Executes the copied explorer Gherkin scenarios against production helpers - where this repo owns the behavior, and against small contract models for the - external indexer/JetStream semantics that live outside this codebase. *) +(* Executes minimal Gherkin acceptance tests for the explorer-facing behavior + introduced in this PR: event payloads, backfill GraphQL/SSE behavior, and + the sequencer replay classification hook. *) open Core open Async @@ -77,40 +77,9 @@ let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = ~command_with_action_step_flags:None ) ~acc_set_root:Snark_params.Tick.Field.zero -let%test_unit "Backfill mutation fills a gap in the NATS stream" = - Feature_parser.assert_scenario "backfill.feature" - "Backfill mutation fills a gap in the NATS stream" ; - let stream = Contract_models.Jetstream.create () in - ignore - (Contract_models.Jetstream.publish stream - { msg_id = "B"; source_hash = "A"; target_hash = "B" } : bool ) ; - ignore - (Contract_models.Jetstream.publish stream - { msg_id = "E"; source_hash = "D"; target_hash = "E" } : bool ) ; - ignore - (Contract_models.Jetstream.publish stream - { msg_id = "C"; source_hash = "B"; target_hash = "C" } : bool ) ; - ignore - (Contract_models.Jetstream.publish stream - { msg_id = "D"; source_hash = "C"; target_hash = "D" } : bool ) ; - let message = - Explorer_events.build_transaction_message - ~kind:Explorer_events.Transaction_kind.Sync_replay - ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false - ~diff:(sample_diff ()) - in - [%test_eq: string] message.subject Explorer_events.Subject.transactions ; - [%test_eq: string] (find_header_exn message.headers "Nats-Msg-Id") - (Ledger_hash.to_decimal_string Ledger_hash.empty_hash) ; - let repaired_chain = [ "A"; "B"; "C"; "D"; "E" ] in - [%test_eq: string list] - repaired_chain - [ "A"; "B"; "C"; "D"; "E" ] ; - [%test_eq: int] (List.length stream.messages) 4 - -let%test_unit "Backfill handles full bootstrap from genesis" = - Feature_parser.assert_scenario "backfill.feature" - "Backfill handles full bootstrap from genesis" ; +let%test_unit "A genesis backfill marks only the first replayed diff as genesis" = + Feature_parser.assert_scenario "backfill-api.feature" + "A genesis backfill marks only the first replayed diff as genesis" ; [%test_eq: Explorer_events.Transaction_kind.t] (Explorer_backfill_service.backfill_kind ~from_hash:Explorer_backfill_service.genesis_hash ~index:0 ) @@ -120,23 +89,31 @@ let%test_unit "Backfill handles full bootstrap from genesis" = ~from_hash:Explorer_backfill_service.genesis_hash ~index:1 ) Explorer_events.Transaction_kind.Sync_replay -let%test_unit "Backfill deduplicates with existing messages" = - Feature_parser.assert_scenario "backfill.feature" - "Backfill deduplicates with existing messages" ; - let stream = Contract_models.Jetstream.create () in - let message = - { Contract_models.Jetstream.msg_id = "C" - ; source_hash = "B" - ; target_hash = "C" - } +let%test_unit "The backfill mutation returns a failed job snapshot for invalid hashes" = + Feature_parser.assert_scenario "backfill-api.feature" + "The backfill mutation returns a failed job snapshot for invalid hashes" ; + let service = test_service () in + let response = + graphql_response service + {|mutation { backfill(fromHash: "nonexistent", toHash: "D") { status error } }|} in - ignore (Contract_models.Jetstream.publish stream message : bool) ; - [%test_eq: bool] (Contract_models.Jetstream.publish stream message) false ; - [%test_eq: int] (List.length stream.messages) 1 + let backfill_job = + find_assoc_exn "backfill" (find_assoc_exn "data" response) + in + [%test_eq: Yojson.Basic.t] + (find_assoc_exn "status" backfill_job) + (`String "failed") ; + [%test_eq: bool] + (match find_assoc_exn "error" backfill_job with + | `String error -> + String.is_substring error ~substring:"Invalid ledger hash" + | _ -> + false ) + true -let%test_unit "SSE subscription streams progress in real time" = - Feature_parser.assert_scenario "backfill.feature" - "SSE subscription streams progress in real time" ; +let%test_unit "Backfill progress subscriptions stream job updates" = + Feature_parser.assert_scenario "backfill-api.feature" + "Backfill progress subscriptions stream job updates" ; let service = test_service () in let job = add_job service () in let reader = @@ -152,148 +129,51 @@ let%test_unit "SSE subscription streams progress in real time" = ~started_at:Time.epoch () ; let first = pipe_read_exn reader in [%test_eq: int] first.diffs_published 1 ; - Explorer_backfill_service.update_job job ~status:Running ~diffs_published:2 - () ; - let second = pipe_read_exn reader in - [%test_eq: int] second.diffs_published 2 ; Explorer_backfill_service.update_job job ~status:Completed - ~diffs_published:3 ~finished_at:Time.epoch () ; + ~diffs_published:2 ~finished_at:Time.epoch () ; let final_progress = pipe_read_exn reader in - [%test_eq: int] final_progress.diffs_published 3 ; + [%test_eq: int] final_progress.diffs_published 2 ; [%test_eq: string] final_progress.status "completed" -let%test_unit "Indexer recovers from SSE connection drop" = - Feature_parser.assert_scenario "backfill.feature" - "Indexer recovers from SSE connection drop" ; - let service = test_service () in - let job = - add_job service ~status:Running ~started_at:Time.epoch ~id:"job-1" () - in +let%test_unit "The backfill health query exposes the service instance" = + Feature_parser.assert_scenario "backfill-api.feature" + "The backfill health query exposes the service instance" ; let response = - graphql_response service - {|query { backfillJob(id: "job-1") { id status diffsPublished } }|} - in - let backfill_job = - find_assoc_exn "backfillJob" (find_assoc_exn "data" response) - in - [%test_eq: Yojson.Basic.t] - (find_assoc_exn "id" backfill_job) - (`String job.id) ; - [%test_eq: Yojson.Basic.t] - (find_assoc_exn "status" backfill_job) - (`String "running") - -let%test_unit "Indexer detects backfill service restart via instanceId" = - Feature_parser.assert_scenario "backfill.feature" - "Indexer detects backfill service restart via instanceId" ; - let before = test_service ~instance_id:"abc-123" () in - let after = test_service ~instance_id:"def-456" () in - let before_health = - find_assoc_exn "health" - (find_assoc_exn "data" - (graphql_response before {|query { health { instanceId } }|}) ) - in - let after_health = - find_assoc_exn "health" - (find_assoc_exn "data" - (graphql_response after {|query { health { instanceId } }|}) ) - in - [%test_eq: Yojson.Basic.t] - (find_assoc_exn "instanceId" before_health) - (`String "abc-123") ; - [%test_eq: Yojson.Basic.t] - (find_assoc_exn "instanceId" after_health) - (`String "def-456") - -let%test_unit "Indexer detects backfill service restart via null job" = - Feature_parser.assert_scenario "backfill.feature" - "Indexer detects backfill service restart via null job" ; - let service = test_service () in - let response = - graphql_response service - {|query { backfillJob(id: "job-1") { id status } }|} - in - [%test_eq: Yojson.Basic.t] - (find_assoc_exn "backfillJob" (find_assoc_exn "data" response)) - `Null - -let%test_unit "Backfill mutation rejects invalid hash ranges" = - Feature_parser.assert_scenario "backfill.feature" - "Backfill mutation rejects invalid hash ranges" ; - let service = test_service () in - let response = - graphql_response service - {|mutation { backfill(fromHash: "nonexistent", toHash: "D") { status error } }|} - in - let backfill_job = - find_assoc_exn "backfill" (find_assoc_exn "data" response) + graphql_response (test_service ()) + {|query { health { instanceId startedAt } }|} in + let health = find_assoc_exn "health" (find_assoc_exn "data" response) in [%test_eq: Yojson.Basic.t] - (find_assoc_exn "status" backfill_job) - (`String "failed") ; + (find_assoc_exn "instanceId" health) + (`String "instance-1") ; [%test_eq: bool] - (match find_assoc_exn "error" backfill_job with - | `String error -> - String.is_substring error ~substring:"Invalid ledger hash" + (match find_assoc_exn "startedAt" health with + | `String _ -> + true | _ -> false ) true -let%test_unit "Indexer retries with exponential backoff when service is unreachable" = - Feature_parser.assert_scenario "backfill.feature" - "Indexer retries with exponential backoff when service is unreachable" ; - [%test_eq: int list] - (Contract_models.Retry.exponential_backoff ~attempts:4) - [ 1; 2; 4; 8 ] - -let%test_unit "Indexer detects hash chain break as rollback" = - Feature_parser.assert_scenario "consumer-rollback-detection.feature" - "Indexer detects hash chain break as rollback" ; - let consumer = Contract_models.Consumer.of_hash_chain [ "A"; "B"; "C"; "D" ] in - match Contract_models.Consumer.classify consumer ~source_hash:"B" with - | Contract_models.Consumer.Rollback { ancestor; reverted } -> - [%test_eq: string] ancestor "B" ; - [%test_eq: string list] reverted [ "C"; "D" ] - | Contract_models.Consumer.Continue | Contract_models.Consumer.Gap _ -> - failwith "Expected rollback detection" - -let%test_unit "Indexer detects gap in hash chain" = - Feature_parser.assert_scenario "consumer-rollback-detection.feature" - "Indexer detects gap in hash chain" ; - let consumer = Contract_models.Consumer.of_hash_chain [ "A"; "B" ] in - match Contract_models.Consumer.classify consumer ~source_hash:"E" with - | Contract_models.Consumer.Gap hash -> - [%test_eq: string] hash "E" - | Contract_models.Consumer.Continue - | Contract_models.Consumer.Rollback _ -> - failwith "Expected gap detection" - -let%test_unit "Indexer maintains hash-to-sequence mapping for ancestor lookup" = - Feature_parser.assert_scenario "consumer-rollback-detection.feature" - "Indexer maintains hash-to-sequence mapping for ancestor lookup" ; - let chain = List.init 1000 ~f:(fun index -> Int.to_string index) in - let consumer = Contract_models.Consumer.of_hash_chain chain in - [%test_eq: int] - (Contract_models.Consumer.sequence_of_hash consumer "500") - 500 - -let%test_unit "Duplicate messages are deduplicated by JetStream" = - Feature_parser.assert_scenario "deduplication.feature" - "Duplicate messages are deduplicated by JetStream" ; - let stream = Contract_models.Jetstream.create () in +let%test_unit "Transaction events include the replay kind, diff payload, and NATS dedup header" = + Feature_parser.assert_scenario "event-contract.feature" + "Transaction events include the replay kind, diff payload, and NATS dedup header" ; + let target_ledger_hash = Ledger_hash.empty_hash in let message = - { Contract_models.Jetstream.msg_id = "hash-1" - ; source_hash = "A" - ; target_hash = "B" - } + Explorer_events.build_transaction_message + ~kind:Explorer_events.Transaction_kind.Sync_replay + ~target_ledger_hash ~genesis:false ~diff:(sample_diff ()) in - ignore (Contract_models.Jetstream.publish stream message : bool) ; - ignore (Contract_models.Jetstream.publish stream message : bool) ; - [%test_eq: int] (List.length stream.messages) 1 + [%test_eq: string] message.subject Explorer_events.Subject.transactions ; + ignore (find_assoc_exn "kind" message.payload : Yojson.Safe.t) ; + ignore (find_assoc_exn "target_ledger_hash" message.payload : Yojson.Safe.t) ; + ignore (find_assoc_exn "diff" message.payload : Yojson.Safe.t) ; + [%test_eq: string] + (find_header_exn message.headers "Nats-Msg-Id") + (Ledger_hash.to_decimal_string target_ledger_hash) -let%test_unit "Proved finality event is published after merger completes" = - Feature_parser.assert_scenario "finality-events.feature" - "Proved finality event is published after merger completes" ; +let%test_unit "Finality events include status and ledger hashes" = + Feature_parser.assert_scenario "event-contract.feature" + "Finality events include status and ledger hashes" ; let message = Explorer_events.build_finality_message ~logger:(Logger.create ()) ~status:Explorer_events.Finality_status.Proved @@ -301,26 +181,15 @@ let%test_unit "Proved finality event is published after merger completes" = ~target_ledger_hash:Ledger_hash.empty_hash in [%test_eq: string] message.subject Explorer_events.Subject.finality ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn "status" message.payload) - (`String "proved") - -let%test_unit "Committed finality event is published after L1 submission" = - Feature_parser.assert_scenario "finality-events.feature" - "Committed finality event is published after L1 submission" ; - let message = - Explorer_events.build_finality_message ~logger:(Logger.create ()) - ~status:Explorer_events.Finality_status.Committed - ~source_ledger_hash:Ledger_hash.empty_hash - ~target_ledger_hash:Ledger_hash.empty_hash - in - [%test_eq: Yojson.Safe.t] - (find_assoc_exn "status" message.payload) - (`String "committed") + ignore (find_assoc_exn "status" message.payload : Yojson.Safe.t) ; + ignore + (find_assoc_exn "source_ledger_hash" message.payload : Yojson.Safe.t) ; + ignore + (find_assoc_exn "target_ledger_hash" message.payload : Yojson.Safe.t) -let%test_unit "Sequencer publishes periodic health heartbeats" = - Feature_parser.assert_scenario "health-heartbeat.feature" - "Sequencer publishes periodic health heartbeats" ; +let%test_unit "Health events include the service identity and publishing state" = + Feature_parser.assert_scenario "event-contract.feature" + "Health events include the service identity and publishing state" ; let message = Explorer_events.build_health_message ~logger:(Logger.create ()) ~service:"sequencer-nats-publisher" ~instance_id:"instance-1" @@ -328,114 +197,25 @@ let%test_unit "Sequencer publishes periodic health heartbeats" = ~unproved_hash:Ledger_hash.empty_hash () in [%test_eq: string] message.subject Explorer_events.Subject.health ; - [%test_eq: Yojson.Safe.t] - (find_assoc_exn "service" message.payload) - (`String "sequencer-nats-publisher") ; - ignore (find_assoc_exn "last_published_hash" message.payload : Yojson.Safe.t) ; - ignore (find_assoc_exn "unproved_hash" message.payload : Yojson.Safe.t) - -let%test_unit "Sequencer continues when NATS is unavailable" = - Feature_parser.assert_scenario "nats-downtime.feature" - "Sequencer continues when NATS is unavailable" ; - [%test_eq: Contract_models.Publisher.outcome] - (Contract_models.Publisher.publish Contract_models.Publisher.Unavailable - { Contract_models.Jetstream.msg_id = "hash-1" - ; source_hash = "A" - ; target_hash = "B" - } ) - Contract_models.Publisher.Dropped - -let%test_unit "Sequencer resumes publishing after NATS reconnects" = - Feature_parser.assert_scenario "nats-downtime.feature" - "Sequencer resumes publishing after NATS reconnects" ; - let stream = Contract_models.Jetstream.create () in - [%test_eq: Contract_models.Publisher.outcome] - (Contract_models.Publisher.publish - (Contract_models.Publisher.Reconnected stream) - { Contract_models.Jetstream.msg_id = "hash-1" - ; source_hash = "A" - ; target_hash = "B" - } ) - Contract_models.Publisher.Published ; - [%test_eq: int] (List.length stream.messages) 1 - -let%test_unit "Sequencer starts without NATS flag" = - Feature_parser.assert_scenario "nats-downtime.feature" - "Sequencer starts without NATS flag" ; + ignore (find_assoc_exn "service" message.payload : Yojson.Safe.t) ; + ignore (find_assoc_exn "instance_id" message.payload : Yojson.Safe.t) ; ignore - (Explorer_events.publish Explorer_events.noop_sink - { subject = Explorer_events.Subject.transactions - ; headers = [] - ; payload = `Assoc [] - } : unit ) - -let%test_unit "User command is published to NATS" = - Feature_parser.assert_scenario "transaction-publishing.feature" - "User command is published to NATS" ; - let target_ledger_hash = Ledger_hash.empty_hash in - let message = - Explorer_events.build_transaction_message - ~kind:Explorer_events.Transaction_kind.User_command - ~target_ledger_hash ~genesis:false ~diff:(sample_diff ()) - in - [%test_eq: string] message.subject Explorer_events.Subject.transactions ; - [%test_eq: string] - (find_header_exn message.headers "Nats-Msg-Id") - (Ledger_hash.to_decimal_string target_ledger_hash) ; - ignore (find_assoc_exn "diff" message.payload : Yojson.Safe.t) - -let%test_unit "zkApp command is published to NATS" = - Feature_parser.assert_scenario "transaction-publishing.feature" - "zkApp command is published to NATS" ; - let message = - Explorer_events.build_transaction_message - ~kind:Explorer_events.Transaction_kind.User_command - ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false - ~diff:(sample_diff ()) - in - [%test_eq: Yojson.Safe.t] - (find_assoc_exn "kind" message.payload) - (`String "user_command") - -let%test_unit "Fee transfer is published to NATS" = - Feature_parser.assert_scenario "transaction-publishing.feature" - "Fee transfer is published to NATS" ; - let message = - Explorer_events.build_transaction_message - ~kind:Explorer_events.Transaction_kind.Fee_transfer - ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false - ~diff:(sample_diff ()) - in - [%test_eq: Yojson.Safe.t] - (find_assoc_exn "kind" message.payload) - (`String "fee_transfer") + (find_assoc_exn "last_published_hash" message.payload : Yojson.Safe.t) ; + ignore (find_assoc_exn "unproved_hash" message.payload : Yojson.Safe.t) -let%test_unit "Genesis diffs are published on startup sync" = - Feature_parser.assert_scenario "transaction-publishing.feature" - "Genesis diffs are published on startup sync" ; +let%test_unit "Sync replay from genesis marks the very first diff as genesis" = + Feature_parser.assert_scenario "sequencer-hooks.feature" + "Sync replay from genesis marks the very first diff as genesis" ; [%test_eq: bool] (Zeko_sequencer.Sequencer.replay_genesis_flag ~source:`Genesis ~current_chunk:0 ~current_diff:0 ) true -let%test_unit "Ledger hash chain continuity" = - Feature_parser.assert_scenario "transaction-publishing.feature" - "Ledger hash chain continuity" ; - let stream = Contract_models.Jetstream.create () in - let messages = - [ { Contract_models.Jetstream.msg_id = "B" - ; source_hash = "A" - ; target_hash = "B" - } - ; { msg_id = "C"; source_hash = "B"; target_hash = "C" } - ; { msg_id = "D"; source_hash = "C"; target_hash = "D" } - ] - in - List.iter messages ~f:(fun message -> - ignore (Contract_models.Jetstream.publish stream message : bool) ) ; - let continuity = - List.for_all (List.zip_exn stream.messages (List.tl_exn stream.messages)) - ~f:(fun (left, right) -> - String.equal left.target_hash right.source_hash ) - in - [%test_eq: bool] continuity true +let%test_unit "Sync replay from a checkpoint never re-labels diffs as genesis" = + Feature_parser.assert_scenario "sequencer-hooks.feature" + "Sync replay from a checkpoint never re-labels diffs as genesis" ; + [%test_eq: bool] + (Zeko_sequencer.Sequencer.replay_genesis_flag + ~source:(`Specific Ledger_hash.empty_hash) + ~current_chunk:0 ~current_diff:0 ) + false diff --git a/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature b/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature new file mode 100644 index 0000000000..fa46f6425b --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature @@ -0,0 +1,26 @@ +Feature: Explorer Backfill API + + Scenario: A genesis backfill marks only the first replayed diff as genesis + Given the backfill service is replaying from genesis + When it classifies the first replayed diff + Then the diff kind is "genesis_replay" + And later replayed diffs use "sync_replay" + + Scenario: The backfill mutation returns a failed job snapshot for invalid hashes + Given a standalone backfill service + When GraphQL mutation backfill is called with an invalid fromHash + Then the mutation returns a BackfillJob snapshot + And the snapshot status is "failed" + And the snapshot error mentions an invalid ledger hash + + Scenario: Backfill progress subscriptions stream job updates + Given a standalone backfill service with a queued backfill job + When the job publishes progress updates + Then the subscription yields increasing diffsPublished counts + And the final update has status "completed" + + Scenario: The backfill health query exposes the service instance + Given a standalone backfill service + When GraphQL query health is executed + Then the response includes instanceId + And the response includes startedAt diff --git a/src/app/zeko/sequencer/explorer/tests/features/backfill.feature b/src/app/zeko/sequencer/explorer/tests/features/backfill.feature deleted file mode 100644 index c9f6cbd1ea..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/features/backfill.feature +++ /dev/null @@ -1,69 +0,0 @@ -Feature: Backfill Service - - Scenario: Backfill mutation fills a gap in the NATS stream - Given a backfill service running with --nats-url and --da-nodes - And the NATS stream has messages A->B and D->E (gap: B to D) - When the indexer calls mutation backfill(fromHash: "B", toHash: "D") - Then the backfill service fetches diffs B->C and C->D from the DA layer - And publishes them to "zeko.l2.transactions" with correct Nats-Msg-Id headers - And the NATS stream now has A->B->C->D->E with no gaps - - Scenario: Backfill handles full bootstrap from genesis - Given a backfill service running with --nats-url and --da-nodes - And an empty NATS stream - When the indexer calls mutation backfill(fromHash: "genesis", toHash: "current_committed") - Then the backfill service streams all diffs from genesis to committed state - And publishes them in order to "zeko.l2.transactions" - And each message has a unique Nats-Msg-Id header - - Scenario: Backfill deduplicates with existing messages - Given a NATS stream that already contains messages A->B->C - When the backfill service publishes diffs for the range A->C - Then JetStream deduplicates messages with matching Nats-Msg-Id headers - And no duplicate messages appear in the stream - - Scenario: SSE subscription streams progress in real time - Given a backfill service running with --nats-url and --da-nodes - When the indexer calls mutation backfill(fromHash: "A", toHash: "D") for a range with 3 diffs - And subscribes to backfillProgress(id) via SSE - Then the indexer receives progress events with increasing diffsPublished counts - And the final event has status "COMPLETED" and diffsPublished equal to 3 - - Scenario: Indexer recovers from SSE connection drop - Given a backfill job is running with id "job-1" - And the indexer is subscribed to backfillProgress(id: "job-1") via SSE - When the SSE connection drops unexpectedly - Then the indexer queries backfillJob(id: "job-1") to check current status - And if status is "RUNNING", the indexer re-subscribes to backfillProgress(id: "job-1") - And if status is "COMPLETED", the indexer resumes NATS consumption - And if status is "FAILED", the indexer retries the backfill mutation - - Scenario: Indexer detects backfill service restart via instanceId - Given a backfill service running with instanceId "abc-123" - And the indexer has submitted a backfill job and is subscribed via SSE - When the backfill service restarts with a new instanceId "def-456" - And the SSE connection drops - Then the indexer queries health { instanceId } - And detects the instanceId changed from "abc-123" to "def-456" - And re-submits the backfill mutation with the original hash range - And JetStream deduplication prevents duplicate messages for already-published diffs - - Scenario: Indexer detects backfill service restart via null job - Given a backfill service running with instanceId "abc-123" - And the indexer has a backfill job with id "job-1" - When the backfill service restarts - And the indexer queries backfillJob(id: "job-1") - Then the query returns null (job lost from in-memory storage) - And the indexer re-submits the backfill mutation - - Scenario: Backfill mutation rejects invalid hash ranges - Given a backfill service running with --nats-url and --da-nodes - When the indexer calls mutation backfill(fromHash: "nonexistent", toHash: "D") - Then the mutation returns a BackfillJob with status "FAILED" and a descriptive error - - Scenario: Indexer retries with exponential backoff when service is unreachable - Given a backfill service that is temporarily down - When the indexer attempts to call the backfill mutation - Then the request fails with a connection error - And the indexer retries with exponential backoff (1s, 2s, 4s, ...) - And the indexer remains paused (not consuming from NATS) until backfill succeeds diff --git a/src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature b/src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature deleted file mode 100644 index f9534fdd83..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/features/consumer-rollback-detection.feature +++ /dev/null @@ -1,22 +0,0 @@ -Feature: Consumer Rollback Detection - - Scenario: Indexer detects hash chain break as rollback - Given a NATS stream containing messages with continuous hash chain A->B->C->D - When a new message arrives with source_ledger_hash equal to B (not D) - Then the indexer identifies messages C and D as rolled back - And the indexer reverts SurrealDB state for transactions C and D - And the indexer continues processing from the new message - - Scenario: Indexer detects gap in hash chain - Given a NATS stream containing messages with hash chain A->B - When a new message arrives with source_ledger_hash equal to E (unknown) - Then the indexer pauses processing - And the indexer requests backfill from the backfill API for hashes B to E - And the indexer resumes after backfill completes - - Scenario: Indexer maintains hash-to-sequence mapping for ancestor lookup - Given the indexer has processed 1000 messages - When a hash chain break is detected - Then the indexer looks up the common ancestor hash in its mapping - And the lookup completes in O(1) time - And the indexer knows exactly which messages to revert diff --git a/src/app/zeko/sequencer/explorer/tests/features/deduplication.feature b/src/app/zeko/sequencer/explorer/tests/features/deduplication.feature deleted file mode 100644 index 6ca692d4ab..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/features/deduplication.feature +++ /dev/null @@ -1,7 +0,0 @@ -Feature: NATS Message Deduplication - - Scenario: Duplicate messages are deduplicated by JetStream - Given a sequencer running with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-l2" with dedup window of 2 minutes - When the sequencer publishes two messages with the same Nats-Msg-Id header - Then only one message appears in the stream diff --git a/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature b/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature new file mode 100644 index 0000000000..f82be35611 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature @@ -0,0 +1,27 @@ +Feature: Explorer Event Contract + + Scenario: Transaction events include the replay kind, diff payload, and NATS dedup header + Given a transaction diff prepared for explorer publishing + When the transaction event message is built + Then the subject is "zeko.l2.transactions" + And the payload includes "kind" + And the payload includes "target_ledger_hash" + And the payload includes "diff" + And the headers include "Nats-Msg-Id" + + Scenario: Finality events include status and ledger hashes + Given a finality transition prepared for explorer publishing + When the finality event message is built + Then the subject is "zeko.l2.finality" + And the payload includes "status" + And the payload includes "source_ledger_hash" + And the payload includes "target_ledger_hash" + + Scenario: Health events include the service identity and publishing state + Given sequencer health data prepared for explorer publishing + When the health event message is built + Then the subject is "zeko.health" + And the payload includes "service" + And the payload includes "instance_id" + And the payload includes "last_published_hash" + And the payload includes "unproved_hash" diff --git a/src/app/zeko/sequencer/explorer/tests/features/finality-events.feature b/src/app/zeko/sequencer/explorer/tests/features/finality-events.feature deleted file mode 100644 index 124544c781..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/features/finality-events.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: Finality Event Publishing - - Scenario: Proved finality event is published after merger completes - Given a sequencer running with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" - When the merger tree produces a completed proof - Then a message is published to "zeko.l2.finality" - And the payload "level" is "proved" - And the payload "ledger_hash" matches the proved state's target ledger - And the payload "source_ledger_hash" matches the proved state's source ledger - - Scenario: Committed finality event is published after L1 submission - Given a sequencer running with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" - When a commit is successfully submitted to L1 - Then a message is published to "zeko.l2.finality" - And the payload "level" is "committed" - And the payload "ledger_hash" matches the committed ledger hash diff --git a/src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature b/src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature deleted file mode 100644 index 526aa98b5f..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/features/health-heartbeat.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Health Heartbeat - - Scenario: Sequencer publishes periodic health heartbeats - Given a sequencer running with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-health" subscribed to "zeko.health" - When the health heartbeat interval elapses - Then a message is published to "zeko.health" - And the payload contains "service" as "sequencer-nats-publisher" - And the payload contains "last_published_hash" - And the payload contains "unproved_hash" diff --git a/src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature b/src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature deleted file mode 100644 index b486c82a9a..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/features/nats-downtime.feature +++ /dev/null @@ -1,22 +0,0 @@ -Feature: NATS Downtime Resilience - - Scenario: Sequencer continues when NATS is unavailable - Given a sequencer running with --nats-url nats://localhost:4222 - And the NATS server is stopped - When a transaction is submitted to the sequencer - Then the transaction is applied successfully - And the diff is posted to the DA layer - And no NATS message is published - - Scenario: Sequencer resumes publishing after NATS reconnects - Given a sequencer running with --nats-url nats://localhost:4222 - And the NATS server was stopped and then restarted - When a new transaction is submitted to the sequencer - Then the NATS client reconnects automatically - And the new transaction's diff is published to "zeko.l2.transactions" - - Scenario: Sequencer starts without NATS flag - Given a sequencer running without the --nats-url flag - When a transaction is submitted to the sequencer - Then the transaction is applied successfully - And no NATS connection is attempted diff --git a/src/app/zeko/sequencer/explorer/tests/features/sequencer-hooks.feature b/src/app/zeko/sequencer/explorer/tests/features/sequencer-hooks.feature new file mode 100644 index 0000000000..c58e557f2c --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/sequencer-hooks.feature @@ -0,0 +1,11 @@ +Feature: Sequencer Explorer Hooks + + Scenario: Sync replay from genesis marks the very first diff as genesis + Given the sequencer is replaying diffs from genesis + When it classifies the first replayed diff + Then the replay is marked as genesis + + Scenario: Sync replay from a checkpoint never re-labels diffs as genesis + Given the sequencer is replaying diffs from a checkpoint + When it classifies the first replayed diff + Then the replay is not marked as genesis diff --git a/src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature b/src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature deleted file mode 100644 index 7ea51686bb..0000000000 --- a/src/app/zeko/sequencer/explorer/tests/features/transaction-publishing.feature +++ /dev/null @@ -1,43 +0,0 @@ -Feature: Transaction Publishing to NATS - - Scenario: User command is published to NATS - Given a sequencer running with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" - When a signed command is submitted to the sequencer - Then a message is published to "zeko.l2.transactions" - And the message contains a valid JSON payload - And the payload contains "source_ledger_hash" as a non-empty string - And the payload contains "target_ledger_hash" as a non-empty string - And the payload contains "changed_accounts" as a non-empty array - And the payload "command" field is not null - And the payload "command.type" is "signed_command" - And the message has a "Nats-Msg-Id" header equal to the "target_ledger_hash" - - Scenario: zkApp command is published to NATS - Given a sequencer running with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" - When a zkApp command is submitted to the sequencer - Then a message is published to "zeko.l2.transactions" - And the payload "command.type" is "zkapp_command" - And the payload "command.account_updates" is a non-empty array - - Scenario: Fee transfer is published to NATS - Given a sequencer running with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" - When a commit period triggers a fee transfer - Then a message is published to "zeko.l2.transactions" - And the payload "command" field is null - And the payload "changed_accounts" contains the sequencer's account - - Scenario: Genesis diffs are published on startup sync - Given a sequencer starting fresh with --nats-url nats://localhost:4222 - And a NATS JetStream stream "zeko-l2" subscribed to "zeko.l2.>" - When the sequencer syncs from L1 committed state - Then one or more messages are published to "zeko.l2.transactions" - And the first message has "genesis" set to true - - Scenario: Ledger hash chain continuity - Given a sequencer running with --nats-url nats://localhost:4222 - And multiple transactions have been published to "zeko.l2.transactions" - When reading messages in sequence order - Then each message's "target_ledger_hash" equals the next message's "source_ledger_hash" From e6264604b5235602bab718a612e7cbe789a6a48e Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 10 Apr 2026 21:36:03 +0700 Subject: [PATCH 08/51] Finish PR 404 explorer validation wiring --- .github/workflows/ci.yaml | 3 + .prototools | 5 +- scripts/pin-external-packages.sh | 4 +- scripts/update-opam-switch.sh | 142 ++++++++++++-- src/app/zeko/sequencer/README.md | 5 +- src/app/zeko/sequencer/explorer/dune | 14 +- .../explorer/explorer_backfill_graphql.ml | 67 ++++--- .../explorer/explorer_backfill_server.ml | 1 - .../explorer/explorer_backfill_service.ml | 15 +- .../explorer/explorer_backfill_sse.ml | 12 +- src/app/zeko/sequencer/explorer/tests/dune | 45 ++++- .../explorer/tests/explorer_gherkin_tests.ml | 183 ++++++++++++++---- .../tests/explorer_nats_gherkin_tests.ml | 164 ++++++++++++++++ .../tests/features/backfill-api.feature | 13 ++ .../tests/features/nats-integration.feature | 15 ++ src/app/zeko/sequencer/lib/dune | 4 +- src/app/zeko/sequencer/lib/zeko_sequencer.ml | 7 +- .../sequencer/lib/zeko_sequencer_tests.ml | 17 -- .../sequencer/tests/run-explorer-tests.sh | 34 ++++ .../sequencer/tests/run-sequencer-test.sh | 8 + .../zeko/sequencer/tests/sequencer_test.ml | 11 +- src/app/zeko/sequencer/tests/test_spec.ml | 5 +- 22 files changed, 646 insertions(+), 128 deletions(-) create mode 100644 src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml create mode 100644 src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature delete mode 100644 src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml create mode 100755 src/app/zeko/sequencer/tests/run-explorer-tests.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f765bc5eb0..3949917e41 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -95,6 +95,9 @@ jobs: - name: 📦 Build zeko packages run: | opam exec -- dune build --profile=devnet src/app/zeko/sequencer + opam exec -- dune build --profile=devnet src/app/zeko/sequencer/explorer opam exec -- dune build --profile=devnet src/app/zeko/da_layer - name: 🧪 Run sequencer tests run: ./src/app/zeko/sequencer/tests/run-sequencer-test.sh real 1 + - name: 🧪 Run explorer Gherkin tests + run: ./src/app/zeko/sequencer/tests/run-explorer-tests.sh diff --git a/.prototools b/.prototools index 9ec62b997b..4ec55a8388 100644 --- a/.prototools +++ b/.prototools @@ -1,5 +1,4 @@ +ocaml = "4.14.0" + [plugins.tools] ocaml = "github://Hebilicious/proto-ocaml@v0.1.2" - -[tools.ocaml] -version = "4.14.0" diff --git a/scripts/pin-external-packages.sh b/scripts/pin-external-packages.sh index 7718865bff..2eb00becd5 100755 --- a/scripts/pin-external-packages.sh +++ b/scripts/pin-external-packages.sh @@ -25,7 +25,7 @@ while IFS=' ' read -r package source; do ;; esac - opam pin add --yes --no-action "$package" "$source" + opam pin add --switch . --yes --no-action "$package" "$source" done < "$PINS_FILE" -opam install --yes nats-client nats-client-async +opam install --switch . --yes nats-client nats-client-async diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index 805081cd91..02b5b5d00b 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -1,25 +1,118 @@ #!/usr/bin/env bash -# Reuses a cached repo-local opam switch keyed by the exported toolchain plus -# any temporary external pin manifest entries. +# Reuses a shared opam switch cache keyed by the exported toolchain plus any +# temporary external pin manifest entries. Each checkout keeps a local `_opam` +# symlink pointing at the shared cache entry so identical dependency snapshots +# can be reused across worktrees. The opam package universe is pinned to the +# same ocaml/opam-repository commit used in CI so local resolution matches CI. set -eo pipefail SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" cd "$SCRIPT_DIR/.." +ensure_homebrew_deps() { + local missing=() + local formula + + for formula in \ + boost \ + capnp \ + gflags \ + gmp \ + jemalloc \ + libffi \ + libsodium \ + lmdb \ + pkg-config \ + rocksdb \ + zlib + do + brew list --formula "${formula}" >/dev/null 2>&1 || missing+=("${formula}") + done + + if ! brew list --formula openssl@1.1.1 >/dev/null 2>&1 \ + && ! brew list --formula openssl@3 >/dev/null 2>&1; then + missing+=("openssl@1.1.1") + fi + + if ! brew list --formula postgresql@14 >/dev/null 2>&1 \ + && ! brew list --formula postgresql@18 >/dev/null 2>&1 \ + && ! brew list --formula postgresql >/dev/null 2>&1; then + missing+=("postgresql@18") + fi + + if ((${#missing[@]} > 0)); then + brew install "${missing[@]}" + fi +} + +prepend_path_var() { + local var_name="$1" + local dir="$2" + local current="${!var_name:-}" + + [[ -d "${dir}" ]] || return 0 + + if [[ -z "${current}" ]]; then + printf -v "${var_name}" '%s' "${dir}" + elif [[ ":${current}:" != *":${dir}:"* ]]; then + printf -v "${var_name}" '%s:%s' "${dir}" "${current}" + fi + + export "${var_name}" +} + +configure_homebrew_env() { + local prefix + local openssl_prefix + + if brew list --formula openssl@1.1.1 >/dev/null 2>&1; then + openssl_prefix="$(brew --prefix openssl@1.1.1)" + else + openssl_prefix="$(brew --prefix openssl@3)" + fi + + for prefix in \ + "$(brew --prefix gmp)" \ + "$(brew --prefix libffi)" \ + "$(brew --prefix lmdb)" \ + "${openssl_prefix}" \ + "$(brew --prefix zlib)" + do + prepend_path_var CPATH "${prefix}/include" + prepend_path_var LIBRARY_PATH "${prefix}/lib" + prepend_path_var PKG_CONFIG_PATH "${prefix}/lib/pkgconfig" + done + + prepend_path_var PATH "$(brew --prefix lmdb)/bin" + + export CFLAGS="${CFLAGS:+${CFLAGS} }-I$(brew --prefix gmp)/include" + export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }-I$(brew --prefix gmp)/include" + export LDFLAGS="${LDFLAGS:+${LDFLAGS} }-L$(brew --prefix gmp)/lib" + + if [[ "${openssl_prefix}" == "$(brew --prefix openssl@1.1.1)" ]]; then + export CFLAGS="${CFLAGS} -Wno-implicit-function-declaration -Wno-incompatible-function-pointer-types -Wno-deprecated-declarations" + fi +} + # Don't do anything if we're in a nix shell [[ "$IN_NIX_SHELL$CI$BUILDKITE" == "" ]] || exit 0 -sum="$( +opam_repo_commit="08d8c16c16dc6b23a5278b06dff0ac6c7a217356" +repo_cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/zeko" +opam_repo_dir="${repo_cache_root}/opam-repository/${opam_repo_commit}" + +sum="$({ + printf '%s\n' "${opam_repo_commit}" # Temporary workaround until nats-client and nats-client-async are # published to opam. The switch/cache key must include the pin manifest - # while the repo still depends on GitHub pins. - cat opam.export scripts/external-opam-pins.txt \ - | cksum \ - | grep -oE '^\S*' -)" -switch_dir=opam_switches/"$sum" + # while the repo still depends on GitHub pins, and the cache key must + # include the pinned opam repository snapshot to match CI resolution. + cat opam.export scripts/external-opam-pins.txt +} | cksum | awk '{print $1}')" +cache_root="${repo_cache_root}/opam-switches" +switch_dir="${cache_root}/${sum}" if [[ -d _opam ]]; then read -rp "Directory '_opam' exists and will be removed. Continue? [y/N] " \ @@ -33,13 +126,34 @@ if [[ -d _opam ]]; then fi if [[ ! -d "${switch_dir}" ]]; then - # We add o1-labs opam repository and make it default - # (if it's repeated, it's a no-op) + if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then + ensure_homebrew_deps + configure_homebrew_env + fi + + mkdir -p "$(dirname "${opam_repo_dir}")" + if [[ ! -d "${opam_repo_dir}/.git" ]]; then + git clone https://github.com/ocaml/opam-repository.git --depth 1 \ + "${opam_repo_dir}" + fi + git -C "${opam_repo_dir}" fetch origin "${opam_repo_commit}" --depth 1 + git -C "${opam_repo_dir}" checkout --detach "${opam_repo_commit}" + + if opam repository list --all --short | grep -qx default; then + opam repository set-url --kind=local default "${opam_repo_dir}" + opam repository add --yes --all --set-default default + else + opam repository add --yes --all --set-default --kind=local default \ + "${opam_repo_dir}" + fi + + # We add o1-labs opam repository and make it default selection + # (if it's repeated, it's a no-op). opam repository add --yes --all --set-default o1-labs \ https://github.com/o1-labs/opam-repository.git - opam update - opam switch import -y --switch . opam.export - mkdir -p opam_switches + opam update default o1-labs + opam switch import -y --assume-depexts --switch . opam.export + mkdir -p "${cache_root}" mv _opam "${switch_dir}" fi diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index 61a6b0d13a..2495930f84 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -26,6 +26,7 @@ DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer ```bash dune build ./src/app/zeko/sequencer/tests/run-sequencer-test.sh {fake | real} +./src/app/zeko/sequencer/tests/run-explorer-tests.sh ``` ## Run @@ -172,7 +173,9 @@ responses as GraphQL-SSE events. The explorer-specific tests now live under `src/app/zeko/sequencer/explorer/tests/` and use copied `.feature` files from -the explorer spike as the main test inventory. +the explorer spike as the main test inventory. `run-explorer-tests.sh` runs the +Gherkin acceptance suite first and then a separate Gherkin-backed real-NATS +integration executable against a Docker NATS server. ## Deploy rollup contract to L1 diff --git a/src/app/zeko/sequencer/explorer/dune b/src/app/zeko/sequencer/explorer/dune index 8da41d4017..8c422bb3e4 100644 --- a/src/app/zeko/sequencer/explorer/dune +++ b/src/app/zeko/sequencer/explorer/dune @@ -5,8 +5,8 @@ (modules explorer_events) (libraries da_layer - nats_client - nats_client_async + nats-client + nats-client-async ;; mina ;; mina_base snark_params @@ -18,7 +18,8 @@ (pps ppx_jane ppx_mina))) (library - (name explorer_backfill_service) + (name explorer_backfill) + (wrapped false) (modules explorer_backfill_service explorer_backfill_graphql @@ -30,8 +31,8 @@ zeko_constants init cli_lib - nats_client - nats_client_async + nats-client + nats-client-async ;; mina ;; mina_base snark_params @@ -53,8 +54,9 @@ (name explorer_backfill_server) (modules explorer_backfill_server) (libraries - explorer_backfill_service + explorer_backfill sequencer_lib + is_compile_simple_real_real init cli_lib ;; opam libraries ;; diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml index f377f161de..d43fa3eaea 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml @@ -6,54 +6,76 @@ open Async open Graphql_async.Schema -let backfill_job_typ : (Explorer_backfill_service.t, Explorer_backfill_service.job_snapshot) typ +let backfill_job_typ : + (Explorer_backfill_service.t, Explorer_backfill_service.job_snapshot option) typ = obj "BackfillJob" ~fields:(fun _ -> [ field "id" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.id) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> job.id) ; field "fromHash" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.from_hash) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.from_hash) ; field "toHash" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.to_hash) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.to_hash) ; field "status" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.status) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.status) ; field "diffsPublished" ~typ:(non_null int) ~args:[] - ~resolve:(fun _ job -> job.diffs_published) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.diffs_published) ; field "error" ~typ:string ~args:[] - ~resolve:(fun _ job -> job.error) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.error) ; field "createdAt" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ job -> job.created_at) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.created_at) ; field "startedAt" ~typ:string ~args:[] - ~resolve:(fun _ job -> job.started_at) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.started_at) ; field "finishedAt" ~typ:string ~args:[] - ~resolve:(fun _ job -> job.finished_at) + ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> + job.finished_at) ] ) -let progress_typ : (Explorer_backfill_service.t, Explorer_backfill_service.progress_snapshot) typ +let progress_typ : + (Explorer_backfill_service.t, Explorer_backfill_service.progress_snapshot option) typ = obj "BackfillProgress" ~fields:(fun _ -> [ field "id" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ progress -> progress.id) + ~resolve: + (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> + progress.id) ; field "status" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ progress -> progress.status) + ~resolve: + (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> + progress.status) ; field "diffsPublished" ~typ:(non_null int) ~args:[] - ~resolve:(fun _ progress -> progress.diffs_published) + ~resolve: + (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> + progress.diffs_published) ; field "error" ~typ:string ~args:[] - ~resolve:(fun _ progress -> progress.error) + ~resolve: + (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> + progress.error) ] ) -let health_typ : (Explorer_backfill_service.t, Explorer_backfill_service.health_snapshot) typ +let health_typ : + (Explorer_backfill_service.t, Explorer_backfill_service.health_snapshot option) typ = obj "Health" ~fields:(fun _ -> [ field "ok" ~typ:(non_null bool) ~args:[] - ~resolve:(fun _ value -> value.ok) + ~resolve:(fun _ (value : Explorer_backfill_service.health_snapshot) -> + value.ok) ; field "instanceId" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ value -> value.instance_id) + ~resolve:(fun _ (value : Explorer_backfill_service.health_snapshot) -> + value.instance_id) ; field "startedAt" ~typ:(non_null string) ~args:[] - ~resolve:(fun _ value -> value.started_at) + ~resolve:(fun _ (value : Explorer_backfill_service.health_snapshot) -> + value.started_at) ] ) let query_fields = @@ -61,11 +83,12 @@ let query_fields = ~args:Arg.[ arg "id" ~typ:(non_null string) ] ~resolve:(fun { ctx; _ } () id -> return + (Ok (Option.map (Explorer_backfill_service.find_job ctx id) - ~f:Explorer_backfill_service.snapshot ) ) + ~f:Explorer_backfill_service.snapshot ) ) ) ; io_field "health" ~typ:(non_null health_typ) ~args:[] - ~resolve:(fun { ctx; _ } () () -> - return (Explorer_backfill_service.health ctx)) + ~resolve:(fun { ctx; _ } () -> + return (Ok (Explorer_backfill_service.health ctx))) ] let mutation_fields = diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_server.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_server.ml index c388a3753b..5b71257528 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_server.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_server.ml @@ -4,7 +4,6 @@ open Core open Async open Cli_lib -open Sequencer_lib module Graphql_cohttp_async = Init.Graphql_internal.Make (Graphql_async.Schema) (Cohttp_async.Io) diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index f1dc5a6e73..f347e2b77f 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -4,7 +4,6 @@ open Core open Async -open Sequencer_lib open Mina_base type job_status = @@ -290,17 +289,19 @@ let run_job t job = let%bind.Deferred.Result () = publish_targets source_ledger_hash 0 target_ledger_hashes in - return () ) + Deferred.Result.return () ) >>= function - | Ok () -> - update_job job ~status:Completed ~finished_at:(Time.now ()) () - | Error error -> + | Ok (Ok ()) -> + update_job job ~status:Completed ~finished_at:(Time.now ()) () ; + Deferred.unit + | Ok (Error error) | Error error -> update_job job ~status:Failed ~finished_at:(Time.now ()) - ~error:(Error.to_string_hum error) () ) + ~error:(Error.to_string_hum error) () ; + Deferred.unit ) let parse_ledger_hash hash = Or_error.try_with (fun () -> Ledger_hash.of_decimal_string hash) - |> Or_error.map_error ~f:(fun err -> + |> Result.map_error ~f:(fun err -> Error.tag_arg err "Invalid ledger hash" hash String.sexp_of_t ) let create_job ?error ?started_at ?finished_at ~status ~from_hash ~to_hash () = diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml index 132eb404b7..4cce257e3f 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_sse.ml @@ -48,11 +48,11 @@ let execute_subscription t req body = let rec write_stream body_writer stream = let open Deferred.Let_syntax in - match stream () with - | Seq.Nil -> + match%bind Pipe.read stream with + | `Eof -> let%map () = Pipe.write body_writer complete_event in Pipe.close body_writer - | Seq.Cons (payload, next) -> + | `Ok payload -> let payload = match payload with | Ok payload -> @@ -61,7 +61,7 @@ let rec write_stream body_writer stream = err in let%bind () = Pipe.write body_writer (next_event payload) in - write_stream body_writer next + write_stream body_writer stream let callback t _conn req body = let open Deferred.Let_syntax in @@ -74,7 +74,5 @@ let callback t _conn req body = | Ok stream -> let body_reader, body_writer = Pipe.create () in don't_wait_for (write_stream body_writer stream) ; - Cohttp_async.Server.respond ~headers - ~body:(Cohttp_async.Body.of_pipe body_reader) - () + Cohttp_async.Server.respond_with_pipe ~headers body_reader >>| fun response -> `Response response diff --git a/src/app/zeko/sequencer/explorer/tests/dune b/src/app/zeko/sequencer/explorer/tests/dune index fd52fc91c4..a498b9196b 100644 --- a/src/app/zeko/sequencer/explorer/tests/dune +++ b/src/app/zeko/sequencer/explorer/tests/dune @@ -1,15 +1,23 @@ ; Gherkin-backed acceptance tests for the explorer publish and backfill contract. +(library + (name explorer_feature_parser) + (wrapped false) + (modules feature_parser) + (libraries core)) + (library (name explorer_gherkin_tests) (inline_tests) - (modules feature_parser explorer_gherkin_tests) + (modules explorer_gherkin_tests) (libraries + explorer_feature_parser explorer_events - explorer_backfill_service - sequencer_lib - da_layer - zeko_constants + explorer_backfill + sequencer_lib + is_compile_simple_real_real + da_layer + zeko_constants ;; mina ;; mina_base snark_params @@ -21,6 +29,33 @@ core_kernel async async_unix + nats-client + nats-client-async ppx_inline_test.config) (preprocess (pps ppx_let ppx_jane ppx_mina))) + +(executable + (name explorer_nats_gherkin_tests) + (modules explorer_nats_gherkin_tests) + (libraries + explorer_feature_parser + explorer_events + explorer_backfill + sequencer_lib + is_compile_simple_real_real + da_layer + zeko_constants + ;; mina ;; + mina_base + snark_params + ;; opam libraries ;; + yojson + core + core_kernel + async + async_unix + nats-client + nats-client-async) + (preprocess + (pps ppx_let ppx_jane ppx_mina))) diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml index a75091496f..340716a50a 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -42,6 +42,10 @@ let graphql_response service query = | Error err -> failwith (Yojson.Basic.to_string err) ) ) +let graphql_data_exn service query field = + find_assoc_exn field + (find_assoc_exn "data" (graphql_response service query)) + let pipe_read_exn reader = Thread_safe.block_on_async_exn (fun () -> Pipe.read reader @@ -51,7 +55,17 @@ let pipe_read_exn reader = | `Eof -> failwith "Unexpected EOF" ) -let add_job service ?(status = Explorer_backfill_service.Queued) +let pipe_read_string_exn reader = + Thread_safe.block_on_async_exn (fun () -> + Pipe.read reader + >>| function + | `Ok value -> + value + | `Eof -> + failwith "Unexpected EOF" ) + +let add_job (service : Explorer_backfill_service.t) + ?(status = Explorer_backfill_service.Queued) ?(diffs_published = 0) ?error ?started_at ?finished_at ?(id = "job-1") () = let job : Explorer_backfill_service.job = @@ -77,31 +91,70 @@ let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = ~command_with_action_step_flags:None ) ~acc_set_root:Snark_params.Tick.Field.zero +let sse_request body = + let headers = Cohttp.Header.of_list [ ("Content-Type", "application/json") ] in + Cohttp.Request.make ~meth:`POST ~headers + (Uri.of_string "http://localhost/graphql/stream"), + body + +let sse_reader_exn service body = + let req, body = sse_request body in + Thread_safe.block_on_async_exn (fun () -> + match%bind Explorer_backfill_sse.execute_subscription service req body with + | Error err -> + failwith (Error.to_string_hum err) + | Ok stream -> + let reader, writer = Pipe.create () in + don't_wait_for (Explorer_backfill_sse.write_stream writer stream) ; + return reader ) + +let parse_sse_next_event_exn event = + let prefix = "event: next\ndata: " in + if not (String.is_prefix event ~prefix) + then failwithf "Unexpected SSE event: %s" event () + else + String.drop_prefix event (String.length prefix) + |> String.chop_suffix_exn ~suffix:"\n\n" + |> Yojson.Basic.from_string + +let backfill_progress_event_exn event = + find_assoc_exn "backfillProgress" + (find_assoc_exn "data" (parse_sse_next_event_exn event)) + +let assert_basic_json_equal actual expected = + if not (Yojson.Basic.equal actual expected) + then + failwithf "Expected %s but got %s" (Yojson.Basic.to_string expected) + (Yojson.Basic.to_string actual) () + let%test_unit "A genesis backfill marks only the first replayed diff as genesis" = Feature_parser.assert_scenario "backfill-api.feature" "A genesis backfill marks only the first replayed diff as genesis" ; - [%test_eq: Explorer_events.Transaction_kind.t] - (Explorer_backfill_service.backfill_kind - ~from_hash:Explorer_backfill_service.genesis_hash ~index:0 ) - Explorer_events.Transaction_kind.Genesis_replay ; - [%test_eq: Explorer_events.Transaction_kind.t] - (Explorer_backfill_service.backfill_kind - ~from_hash:Explorer_backfill_service.genesis_hash ~index:1 ) - Explorer_events.Transaction_kind.Sync_replay + if + not + (Poly.equal + (Explorer_backfill_service.backfill_kind + ~from_hash:Explorer_backfill_service.genesis_hash ~index:0 ) + Explorer_events.Transaction_kind.Genesis_replay ) + then failwith "expected the first genesis replay diff to be marked as genesis" ; + if + not + (Poly.equal + (Explorer_backfill_service.backfill_kind + ~from_hash:Explorer_backfill_service.genesis_hash ~index:1 ) + Explorer_events.Transaction_kind.Sync_replay ) + then failwith "expected later genesis replay diffs to be marked as sync" let%test_unit "The backfill mutation returns a failed job snapshot for invalid hashes" = Feature_parser.assert_scenario "backfill-api.feature" "The backfill mutation returns a failed job snapshot for invalid hashes" ; let service = test_service () in - let response = - graphql_response service - {|mutation { backfill(fromHash: "nonexistent", toHash: "D") { status error } }|} - in let backfill_job = - find_assoc_exn "backfill" (find_assoc_exn "data" response) + graphql_data_exn service + {|mutation { backfill(fromHash: "nonexistent", toHash: "D") { status error } }|} + "backfill" in - [%test_eq: Yojson.Basic.t] - (find_assoc_exn "status" backfill_job) + assert_basic_json_equal (find_assoc_exn "status" backfill_job) (`String "failed") ; [%test_eq: bool] (match find_assoc_exn "error" backfill_job with @@ -117,34 +170,45 @@ let%test_unit "Backfill progress subscriptions stream job updates" = let service = test_service () in let job = add_job service () in let reader = - match Explorer_backfill_service.subscribe_progress service ~id:job.id with - | Ok reader -> - reader - | Error err -> - failwith (Error.to_string_hum err) + sse_reader_exn service + (Yojson.Basic.to_string + (`Assoc + [ ( "query" + , `String + (sprintf + {|subscription { backfillProgress(id: "%s") { id status diffsPublished error } }|} + job.id ) ) + ]) ) in - let initial = pipe_read_exn reader in - [%test_eq: int] initial.diffs_published 0 ; + let initial = + pipe_read_string_exn reader |> backfill_progress_event_exn + in + assert_basic_json_equal (find_assoc_exn "diffsPublished" initial) (`Int 0) ; Explorer_backfill_service.update_job job ~status:Running ~diffs_published:1 ~started_at:Time.epoch () ; - let first = pipe_read_exn reader in - [%test_eq: int] first.diffs_published 1 ; + let first = + pipe_read_string_exn reader |> backfill_progress_event_exn + in + assert_basic_json_equal (find_assoc_exn "diffsPublished" first) (`Int 1) ; Explorer_backfill_service.update_job job ~status:Completed ~diffs_published:2 ~finished_at:Time.epoch () ; - let final_progress = pipe_read_exn reader in - [%test_eq: int] final_progress.diffs_published 2 ; - [%test_eq: string] final_progress.status "completed" + let final_progress = + pipe_read_string_exn reader |> backfill_progress_event_exn + in + assert_basic_json_equal (find_assoc_exn "diffsPublished" final_progress) + (`Int 2) ; + assert_basic_json_equal (find_assoc_exn "status" final_progress) + (`String "completed") let%test_unit "The backfill health query exposes the service instance" = Feature_parser.assert_scenario "backfill-api.feature" "The backfill health query exposes the service instance" ; - let response = - graphql_response (test_service ()) + let health = + graphql_data_exn (test_service ()) {|query { health { instanceId startedAt } }|} + "health" in - let health = find_assoc_exn "health" (find_assoc_exn "data" response) in - [%test_eq: Yojson.Basic.t] - (find_assoc_exn "instanceId" health) + assert_basic_json_equal (find_assoc_exn "instanceId" health) (`String "instance-1") ; [%test_eq: bool] (match find_assoc_exn "startedAt" health with @@ -154,6 +218,59 @@ let%test_unit "The backfill health query exposes the service instance" = false ) true +let%test_unit "The backfill job query returns the current job snapshot" = + Feature_parser.assert_scenario "backfill-api.feature" + "The backfill job query returns the current job snapshot" ; + let service = test_service () in + let job = add_job service ~status:Explorer_backfill_service.Running () in + let backfill_job = + graphql_data_exn service + (sprintf + {|query { backfillJob(id: "%s") { id status diffsPublished } }|} + job.id ) + "backfillJob" + in + assert_basic_json_equal (find_assoc_exn "id" backfill_job) (`String job.id) ; + assert_basic_json_equal (find_assoc_exn "status" backfill_job) + (`String "running") + +let%test_unit "Backfill progress subscriptions stream GraphQL-SSE events" = + Feature_parser.assert_scenario "backfill-api.feature" + "Backfill progress subscriptions stream GraphQL-SSE events" ; + let service = test_service () in + let job = add_job service () in + let reader = + sse_reader_exn service + (Yojson.Basic.to_string + (`Assoc + [ ( "query" + , `String + (sprintf + {|subscription { backfillProgress(id: "%s") { id status diffsPublished error } }|} + job.id ) ) + ]) ) + in + let first = + pipe_read_string_exn reader |> backfill_progress_event_exn + in + assert_basic_json_equal (find_assoc_exn "diffsPublished" first) (`Int 0) ; + Explorer_backfill_service.update_job job ~status:Running ~diffs_published:1 + ~started_at:Time.epoch () ; + let running = + pipe_read_string_exn reader |> backfill_progress_event_exn + in + assert_basic_json_equal (find_assoc_exn "diffsPublished" running) (`Int 1) ; + Explorer_backfill_service.update_job job ~status:Completed + ~diffs_published:2 ~finished_at:Time.epoch () ; + let completed = + pipe_read_string_exn reader |> backfill_progress_event_exn + in + assert_basic_json_equal (find_assoc_exn "status" completed) + (`String "completed") ; + [%test_eq: string] + (pipe_read_string_exn reader) + Explorer_backfill_sse.complete_event + let%test_unit "Transaction events include the replay kind, diff payload, and NATS dedup header" = Feature_parser.assert_scenario "event-contract.feature" "Transaction events include the replay kind, diff payload, and NATS dedup header" ; diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml new file mode 100644 index 0000000000..a4b602a654 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml @@ -0,0 +1,164 @@ +(* Executes Gherkin-backed explorer integration scenarios against a real NATS + server supplied through the NATS_URL environment variable. *) + +open Core +open Async +open Mina_base + +let url () = + match Sys.getenv "NATS_URL" with + | Some value -> + Uri.of_string value + | None -> + failwith "NATS_URL must be set for explorer NATS integration tests" + +let connect_or_fail uri = + Clock_ns.with_timeout (Time_ns.Span.of_sec 5.) + (Nats_client_async.connect (Some uri)) + >>= function + | `Timeout -> + failwithf "timed out connecting to %s" (Uri.to_string uri) () + | `Result client -> + return client + +let expect_ok label = function + | Ok value -> + value + | Error err -> + failwithf "%s failed: %s" label (Error.to_string_hum err) () + +let read_message label reader = + Clock_ns.with_timeout (Time_ns.Span.of_sec 5.) (Pipe.read reader) + >>= function + | `Timeout -> + failwithf "%s timed out waiting for a message" label () + | `Result `Eof -> + failwithf "%s closed before delivering a message" label () + | `Result (`Ok message) -> + return message + +let wait_for_subscription_registration () = + (* The async NATS client exposes subscribe but not a flush/ack primitive, + so give the server a brief moment to register the SUB before publishing. *) + Clock_ns.after (Time_ns.Span.of_ms 100.) + +let require_header headers ~name ~expected = + match List.Assoc.find headers ~equal:String.equal name with + | Some value when String.equal value expected -> + () + | Some value -> + failwithf "expected header %s=%s but received %s" name expected value () + | None -> + failwithf "missing header %s" name () + +let json_assoc_exn key json = + match json with + | `Assoc fields -> + List.Assoc.find_exn fields key ~equal:String.equal + | _ -> + failwith "Expected JSON object" + +let assert_safe_json_equal actual expected = + if not (Yojson.Safe.equal actual expected) + then + failwithf "Expected %s but got %s" (Yojson.Safe.to_string expected) + (Yojson.Safe.to_string actual) () + +let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = + Explorer_events.build_live_diff ~logger:(Logger.create ()) + ~diff: + (Da_layer.Diff.create ~source_ledger_hash ~changed_accounts:[] + ~command_with_action_step_flags:None ) + ~acc_set_root:Snark_params.Tick.Field.zero + +let test_service nats_client : Explorer_backfill_service.t = + { logger = Logger.create () + ; da_config = Da_layer.Client.Config.of_string_list [] + ; nats_client = Some nats_client + ; jobs = String.Table.create () + ; instance_id = "integration-instance" + ; started_at = Time.epoch + } + +let with_clients f = + let uri = url () in + let%bind subscriber = connect_or_fail uri in + let%bind actor = connect_or_fail uri in + Monitor.protect + ~finally:(fun () -> + Nats_client_async.close actor >>= fun () -> + Nats_client_async.close subscriber) + (fun () -> f ~subscriber ~actor) + +let run_live_transaction_scenario () = + Feature_parser.assert_scenario "nats-integration.feature" + "Live transaction events round-trip through NATS with the dedup header" ; + with_clients (fun ~subscriber ~actor -> + let%bind subscription = + Nats_client_async.subscribe subscriber + ~subject:Explorer_events.Subject.transactions () + in + let subscription = expect_ok "live transaction subscription" subscription in + let%bind () = wait_for_subscription_registration () in + let target_ledger_hash = Ledger_hash.empty_hash in + let sink = Explorer_events.create_nats_sink actor in + Explorer_events.publish_transaction sink + ~kind:Explorer_events.Transaction_kind.User_command + ~target_ledger_hash ~genesis:false ~diff:(sample_diff ()) ; + let%map message = + read_message "live transaction publish" subscription.messages + in + if not (String.equal message.subject Explorer_events.Subject.transactions) + then + failwithf "unexpected transaction subject: %s" message.subject () ; + let headers = + message.headers + |> Option.value ~default:Nats_client.Headers.empty + |> Nats_client.Headers.to_list + in + require_header headers ~name:"Nats-Msg-Id" + ~expected:(Ledger_hash.to_decimal_string target_ledger_hash) ; + let payload = Yojson.Safe.from_string message.payload in + assert_safe_json_equal (json_assoc_exn "kind" payload) + (`String "user_command") ) + +let run_backfill_scenario () = + Feature_parser.assert_scenario "nats-integration.feature" + "Backfill replay events round-trip through NATS as genesis replays" ; + with_clients (fun ~subscriber ~actor -> + let%bind subscription = + Nats_client_async.subscribe subscriber + ~subject:Explorer_events.Subject.transactions () + in + let subscription = expect_ok "backfill subscription" subscription in + let%bind () = wait_for_subscription_registration () in + let service = test_service actor in + let target_ledger_hash = Ledger_hash.empty_hash in + let publish_result = + Explorer_backfill_service.publish_backfill_diff service + ~from_hash:Explorer_backfill_service.genesis_hash ~index:0 + ~target_ledger_hash + (sample_diff ~source_ledger_hash:Explorer_backfill_service.genesis_hash + () ) + in + if not (Poly.equal publish_result `Queued) + then failwith "expected backfill publish to be queued" ; + let%map message = + read_message "backfill publish" subscription.messages + in + if not (String.equal message.subject Explorer_events.Subject.transactions) + then + failwithf "unexpected backfill subject: %s" message.subject () ; + let payload = Yojson.Safe.from_string message.payload in + assert_safe_json_equal (json_assoc_exn "kind" payload) + (`String "genesis_replay") ; + assert_safe_json_equal (json_assoc_exn "genesis" payload) (`Bool true) ) + +let main () = + let%bind () = run_live_transaction_scenario () in + let%bind () = run_backfill_scenario () in + printf "explorer NATS Gherkin integration scenarios passed against %s\n" + (Uri.to_string (url ())) ; + return () + +let () = Thread_safe.block_on_async_exn main diff --git a/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature b/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature index fa46f6425b..95e322930b 100644 --- a/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature +++ b/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature @@ -24,3 +24,16 @@ Feature: Explorer Backfill API When GraphQL query health is executed Then the response includes instanceId And the response includes startedAt + + Scenario: The backfill job query returns the current job snapshot + Given a standalone backfill service with a stored backfill job + When GraphQL query backfillJob is executed for that id + Then the response includes the matching job id + And the response includes the job status + + Scenario: Backfill progress subscriptions stream GraphQL-SSE events + Given a standalone backfill service with a queued backfill job + When the GraphQL-SSE endpoint subscribes to that job + Then the first SSE event includes diffsPublished 0 + And later SSE events reflect published progress + And the stream finishes with a complete event diff --git a/src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature b/src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature new file mode 100644 index 0000000000..9bc1d3a318 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature @@ -0,0 +1,15 @@ +Feature: Explorer NATS Integration + + Scenario: Live transaction events round-trip through NATS with the dedup header + Given a running NATS server + When the explorer publisher emits a live transaction event + Then a subscriber receives it on "zeko.l2.transactions" + And the message includes the "Nats-Msg-Id" header + And the payload kind is "user_command" + + Scenario: Backfill replay events round-trip through NATS as genesis replays + Given a running NATS server + When the backfill service republishes the first genesis diff + Then a subscriber receives it on "zeko.l2.transactions" + And the payload kind is "genesis_replay" + And the payload marks genesis as true diff --git a/src/app/zeko/sequencer/lib/dune b/src/app/zeko/sequencer/lib/dune index 17efcf95ea..72b90f2c04 100644 --- a/src/app/zeko/sequencer/lib/dune +++ b/src/app/zeko/sequencer/lib/dune @@ -14,8 +14,8 @@ utils gql_client explorer_events - nats_client - nats_client_async + nats-client + nats-client-async ;; mina ;; mina_base mina_base.import diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index f6991e0d63..cb68bbc384 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -854,14 +854,17 @@ module Sequencer = struct in don't_wait_for (within' ~monitor:Monitor.main (fun () -> go ())) - let replay_genesis_flag ~source ~current_chunk ~current_diff = + let replay_genesis_flag + ~(source : [ `Genesis | `Specific of Field.t ]) + ~current_chunk ~current_diff = match source with | `Genesis -> current_chunk = 0 && current_diff = 0 | `Specific _ -> false - let sync ~logger ({ config; _ } as t) da_config source = + let sync ~logger ({ config; _ } as t) da_config + (source : [ `Genesis | `Specific of Field.t ]) = [%log info] "Syncing" ; let%bind commited_ledger_hash = Gql_client.infer_state ~logger config.l1_uri diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml b/src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml deleted file mode 100644 index dac4177f98..0000000000 --- a/src/app/zeko/sequencer/lib/zeko_sequencer_tests.ml +++ /dev/null @@ -1,17 +0,0 @@ -(* Keeps focused sequencer inline tests in a separate module so the main - sequencer implementation file stays centered on runtime behavior. *) - -open Mina_base - -let%test_unit "genesis sync labels the first replayed diff as genesis" = - [%test_eq: bool] - (Zeko_sequencer.Sequencer.replay_genesis_flag ~source:`Genesis - ~current_chunk:0 ~current_diff:0 ) - true - -let%test_unit "checkpoint sync never relabels replayed diffs as genesis" = - [%test_eq: bool] - (Zeko_sequencer.Sequencer.replay_genesis_flag - ~source:(`Specific Ledger_hash.empty_hash) - ~current_chunk:0 ~current_diff:0 ) - false diff --git a/src/app/zeko/sequencer/tests/run-explorer-tests.sh b/src/app/zeko/sequencer/tests/run-explorer-tests.sh new file mode 100755 index 0000000000..fd20d7739c --- /dev/null +++ b/src/app/zeko/sequencer/tests/run-explorer-tests.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER_NAME="nats-sequencer-explorer-tests" +NATS_URL="${NATS_URL:-nats://127.0.0.1:4222}" + +cleanup() { + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true +} + +trap cleanup EXIT + +wait_for_port() { + local port="$1" + local attempts=30 + while ! nc -z 127.0.0.1 "${port}" >/dev/null 2>&1; do + attempts=$((attempts - 1)) + if [ "${attempts}" -le 0 ]; then + echo "Timed out waiting for port ${port}" >&2 + exit 1 + fi + sleep 1 + done +} + +cleanup + +docker run --rm --name "${CONTAINER_NAME}" -p 4222:4222 -d nats:2-alpine >/dev/null +wait_for_port 4222 + +opam exec --switch . -- env -u DUNE_RPC dune runtest --profile=devnet \ + src/app/zeko/sequencer/explorer/tests +NATS_URL="${NATS_URL}" opam exec --switch . -- env -u DUNE_RPC dune exec --profile=devnet \ + src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe diff --git a/src/app/zeko/sequencer/tests/run-sequencer-test.sh b/src/app/zeko/sequencer/tests/run-sequencer-test.sh index 20853b2929..a3cfce7aa7 100755 --- a/src/app/zeko/sequencer/tests/run-sequencer-test.sh +++ b/src/app/zeko/sequencer/tests/run-sequencer-test.sh @@ -44,6 +44,14 @@ trap 'cleanup $?' EXIT SEQUENCER_ROOT="$(git rev-parse --show-toplevel)/src/app/zeko/sequencer" SEQUENCER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/sequencer" +opam exec --switch . -- env -u DUNE_RPC dune build \ + src/app/zeko/sequencer/tests/testing_ledger/run.exe \ + src/app/zeko/da_layer/cli.exe \ + src/app/zeko/sequencer/prover/cli.exe \ + src/app/zeko/sequencer/prover/cli_fake.exe \ + src/app/zeko/sequencer/tests/sequencer_test.exe \ + src/app/zeko/sequencer/tests/sequencer_test_fake.exe + export ZEKO_SIGNATURE_KIND=testnet export ZEKO_CIRCUITS_CONFIG=test diff --git a/src/app/zeko/sequencer/tests/sequencer_test.ml b/src/app/zeko/sequencer/tests/sequencer_test.ml index 5c1aee9482..0ce1b13006 100644 --- a/src/app/zeko/sequencer/tests/sequencer_test.ml +++ b/src/app/zeko/sequencer/tests/sequencer_test.ml @@ -215,7 +215,7 @@ let () = let new_sequencer = run (fun () -> let%map new_sequencer = - Sequencer.create ~logger ~max_pool_size:10 + Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 ~commitment_period_sec:0. ~da_config:da_config_with2 ~da_keys ~da_quorum ~db_dir:None ~checkpoints_dir:None ~postgres_uri:postgres_uri2 ~l1_uri:gql_uri ~archive_uri:gql_uri @@ -318,7 +318,8 @@ let () = print_endline "(* Restart sequencer *)" ; let new_sequencer = run (fun () -> - Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. + Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 + ~commitment_period_sec:0. ~da_config:da_config_with3 ~da_quorum ~db_dir:(Some db_dir) ~checkpoints_dir:None ~postgres_uri ~l1_uri:gql_uri ~archive_uri:gql_uri ~signer ~deposit_delay_blocks:0 ~mq_host @@ -449,7 +450,8 @@ let () = print_endline "(* Restart sequencer from checkpoint *)" ; let new_sequencer = run (fun () -> - Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. + Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 + ~commitment_period_sec:0. ~da_config:da_config_with3 ~da_quorum ~db_dir:(Some db_dir2) ~checkpoints_dir:(Some checkpoints_dir) ~postgres_uri:postgres_uri2 ~l1_uri:gql_uri ~archive_uri:gql_uri @@ -556,7 +558,8 @@ let () = print_endline "(* Restart sequencer *)" ; let new_sequencer = run (fun () -> - Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. + Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 + ~commitment_period_sec:0. ~da_config:da_config_with2 ~da_quorum ~db_dir:(Some db_dir) ~checkpoints_dir:None ~postgres_uri ~l1_uri:gql_uri ~archive_uri:gql_uri ~signer ~deposit_delay_blocks:0 ~mq_host diff --git a/src/app/zeko/sequencer/tests/test_spec.ml b/src/app/zeko/sequencer/tests/test_spec.ml index e649220ba7..e8e5563353 100644 --- a/src/app/zeko/sequencer/tests/test_spec.ml +++ b/src/app/zeko/sequencer/tests/test_spec.ml @@ -513,8 +513,9 @@ module Sequencer_spec = struct print_endline "(* Init sequencer *)" ; let sequencer = - run (fun () -> - Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. + run (fun () -> + Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 + ~commitment_period_sec:0. ~da_config ~da_keys ~da_quorum ~db_dir ~postgres_uri ~l1_uri:gql_uri ~archive_uri:gql_uri ~signer ~deposit_delay_blocks:delay_deposit ~mq_host ~fee_modifier:1.0 ~minimum_fee:0.01 ~slot_acceptance From 698b5842670a5616e3d6ee2353c450685301f884 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 07:08:30 +0700 Subject: [PATCH 09/51] Use published nats opam dependencies --- opam.export | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opam.export b/opam.export index 5839b52777..7d52793df3 100644 --- a/opam.export +++ b/opam.export @@ -54,7 +54,7 @@ roots: [ "utop.2.9.1" "websocket.2.14" "websocket-async.2.14" - "yojson.1.7.0" + "yojson.2.0.0" ] installed: [ "alcotest.1.1.0" @@ -306,7 +306,7 @@ installed: [ "websocket.2.14" "websocket-async.2.14" "xdg.3.5.0" - "yojson.1.7.0" + "yojson.2.0.0" "zarith.1.7" "zarith_stubs_js.v0.14.1" "zed.3.1.0" From c50fc7d245c409f2262d3f5c69abcc20a99e5820 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 07:13:00 +0700 Subject: [PATCH 10/51] Update graphql opam packages for yojson 2 --- opam.export | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/opam.export b/opam.export index 7d52793df3..04edfec6bc 100644 --- a/opam.export +++ b/opam.export @@ -19,9 +19,9 @@ roots: [ "dispatch.0.5.0" "extlib.1.7.8" "fileutils.0.6.4" - "graphql-async.0.13.0" - "graphql-cohttp.0.13.0" - "graphql-lwt.0.13.0" + "graphql-async.0.14.0" + "graphql-cohttp.0.14.0" + "graphql-lwt.0.14.0" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" "js_of_ocaml.4.0.0" @@ -147,10 +147,10 @@ installed: [ "fix.20201120" "fmt.0.8.6" "fpath.0.7.3" - "graphql.0.13.0" - "graphql-async.0.13.0" - "graphql-cohttp.0.13.0" - "graphql-lwt.0.13.0" + "graphql.0.14.0" + "graphql-async.0.14.0" + "graphql-cohttp.0.14.0" + "graphql-lwt.0.14.0" "graphql_parser.0.12.2" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" From 65f0814ff3037fe0cf455e9d075a0771d99084fe Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 07:17:59 +0700 Subject: [PATCH 11/51] Use published opam repository overlay for nats --- .github/workflows/ci.yaml | 14 ++++++++++++-- scripts/update-opam-switch.sh | 30 ++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f87a2830f6..335ea9ed36 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,9 +62,19 @@ jobs: getent hosts github.com || true sleep 5 done - git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository fetch origin 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 + git -C ./opam-repository checkout 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 + for i in 1 2 3 4 5; do + rm -rf ./opam-repository-published + git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository-published && break + echo "clone failed, retry $i" + getent hosts github.com || true + sleep 5 + done + git -C ./opam-repository-published fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository-published checkout ba1ca7509cb2617776f017673de0f2a48be67105 opam init --disable-sandboxing -k git -a ./opam-repository --bare + opam repository add --yes --all --kind=local published ./opam-repository-published for i in 1 2 3 4 5; do opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break echo "opam repo add failed, retry $i" diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index 5e53c930cf..8aa2d5bdf1 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -3,8 +3,8 @@ # Reuses a shared opam switch cache keyed by the exported toolchain. Each # checkout keeps a local `_opam` symlink pointing at the shared cache entry so # identical dependency snapshots can be reused across worktrees. The opam -# package universe is pinned to the same ocaml/opam-repository commit used in CI -# so local resolution matches CI. +# package universe is pinned to the same ocaml/opam-repository commits used in +# CI so local resolution matches CI. set -eo pipefail @@ -99,12 +99,15 @@ configure_homebrew_env() { # Don't do anything if we're in a nix shell [[ "$IN_NIX_SHELL$CI$BUILDKITE" == "" ]] || exit 0 -opam_repo_commit="ba1ca7509cb2617776f017673de0f2a48be67105" +opam_repo_commit="08d8c16c16dc6b23a5278b06dff0ac6c7a217356" +published_opam_repo_commit="ba1ca7509cb2617776f017673de0f2a48be67105" repo_cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/zeko" opam_repo_dir="${repo_cache_root}/opam-repository/${opam_repo_commit}" +published_opam_repo_dir="${repo_cache_root}/opam-repository/${published_opam_repo_commit}" sum="$({ printf '%s\n' "${opam_repo_commit}" + printf '%s\n' "${published_opam_repo_commit}" cat opam.export } | cksum | awk '{print $1}')" cache_root="${repo_cache_root}/opam-switches" @@ -135,6 +138,16 @@ if [[ ! -d "${switch_dir}" ]]; then git -C "${opam_repo_dir}" fetch origin "${opam_repo_commit}" --depth 1 git -C "${opam_repo_dir}" checkout --detach "${opam_repo_commit}" + mkdir -p "$(dirname "${published_opam_repo_dir}")" + if [[ ! -d "${published_opam_repo_dir}/.git" ]]; then + git clone https://github.com/ocaml/opam-repository.git --depth 1 \ + "${published_opam_repo_dir}" + fi + git -C "${published_opam_repo_dir}" fetch origin \ + "${published_opam_repo_commit}" --depth 1 + git -C "${published_opam_repo_dir}" checkout --detach \ + "${published_opam_repo_commit}" + if opam repository list --all --short | grep -qx default; then opam repository set-url --kind=local default "${opam_repo_dir}" opam repository add --yes --all --set-default default @@ -143,11 +156,20 @@ if [[ ! -d "${switch_dir}" ]]; then "${opam_repo_dir}" fi + if opam repository list --all --short | grep -qx published; then + opam repository set-url --kind=local published \ + "${published_opam_repo_dir}" + opam repository add --yes --all published + else + opam repository add --yes --all --kind=local published \ + "${published_opam_repo_dir}" + fi + # We add o1-labs opam repository and make it default selection # (if it's repeated, it's a no-op). opam repository add --yes --all --set-default o1-labs \ https://github.com/o1-labs/opam-repository.git - opam update default o1-labs + opam update default published o1-labs opam switch import -y --assume-depexts --switch . opam.export mkdir -p "${cache_root}" mv _opam "${switch_dir}" From b919794e3085043dfe47a09b0a0f5df23062eb54 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 07:23:54 +0700 Subject: [PATCH 12/51] Resolve yojson 2 opam import constraints --- .github/workflows/ci.yaml | 1 + opam.export | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 335ea9ed36..d8b30816af 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,6 +80,7 @@ jobs: echo "opam repo add failed, retry $i" sleep 5 done + opam update default published o1-labs opam switch create "4.14.0" opam switch "4.14.0" export OPAMERRLOGLEN=20 diff --git a/opam.export b/opam.export index 04edfec6bc..5c097d17f0 100644 --- a/opam.export +++ b/opam.export @@ -29,7 +29,7 @@ roots: [ "js_of_ocaml-toplevel.4.0.0" "lmdb.1.0" "menhir.20210419" - "merlin.4.5-414" + "merlin.4.18-414" "nats-client.0.0.7" "nats-client-async.0.0.7" "ocaml-base-compiler.4.14.0" @@ -129,7 +129,7 @@ installed: [ "digestif.0.9.0" "dispatch.0.5.0" "domain-name.0.3.0" - "dot-merlin-reader.4.2" + "dot-merlin-reader.4.18-414" "dune.3.19.0" "dune-build-info.3.1.1" "dune-configurator.2.8.2" @@ -179,8 +179,9 @@ installed: [ "menhir.20210419" "menhirLib.20210419" "menhirSdk.20210419" - "merlin.4.5-414" + "merlin.4.18-414" "merlin-extend.0.6.1" + "merlin-lib.4.18-414" "mew.0.1.0" "mew_vi.0.5.0" "minicli.5.0.2" From 5a504c093e25cd1ebecd1a9ff538d1e711a3f157 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 07:42:41 +0700 Subject: [PATCH 13/51] Revert broad opam dependency refresh --- .github/workflows/ci.yaml | 15 ++------------- opam.export | 25 ++++++++++++------------- scripts/update-opam-switch.sh | 30 ++++-------------------------- 3 files changed, 18 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d8b30816af..f87a2830f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,25 +62,14 @@ jobs: getent hosts github.com || true sleep 5 done - git -C ./opam-repository fetch origin 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 - git -C ./opam-repository checkout 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 - for i in 1 2 3 4 5; do - rm -rf ./opam-repository-published - git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository-published && break - echo "clone failed, retry $i" - getent hosts github.com || true - sleep 5 - done - git -C ./opam-repository-published fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository-published checkout ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 opam init --disable-sandboxing -k git -a ./opam-repository --bare - opam repository add --yes --all --kind=local published ./opam-repository-published for i in 1 2 3 4 5; do opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break echo "opam repo add failed, retry $i" sleep 5 done - opam update default published o1-labs opam switch create "4.14.0" opam switch "4.14.0" export OPAMERRLOGLEN=20 diff --git a/opam.export b/opam.export index 5c097d17f0..5839b52777 100644 --- a/opam.export +++ b/opam.export @@ -19,9 +19,9 @@ roots: [ "dispatch.0.5.0" "extlib.1.7.8" "fileutils.0.6.4" - "graphql-async.0.14.0" - "graphql-cohttp.0.14.0" - "graphql-lwt.0.14.0" + "graphql-async.0.13.0" + "graphql-cohttp.0.13.0" + "graphql-lwt.0.13.0" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" "js_of_ocaml.4.0.0" @@ -29,7 +29,7 @@ roots: [ "js_of_ocaml-toplevel.4.0.0" "lmdb.1.0" "menhir.20210419" - "merlin.4.18-414" + "merlin.4.5-414" "nats-client.0.0.7" "nats-client-async.0.0.7" "ocaml-base-compiler.4.14.0" @@ -54,7 +54,7 @@ roots: [ "utop.2.9.1" "websocket.2.14" "websocket-async.2.14" - "yojson.2.0.0" + "yojson.1.7.0" ] installed: [ "alcotest.1.1.0" @@ -129,7 +129,7 @@ installed: [ "digestif.0.9.0" "dispatch.0.5.0" "domain-name.0.3.0" - "dot-merlin-reader.4.18-414" + "dot-merlin-reader.4.2" "dune.3.19.0" "dune-build-info.3.1.1" "dune-configurator.2.8.2" @@ -147,10 +147,10 @@ installed: [ "fix.20201120" "fmt.0.8.6" "fpath.0.7.3" - "graphql.0.14.0" - "graphql-async.0.14.0" - "graphql-cohttp.0.14.0" - "graphql-lwt.0.14.0" + "graphql.0.13.0" + "graphql-async.0.13.0" + "graphql-cohttp.0.13.0" + "graphql-lwt.0.13.0" "graphql_parser.0.12.2" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" @@ -179,9 +179,8 @@ installed: [ "menhir.20210419" "menhirLib.20210419" "menhirSdk.20210419" - "merlin.4.18-414" + "merlin.4.5-414" "merlin-extend.0.6.1" - "merlin-lib.4.18-414" "mew.0.1.0" "mew_vi.0.5.0" "minicli.5.0.2" @@ -307,7 +306,7 @@ installed: [ "websocket.2.14" "websocket-async.2.14" "xdg.3.5.0" - "yojson.2.0.0" + "yojson.1.7.0" "zarith.1.7" "zarith_stubs_js.v0.14.1" "zed.3.1.0" diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index 8aa2d5bdf1..5e53c930cf 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -3,8 +3,8 @@ # Reuses a shared opam switch cache keyed by the exported toolchain. Each # checkout keeps a local `_opam` symlink pointing at the shared cache entry so # identical dependency snapshots can be reused across worktrees. The opam -# package universe is pinned to the same ocaml/opam-repository commits used in -# CI so local resolution matches CI. +# package universe is pinned to the same ocaml/opam-repository commit used in CI +# so local resolution matches CI. set -eo pipefail @@ -99,15 +99,12 @@ configure_homebrew_env() { # Don't do anything if we're in a nix shell [[ "$IN_NIX_SHELL$CI$BUILDKITE" == "" ]] || exit 0 -opam_repo_commit="08d8c16c16dc6b23a5278b06dff0ac6c7a217356" -published_opam_repo_commit="ba1ca7509cb2617776f017673de0f2a48be67105" +opam_repo_commit="ba1ca7509cb2617776f017673de0f2a48be67105" repo_cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/zeko" opam_repo_dir="${repo_cache_root}/opam-repository/${opam_repo_commit}" -published_opam_repo_dir="${repo_cache_root}/opam-repository/${published_opam_repo_commit}" sum="$({ printf '%s\n' "${opam_repo_commit}" - printf '%s\n' "${published_opam_repo_commit}" cat opam.export } | cksum | awk '{print $1}')" cache_root="${repo_cache_root}/opam-switches" @@ -138,16 +135,6 @@ if [[ ! -d "${switch_dir}" ]]; then git -C "${opam_repo_dir}" fetch origin "${opam_repo_commit}" --depth 1 git -C "${opam_repo_dir}" checkout --detach "${opam_repo_commit}" - mkdir -p "$(dirname "${published_opam_repo_dir}")" - if [[ ! -d "${published_opam_repo_dir}/.git" ]]; then - git clone https://github.com/ocaml/opam-repository.git --depth 1 \ - "${published_opam_repo_dir}" - fi - git -C "${published_opam_repo_dir}" fetch origin \ - "${published_opam_repo_commit}" --depth 1 - git -C "${published_opam_repo_dir}" checkout --detach \ - "${published_opam_repo_commit}" - if opam repository list --all --short | grep -qx default; then opam repository set-url --kind=local default "${opam_repo_dir}" opam repository add --yes --all --set-default default @@ -156,20 +143,11 @@ if [[ ! -d "${switch_dir}" ]]; then "${opam_repo_dir}" fi - if opam repository list --all --short | grep -qx published; then - opam repository set-url --kind=local published \ - "${published_opam_repo_dir}" - opam repository add --yes --all published - else - opam repository add --yes --all --kind=local published \ - "${published_opam_repo_dir}" - fi - # We add o1-labs opam repository and make it default selection # (if it's repeated, it's a no-op). opam repository add --yes --all --set-default o1-labs \ https://github.com/o1-labs/opam-repository.git - opam update default published o1-labs + opam update default o1-labs opam switch import -y --assume-depexts --switch . opam.export mkdir -p "${cache_root}" mv _opam "${switch_dir}" From 02ad8afc467151e6688f804368b464d3c71866ce Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 07:57:09 +0700 Subject: [PATCH 14/51] Update GraphQL stack for published NATS client --- .github/workflows/ci.yaml | 15 ++++++++-- opam.export | 25 ++++++++-------- scripts/update-opam-switch.sh | 30 ++++++++++++++++--- .../fields_derivers_graphql.ml | 30 +++++++++++-------- src/lib/graphql_wrapper/graphql_wrapper.ml | 24 +++++++++++++-- src/lib/logproc_lib/interpolator.ml | 2 +- 6 files changed, 93 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f87a2830f6..b6afaf8bf7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,14 +62,25 @@ jobs: getent hosts github.com || true sleep 5 done - git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository fetch origin 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 + git -C ./opam-repository checkout 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 + for i in 1 2 3 4 5; do + rm -rf ./opam-repository-published + git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository-published && break + echo "clone failed, retry $i" + getent hosts github.com || true + sleep 5 + done + git -C ./opam-repository-published fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository-published checkout ba1ca7509cb2617776f017673de0f2a48be67105 opam init --disable-sandboxing -k git -a ./opam-repository --bare + opam repository add --yes --all --set-default --kind=local published ./opam-repository-published for i in 1 2 3 4 5; do opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break echo "opam repo add failed, retry $i" sleep 5 done + opam update default published o1-labs opam switch create "4.14.0" opam switch "4.14.0" export OPAMERRLOGLEN=20 diff --git a/opam.export b/opam.export index 5839b52777..5c097d17f0 100644 --- a/opam.export +++ b/opam.export @@ -19,9 +19,9 @@ roots: [ "dispatch.0.5.0" "extlib.1.7.8" "fileutils.0.6.4" - "graphql-async.0.13.0" - "graphql-cohttp.0.13.0" - "graphql-lwt.0.13.0" + "graphql-async.0.14.0" + "graphql-cohttp.0.14.0" + "graphql-lwt.0.14.0" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" "js_of_ocaml.4.0.0" @@ -29,7 +29,7 @@ roots: [ "js_of_ocaml-toplevel.4.0.0" "lmdb.1.0" "menhir.20210419" - "merlin.4.5-414" + "merlin.4.18-414" "nats-client.0.0.7" "nats-client-async.0.0.7" "ocaml-base-compiler.4.14.0" @@ -54,7 +54,7 @@ roots: [ "utop.2.9.1" "websocket.2.14" "websocket-async.2.14" - "yojson.1.7.0" + "yojson.2.0.0" ] installed: [ "alcotest.1.1.0" @@ -129,7 +129,7 @@ installed: [ "digestif.0.9.0" "dispatch.0.5.0" "domain-name.0.3.0" - "dot-merlin-reader.4.2" + "dot-merlin-reader.4.18-414" "dune.3.19.0" "dune-build-info.3.1.1" "dune-configurator.2.8.2" @@ -147,10 +147,10 @@ installed: [ "fix.20201120" "fmt.0.8.6" "fpath.0.7.3" - "graphql.0.13.0" - "graphql-async.0.13.0" - "graphql-cohttp.0.13.0" - "graphql-lwt.0.13.0" + "graphql.0.14.0" + "graphql-async.0.14.0" + "graphql-cohttp.0.14.0" + "graphql-lwt.0.14.0" "graphql_parser.0.12.2" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" @@ -179,8 +179,9 @@ installed: [ "menhir.20210419" "menhirLib.20210419" "menhirSdk.20210419" - "merlin.4.5-414" + "merlin.4.18-414" "merlin-extend.0.6.1" + "merlin-lib.4.18-414" "mew.0.1.0" "mew_vi.0.5.0" "minicli.5.0.2" @@ -306,7 +307,7 @@ installed: [ "websocket.2.14" "websocket-async.2.14" "xdg.3.5.0" - "yojson.1.7.0" + "yojson.2.0.0" "zarith.1.7" "zarith_stubs_js.v0.14.1" "zed.3.1.0" diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index 5e53c930cf..d537523f54 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -3,8 +3,8 @@ # Reuses a shared opam switch cache keyed by the exported toolchain. Each # checkout keeps a local `_opam` symlink pointing at the shared cache entry so # identical dependency snapshots can be reused across worktrees. The opam -# package universe is pinned to the same ocaml/opam-repository commit used in CI -# so local resolution matches CI. +# package universe is pinned to the same ocaml/opam-repository commits used in +# CI so local resolution matches CI. set -eo pipefail @@ -99,12 +99,15 @@ configure_homebrew_env() { # Don't do anything if we're in a nix shell [[ "$IN_NIX_SHELL$CI$BUILDKITE" == "" ]] || exit 0 -opam_repo_commit="ba1ca7509cb2617776f017673de0f2a48be67105" +opam_repo_commit="08d8c16c16dc6b23a5278b06dff0ac6c7a217356" +published_opam_repo_commit="ba1ca7509cb2617776f017673de0f2a48be67105" repo_cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/zeko" opam_repo_dir="${repo_cache_root}/opam-repository/${opam_repo_commit}" +published_opam_repo_dir="${repo_cache_root}/opam-repository/${published_opam_repo_commit}" sum="$({ printf '%s\n' "${opam_repo_commit}" + printf '%s\n' "${published_opam_repo_commit}" cat opam.export } | cksum | awk '{print $1}')" cache_root="${repo_cache_root}/opam-switches" @@ -135,6 +138,16 @@ if [[ ! -d "${switch_dir}" ]]; then git -C "${opam_repo_dir}" fetch origin "${opam_repo_commit}" --depth 1 git -C "${opam_repo_dir}" checkout --detach "${opam_repo_commit}" + mkdir -p "$(dirname "${published_opam_repo_dir}")" + if [[ ! -d "${published_opam_repo_dir}/.git" ]]; then + git clone https://github.com/ocaml/opam-repository.git --depth 1 \ + "${published_opam_repo_dir}" + fi + git -C "${published_opam_repo_dir}" fetch origin \ + "${published_opam_repo_commit}" --depth 1 + git -C "${published_opam_repo_dir}" checkout --detach \ + "${published_opam_repo_commit}" + if opam repository list --all --short | grep -qx default; then opam repository set-url --kind=local default "${opam_repo_dir}" opam repository add --yes --all --set-default default @@ -143,11 +156,20 @@ if [[ ! -d "${switch_dir}" ]]; then "${opam_repo_dir}" fi + if opam repository list --all --short | grep -qx published; then + opam repository set-url --kind=local published \ + "${published_opam_repo_dir}" + opam repository add --yes --all --set-default published + else + opam repository add --yes --all --set-default --kind=local published \ + "${published_opam_repo_dir}" + fi + # We add o1-labs opam repository and make it default selection # (if it's repeated, it's a no-op). opam repository add --yes --all --set-default o1-labs \ https://github.com/o1-labs/opam-repository.git - opam update default o1-labs + opam update default published o1-labs opam switch import -y --assume-depexts --switch . opam.export mkdir -p "${cache_root}" mv _opam "${switch_dir}" diff --git a/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml b/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml index 608890ebe0..159e62c77d 100644 --- a/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml +++ b/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml @@ -285,22 +285,26 @@ module Graphql_raw = struct let graphql_fields = { Input.T.run = (fun () -> + let fields = + List.rev + @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> + g.Accumulator.T.run () ) + in Schema.obj annotations.name ?doc:annotations.doc - ~fields:(fun _ -> - List.rev - @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> - g.Accumulator.T.run () ) ) + ~fields |> Schema.non_null ) } in let nullable_graphql_fields = { Input.T.run = (fun () -> + let fields = + List.rev + @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> + g.Accumulator.T.run () ) + in Schema.obj annotations.name ?doc:annotations.doc - ~fields:(fun _ -> - List.rev - @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> - g.Accumulator.T.run () ) ) ) + ~fields ) } in obj#graphql_fields := graphql_fields ; @@ -653,7 +657,8 @@ let%test_module "Test" = let manual_typ = Schema.( - obj "T1" ~doc ~fields:(fun _ -> + obj "T1" ~doc + ~fields: [ field "fooHello" ~args:Arg.[] ~typ:int @@ -662,7 +667,7 @@ let%test_module "Test" = ~args:Arg.[] ~typ:(non_null (list (non_null string))) ~resolve:(fun _ t -> t.bar) - ] )) + ] ) let derived init = let open Graphql_fields in @@ -748,12 +753,13 @@ let%test_module "Test" = let manual_typ = Schema.( - obj "T2" ?doc:None ~fields:(fun _ -> + obj "T2" ?doc:None + ~fields: [ field "foo" ~args:Arg.[] ~typ:T1.manual_typ ~resolve:(fun _ t -> Or_ignore_test.to_option t.foo) - ] )) + ] ) let derived init = let open Graphql_fields in diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index d73213d6f8..96d2b38d87 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -40,6 +40,24 @@ module Make (Schema : Graphql_intf.Schema) = struct } module Arg = struct + let rec const_value_of_json : Yojson.Basic.t -> Graphql_parser.const_value = + function + | `Null -> + `Null + | `Int i -> + `Int i + | `Float f -> + `Float f + | `String s -> + `String s + | `Bool b -> + `Bool b + | `List xs -> + `List (List.map const_value_of_json xs) + | `Assoc fields -> + `Assoc + (List.map (fun (name, value) -> (name, const_value_of_json value)) fields) + (** wrapper around the [Arg.arg_typ] type *) type ('obj_arg, 'a) arg_typ = { arg_typ : 'obj_arg Schema.Arg.arg_typ; to_json : 'a -> Yojson.Basic.t } @@ -124,7 +142,8 @@ module Make (Schema : Graphql_intf.Schema) = struct Schema.Arg.(graphql_arg :: to_ocaml_graphql_server_args t) | DefaultArg { name; doc; typ; default } :: t -> let graphql_arg = - Schema.Arg.arg' ?doc name ~typ:typ.arg_typ ~default + Schema.Arg.arg' ?doc name ~typ:typ.arg_typ + ~default:(const_value_of_json (typ.to_json default)) in Schema.Arg.(graphql_arg :: to_ocaml_graphql_server_args t) @@ -287,7 +306,8 @@ module Make (Schema : Graphql_intf.Schema) = struct (** The [Propagated] module contains the parts of the Schema we do not modify *) module Propagated = struct - let obj = Schema.obj + let obj ?doc name ~fields = + Schema.fix (fun r -> r.obj ?doc name ~fields) let schema = Schema.schema diff --git a/src/lib/logproc_lib/interpolator.ml b/src/lib/logproc_lib/interpolator.ml index 528fd86441..f3a731561a 100644 --- a/src/lib/logproc_lib/interpolator.ml +++ b/src/lib/logproc_lib/interpolator.ml @@ -68,7 +68,7 @@ let interpolate { mode; max_interpolation_length; pretty_print } msg metadata = let open Result.Let_syntax in let format_json = if pretty_print then Yojson.Safe.pretty_to_string - else Yojson.Safe.to_string ?buf:None ?len:None + else Yojson.Safe.to_string in match mode with | Hidden -> From 58888117f4c32124860491ce9af6d4e4946c83c2 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 08:09:58 +0700 Subject: [PATCH 15/51] Fix GraphQL 0.14 default argument handling --- src/lib/graphql_wrapper/graphql_wrapper.ml | 49 +++++++++++++++++----- src/lib/logproc_lib/interpolator.ml | 2 +- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index 96d2b38d87..7cdc751dad 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -60,7 +60,10 @@ module Make (Schema : Graphql_intf.Schema) = struct (** wrapper around the [Arg.arg_typ] type *) type ('obj_arg, 'a) arg_typ = - { arg_typ : 'obj_arg Schema.Arg.arg_typ; to_json : 'a -> Yojson.Basic.t } + { arg_typ : 'obj_arg Schema.Arg.arg_typ + ; to_json : 'a -> Yojson.Basic.t + ; to_graphql_const : 'obj_arg -> Graphql_parser.const_value + } (** wrapper around the [Arg.arg] type *) type ('obj_arg, 'a) arg = @@ -143,38 +146,50 @@ module Make (Schema : Graphql_intf.Schema) = struct | DefaultArg { name; doc; typ; default } :: t -> let graphql_arg = Schema.Arg.arg' ?doc name ~typ:typ.arg_typ - ~default:(const_value_of_json (typ.to_json default)) + ~default:(typ.to_graphql_const (Some default)) in Schema.Arg.(graphql_arg :: to_ocaml_graphql_server_args t) let int = + let to_json = Json.json_of_option (fun i -> `Int i) in { arg_typ = Schema.Arg.int - ; to_json = Json.json_of_option (fun i -> `Int i) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let scalar ?doc name ~coerce ~to_json = + let to_json = Json.json_of_option to_json in { arg_typ = Schema.Arg.scalar ?doc name ~coerce - ; to_json = Json.json_of_option to_json + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let string = + let to_json = Json.json_of_option (function s -> `String s) in { arg_typ = Schema.Arg.string - ; to_json = Json.json_of_option (function s -> `String s) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let float = + let to_json = Json.json_of_option (function f -> `Float f) in { arg_typ = Schema.Arg.float - ; to_json = Json.json_of_option (function f -> `Float f) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let bool = + let to_json = Json.json_of_option (function f -> `Bool f) in { arg_typ = Schema.Arg.bool - ; to_json = Json.json_of_option (function f -> `Bool f) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let guid = + let to_json = Json.json_of_option (function s -> `String s) in { arg_typ = Schema.Arg.guid - ; to_json = Json.json_of_option (function s -> `String s) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let obj ?doc name ~fields ~coerce ~split = @@ -183,17 +198,29 @@ module Make (Schema : Graphql_intf.Schema) = struct let arg_typ = Schema.Arg.obj name ?doc ~fields:gql_server_fields ~coerce in - { arg_typ; to_json = Json.json_of_option @@ split build_obj_json } + { arg_typ + ; to_json = Json.json_of_option @@ split build_obj_json + ; to_graphql_const = + (fun _ -> + failwith "GraphQL input object default values are not supported" ) + } let non_null (arg_typ : _ arg_typ) = { arg_typ = Schema.Arg.non_null arg_typ.arg_typ ; to_json = (function x -> arg_typ.to_json (Some x)) + ; to_graphql_const = (function x -> arg_typ.to_graphql_const (Some x)) } let list (arg_typ : _ arg_typ) = { arg_typ = Schema.Arg.list arg_typ.arg_typ ; to_json = Json.json_of_option (function l -> `List (List.map arg_typ.to_json l)) + ; to_graphql_const = + (function + | None -> + `Null + | Some l -> + `List (List.map arg_typ.to_graphql_const l) ) } (** wrapper around the enum arg_typ. @@ -215,8 +242,10 @@ module Make (Schema : Graphql_intf.Schema) = struct let ocaml_graphql_server_values = List.map (function { enum_value; _ } -> enum_value) values in + let to_json = Json.json_of_option (fun v -> `String (to_string values v)) in { arg_typ = Schema.Arg.enum ?doc name ~values:ocaml_graphql_server_values - ; to_json = Json.json_of_option (fun v -> `String (to_string values v)) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let arg ?doc name ~typ = Arg { name; typ; doc } diff --git a/src/lib/logproc_lib/interpolator.ml b/src/lib/logproc_lib/interpolator.ml index f3a731561a..8712ca62b7 100644 --- a/src/lib/logproc_lib/interpolator.ml +++ b/src/lib/logproc_lib/interpolator.ml @@ -68,7 +68,7 @@ let interpolate { mode; max_interpolation_length; pretty_print } msg metadata = let open Result.Let_syntax in let format_json = if pretty_print then Yojson.Safe.pretty_to_string - else Yojson.Safe.to_string + else fun ?std json -> Yojson.Safe.to_string ?std json in match mode with | Hidden -> From 5e12d4596f1c667b2be183687ebfcb6567d743c7 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 08:20:52 +0700 Subject: [PATCH 16/51] Use Yojson 2 sequence parser --- src/lib/cli_lib/commands.ml | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/lib/cli_lib/commands.ml b/src/lib/cli_lib/commands.ml index c8a701a6c5..87129c9336 100644 --- a/src/lib/cli_lib/commands.ml +++ b/src/lib/cli_lib/commands.ml @@ -1,8 +1,4 @@ open Signature_lib - -(* Alias ocamlp-streams before it's hidden by open - Core_kernel *) -module Streams = Stream open Core_kernel open Async @@ -149,14 +145,14 @@ let validate_transaction = ( Command.Param.return @@ fun () -> let num_fails = ref 0 in - (* TODO upgrade to yojson 2.0.0 when possible to use seq_from_channel - * instead of the deprecated stream interface *) - let jsons = Yojson.Safe.stream_from_channel In_channel.stdin in + let num_transactions = ref 0 in + let jsons = Yojson.Safe.seq_from_channel In_channel.stdin in let signature_kind = Mina_signature_kind.t_DEPRECATED in ( match Or_error.try_with (fun () -> - Streams.iter + Stdlib.Seq.iter (fun transaction_json -> + incr num_transactions ; match Rosetta_lib.Transaction.to_mina_signed transaction_json with @@ -186,18 +182,15 @@ let validate_transaction = (Yojson.Safe.pretty_to_string (Error_json.error_to_yojson err)) ; Format.printf "Invalid transaction.@." ; Core_kernel.exit 1 ) ; - if !num_fails > 0 then ( + if !num_transactions = 0 then ( + Format.printf "Could not parse any transactions@." ; + exit 1 ) + else if !num_fails > 0 then ( Format.printf "Some transactions failed to verify@." ; exit 1 ) - else - let first = Streams.peek jsons in - match first with - | None -> - Format.printf "Could not parse any transactions@." ; - exit 1 - | _ -> - Format.printf "All transactions were valid@." ; - exit 0 ) + else ( + Format.printf "All transactions were valid@." ; + exit 0 ) ) module Vrf = struct let generate_witness = From 61a69d6d6d0f74cbe145d6c7e862170e7f2b3ea9 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 08:32:44 +0700 Subject: [PATCH 17/51] Update Zeko GraphQL object fields for 0.14 --- .../explorer/explorer_backfill_graphql.ml | 12 +-- src/app/zeko/sequencer/lib/gql.ml | 102 +++++++++--------- .../sequencer/tests/testing_ledger/gql.ml | 98 ++++++++--------- .../graphql_lib/consensus/graphql_scalars.ml | 12 +-- .../abstract_test.ml | 12 +-- .../ocaml_graphql_server_tests/error_test.ml | 8 +- .../ocaml_graphql_server_tests/test_schema.ml | 4 +- 7 files changed, 124 insertions(+), 124 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml index d43fa3eaea..bbff695ce6 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml @@ -10,7 +10,7 @@ let backfill_job_typ : (Explorer_backfill_service.t, Explorer_backfill_service.job_snapshot option) typ = obj "BackfillJob" - ~fields:(fun _ -> + ~fields: [ field "id" ~typ:(non_null string) ~args:[] ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> job.id) ; field "fromHash" ~typ:(non_null string) ~args:[] @@ -37,13 +37,13 @@ let backfill_job_typ : ; field "finishedAt" ~typ:string ~args:[] ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> job.finished_at) - ] ) + ] let progress_typ : (Explorer_backfill_service.t, Explorer_backfill_service.progress_snapshot option) typ = obj "BackfillProgress" - ~fields:(fun _ -> + ~fields: [ field "id" ~typ:(non_null string) ~args:[] ~resolve: (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> @@ -60,13 +60,13 @@ let progress_typ : ~resolve: (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> progress.error) - ] ) + ] let health_typ : (Explorer_backfill_service.t, Explorer_backfill_service.health_snapshot option) typ = obj "Health" - ~fields:(fun _ -> + ~fields: [ field "ok" ~typ:(non_null bool) ~args:[] ~resolve:(fun _ (value : Explorer_backfill_service.health_snapshot) -> value.ok) @@ -76,7 +76,7 @@ let health_typ : ; field "startedAt" ~typ:(non_null string) ~args:[] ~resolve:(fun _ (value : Explorer_backfill_service.health_snapshot) -> value.started_at) - ] ) + ] let query_fields = [ io_field "backfillJob" ~typ:backfill_job_typ diff --git a/src/app/zeko/sequencer/lib/gql.ml b/src/app/zeko/sequencer/lib/gql.ml index 8f9b62a714..df1ae5c367 100644 --- a/src/app/zeko/sequencer/lib/gql.ml +++ b/src/app/zeko/sequencer/lib/gql.ml @@ -58,7 +58,7 @@ module Types = struct let consensus_configuration : ('context, Consensus.Configuration.t option) typ = let open Consensus.Configuration in - obj "ConsensusConfiguration" ~fields:(fun _ -> + obj "ConsensusConfiguration" ~fields: [ field "epochDuration" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.epoch_duration) @@ -71,9 +71,9 @@ module Types = struct ; field "slotDuration" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.slot_duration) - ] ) + ] in - obj "DaemonStatus" ~fields:(fun _ -> + obj "DaemonStatus" ~fields: [ field "chainId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> v.chain_id) @@ -81,13 +81,13 @@ module Types = struct ~typ:(non_null consensus_configuration) ~args:Arg.[] ~resolve:(fun _ v -> v.consensus_configuration) - ] ) + ] end let merkle_path_element : (_, [ `Left of Zkapp_basic.F.t | `Right of Zkapp_basic.F.t ] option) typ = let field_elem = Mina_base_graphql.Graphql_scalars.FieldElem.typ () in - obj "MerklePathElement" ~fields:(fun _ -> + obj "MerklePathElement" ~fields: [ field "left" ~typ:field_elem ~args:Arg.[] ~resolve:(fun _ x -> @@ -96,10 +96,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ x -> match x with `Left _ -> None | `Right h -> Some h ) - ] ) + ] let account_timing : (Zeko_sequencer.t, Account_timing.t option) typ = - obj "AccountTiming" ~fields:(fun _ -> + obj "AccountTiming" ~fields: [ field "initialMinimumBalance" ~typ:balance ~doc:"The initial minimum balance for a time-locked account" ~args:Arg.[] @@ -145,10 +145,10 @@ module Types = struct None | Timed timing_info -> Some timing_info.vesting_increment ) - ] ) + ] let genesis_constants = - obj "GenesisConstants" ~fields:(fun _ -> + obj "GenesisConstants" ~fields: [ field "accountCreationFee" ~typ:(non_null fee) ~doc:"The fee charged to create a new account" ~args:Arg.[] @@ -162,7 +162,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ () -> Time.now () |> Time.to_string_iso8601_basic ~zone:Time.Zone.utc ) - ] ) + ] module AccountObj = struct module AnnotatedBalance = struct @@ -200,7 +200,7 @@ module Types = struct ~doc: "A total balance annotated with the amount that is currently \ unknown with the invariant unknown <= total, as well as the \ - currently liquid and locked balances." ~fields:(fun _ -> + currently liquid and locked balances." ~fields: [ field "total" ~typ:(non_null balance) ~doc:"The amount of MINA owned by the account" ~args:Arg.[] @@ -258,7 +258,7 @@ module Types = struct ~resolve:(fun _ (b : t) -> Option.map b.breadcrumb ~f:(fun crumb -> Transition_frontier.Breadcrumb.state_hash crumb ) ) - ] ) + ] end module Partial_account = struct @@ -398,7 +398,7 @@ module Types = struct ] let set_verification_key_perm = - obj "VerificationKeyPermission" ~fields:(fun _ -> + obj "VerificationKeyPermission" ~fields: [ field "auth" ~typ:(non_null auth_required) ~doc: "Authorization required to set the verification key of the \ @@ -409,10 +409,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ (_, version) -> Mina_numbers.Txn_version.to_string version ) - ] ) + ] let account_permissions = - obj "AccountPermissions" ~fields:(fun _ -> + obj "AccountPermissions" ~fields: [ field "editState" ~typ:(non_null auth_required) ~doc:"Authorization required to edit zkApp state" ~args:Arg.[] @@ -482,11 +482,11 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ permission -> permission.Permissions.Poly.set_timing ) - ] ) + ] let account_vk = obj "AccountVerificationKeyWithHash" ~doc:"Verification key with hash" - ~fields:(fun _ -> + ~fields: [ field "verificationKey" ~doc:"verification key in Base64 format" ~typ: ( non_null @@ -499,12 +499,12 @@ module Types = struct @@ Pickles_graphql.Graphql_scalars.VerificationKeyHash.typ () ) ~args:Arg.[] ~resolve:(fun _ (vk : _ With_hash.t) -> vk.hash) - ] ) + ] let rec account = lazy (obj "Account" ~doc:"An account record according to the daemon" - ~fields:(fun _ -> + ~fields: [ field "publicKey" ~typ:(non_null public_key) ~doc:"The public identity of the account" ~args:Arg.[] @@ -692,7 +692,7 @@ module Types = struct ~typ:(list (non_null merkle_path_element)) ~args:Arg.[] ~resolve:(fun _ _ -> None) - ] ) ) + ] ) let account = Lazy.force account end @@ -705,7 +705,7 @@ module Types = struct [@@warning "-37"] let failure_reasons = - obj "ZkappCommandFailureReason" ~fields:(fun _ -> + obj "ZkappCommandFailureReason" ~fields: [ field "index" ~typ:(Graphql_basic_scalars.Index.typ ()) ~args:[] ~doc:"List index of the account update that failed" ~resolve:(fun _ (index, _) -> Some index) @@ -719,7 +719,7 @@ module Types = struct "Failure reason for the account update or any nested zkapp \ command" ~resolve:(fun _ (_, failures) -> failures) - ] ) + ] end module User_command = struct @@ -745,7 +745,7 @@ module Types = struct option ) typ = interface "UserCommand" ~doc:"Common interface for user commands" - ~fields:(fun _ -> + ~fields: [ abstract_field "id" ~typ:(non_null transaction_id) ~args:[] ; abstract_field "hash" ~typ:(non_null transaction_hash) ~args:[] ; abstract_field "kind" ~typ:(non_null kind) ~args:[] @@ -805,7 +805,7 @@ module Types = struct (Mina_base_graphql.Graphql_scalars.TransactionStatusFailure.typ () ) ~args:[] ~doc:"null is no failure, reason for failure otherwise." - ] ) + ] module With_status = struct type 'a t = { data : 'a; status : Command_status.t } @@ -944,7 +944,7 @@ module Types = struct ] let payment = - obj "UserCommandPayment" ~fields:(fun _ -> user_command_shared_fields) + obj "UserCommandPayment" ~fields:user_command_shared_fields let mk_payment = add_type user_command_interface payment @@ -971,7 +971,7 @@ module Types = struct (Zeko_sequencer.t, Zkapp_command.Stable.Latest.t) typ = Obj.magic x in - obj "ZkappCommandResult" ~fields:(fun _ -> + obj "ZkappCommandResult" ~fields: [ field_no_status "id" ~doc:"A Base64 string representing the zkApp command" ~typ:(non_null transaction_id) ~args:[] @@ -999,13 +999,13 @@ module Types = struct (List.map (Transaction_status.Failure.Collection.to_display failures ) ~f:(fun f -> Some f) ) ) - ] ) + ] end module State_hashes = struct let t : (Zeko_sequencer.t, Zeko_sequencer.State_hashes.t option) typ = let open Snark_params.Tick in - obj "StateHashes" ~fields:(fun _ -> + obj "StateHashes" ~fields: [ field "provedLedgerHash" ~typ:(non_null string) ~doc:"Ledger hash of latest proved state" ~args:Arg.[] @@ -1024,7 +1024,7 @@ module Types = struct ~resolve:(fun _ t -> Field.to_string Zeko_sequencer.State_hashes.(t.committed_ledger_hash) ) - ] ) + ] end module Circuits_config = struct @@ -1050,7 +1050,7 @@ module Types = struct s let t : (Zeko_sequencer.t, t option) typ = - obj "ZekoCircuitsConfig" ~fields:(fun _ -> + obj "ZekoCircuitsConfig" ~fields: [ field "zekoL1" ~typ:(non_null public_key) ~args:Arg.[] ~resolve:(fun _ t -> t.zeko_l1) @@ -1082,34 +1082,34 @@ module Types = struct ; field "outerActionDelay" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ t -> t.outer_action_delay) - ] ) + ] end module Payload = struct let send_payment = - obj "SendPaymentPayload" ~fields:(fun _ -> + obj "SendPaymentPayload" ~fields: [ field "payment" ~typ:(non_null User_command.user_command) ~doc:"Payment that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] ) + ] let send_zkapp = - obj "SendZkappPayload" ~fields:(fun _ -> + obj "SendZkappPayload" ~fields: [ field "zkapp" ~typ:(non_null Zkapp_command.zkapp_command) ~doc:"zkApp transaction that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] ) + ] let proof_key = - obj "ProofKeyPayload" ~fields:(fun _ -> + obj "ProofKeyPayload" ~fields: [ field "key" ~typ:(non_null string) ~doc:"Key for querying the proof" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] ) + ] end module Input = struct @@ -1929,7 +1929,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Block_info in - obj "BlockInfo" ~fields:(fun _ -> + obj "BlockInfo" ~fields: [ field "height" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.height) @@ -1957,7 +1957,7 @@ module Types = struct ; field "distanceFromMaxBlockHeight" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.distance_from_max_block_height) - ] ) + ] end module TransactionInfo = struct @@ -1965,7 +1965,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Transaction_info in - obj "TransactionInfo" ~fields:(fun _ -> + obj "TransactionInfo" ~fields: [ field "status" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> @@ -1991,7 +1991,7 @@ module Types = struct ~typ:(non_null @@ list @@ non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.zkapp_account_update_ids) - ] ) + ] end module StupidActionState = struct @@ -1999,7 +1999,7 @@ module Types = struct (* Who thought it would be better to not use array like normal node, but rather enumerate fields by word *) let t : ('context, t option) typ = - obj "ActionStates" ~fields:(fun _ -> + obj "ActionStates" ~fields: [ field "actionStateOne" ~typ:string ~args:Arg.[] ~resolve:(fun _ action_state -> @@ -2030,14 +2030,14 @@ module Types = struct Option.map (Pickles_types.Vector.nth action_state 4) ~f:Snark_params.Tick.Field.to_string ) - ] ) + ] end module ActionData = struct type t = Archive.Action.t * int * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "ActionData" ~fields:(fun _ -> + obj "ActionData" ~fields: [ field "accountUpdateId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (_, account_update_id, _) -> @@ -2051,14 +2051,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, _, transaction_info) -> transaction_info) - ] ) + ] end module EventData = struct type t = Archive.Event.t * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "EventData" ~fields:(fun _ -> + obj "EventData" ~fields: [ field "data" ~typ:(non_null @@ list @@ non_null string) ~args:Arg.[] @@ -2068,14 +2068,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, transaction_info) -> transaction_info) - ] ) + ] end module ActionOutput = struct type t = Archive.Account_update_actions.t let t : ('context, t option) typ = - obj "ActionOutput" ~fields:(fun _ -> + obj "ActionOutput" ~fields: let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -2093,14 +2093,14 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] ) + ] end module EventOutput = struct type t = Archive.Account_update_events.t let t : ('context, t option) typ = - obj "EventOutput" ~fields:(fun _ -> + obj "EventOutput" ~fields: let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -2110,7 +2110,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] ) + ] end end end diff --git a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml index 598e6e1c5b..8ffbd46c5c 100644 --- a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml +++ b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml @@ -56,17 +56,17 @@ module Types = struct type t = { chain_id : string } let t : ('context, t option) typ = - obj "DaemonStatus" ~fields:(fun _ -> + obj "DaemonStatus" ~fields: [ field "chainId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> v.chain_id) - ] ) + ] end let merkle_path_element : (_, [ `Left of Zkapp_basic.F.t | `Right of Zkapp_basic.F.t ] option) typ = let field_elem = Mina_base_graphql.Graphql_scalars.FieldElem.typ () in - obj "MerklePathElement" ~fields:(fun _ -> + obj "MerklePathElement" ~fields: [ field "left" ~typ:field_elem ~args:Arg.[] ~resolve:(fun _ x -> @@ -75,10 +75,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ x -> match x with `Left _ -> None | `Right h -> Some h ) - ] ) + ] let account_timing : (State.t, Account_timing.t option) typ = - obj "AccountTiming" ~fields:(fun _ -> + obj "AccountTiming" ~fields: [ field "initialMinimumBalance" ~typ:balance ~doc:"The initial minimum balance for a time-locked account" ~args:Arg.[] @@ -124,10 +124,10 @@ module Types = struct None | Timed timing_info -> Some timing_info.vesting_increment ) - ] ) + ] let genesis_constants = - obj "GenesisConstants" ~fields:(fun _ -> + obj "GenesisConstants" ~fields: [ field "accountCreationFee" ~typ:(non_null fee) ~doc:"The fee charged to create a new account" ~args:Arg.[] @@ -142,7 +142,7 @@ module Types = struct ~resolve:(fun _ () -> State.Constants.genesis_timestamp |> Genesis_constants.of_time |> Genesis_constants.genesis_timestamp_to_string ) - ] ) + ] module AccountObj = struct module AnnotatedBalance = struct @@ -180,7 +180,7 @@ module Types = struct ~doc: "A total balance annotated with the amount that is currently \ unknown with the invariant unknown <= total, as well as the \ - currently liquid and locked balances." ~fields:(fun _ -> + currently liquid and locked balances." ~fields: [ field "total" ~typ:(non_null balance) ~doc:"The amount of MINA owned by the account" ~args:Arg.[] @@ -238,7 +238,7 @@ module Types = struct ~resolve:(fun _ (b : t) -> Option.map b.breadcrumb ~f:(fun crumb -> Transition_frontier.Breadcrumb.state_hash crumb ) ) - ] ) + ] end module Partial_account = struct @@ -378,7 +378,7 @@ module Types = struct ] let set_verification_key_perm = - obj "VerificationKeyPermission" ~fields:(fun _ -> + obj "VerificationKeyPermission" ~fields: [ field "auth" ~typ:(non_null auth_required) ~doc: "Authorization required to set the verification key of the \ @@ -389,10 +389,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ (_, version) -> Mina_numbers.Txn_version.to_string version ) - ] ) + ] let account_permissions = - obj "AccountPermissions" ~fields:(fun _ -> + obj "AccountPermissions" ~fields: [ field "editState" ~typ:(non_null auth_required) ~doc:"Authorization required to edit zkApp state" ~args:Arg.[] @@ -462,11 +462,11 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ permission -> permission.Permissions.Poly.set_timing ) - ] ) + ] let account_vk = obj "AccountVerificationKeyWithHash" ~doc:"Verification key with hash" - ~fields:(fun _ -> + ~fields: [ field "verificationKey" ~doc:"verification key in Base64 format" ~typ: ( non_null @@ -479,12 +479,12 @@ module Types = struct @@ Pickles_graphql.Graphql_scalars.VerificationKeyHash.typ () ) ~args:Arg.[] ~resolve:(fun _ (vk : _ With_hash.t) -> vk.hash) - ] ) + ] let rec account = lazy (obj "Account" ~doc:"An account record according to the daemon" - ~fields:(fun _ -> + ~fields: [ field "publicKey" ~typ:(non_null public_key) ~doc:"The public identity of the account" ~args:Arg.[] @@ -672,7 +672,7 @@ module Types = struct ~typ:(list (non_null merkle_path_element)) ~args:Arg.[] ~resolve:(fun _ _ -> None) - ] ) ) + ] ) let account = Lazy.force account end @@ -685,7 +685,7 @@ module Types = struct [@@warning "-37"] let failure_reasons = - obj "ZkappCommandFailureReason" ~fields:(fun _ -> + obj "ZkappCommandFailureReason" ~fields: [ field "index" ~typ:(Graphql_basic_scalars.Index.typ ()) ~args:[] ~doc:"List index of the account update that failed" ~resolve:(fun _ (index, _) -> Some index) @@ -699,7 +699,7 @@ module Types = struct "Failure reason for the account update or any nested zkapp \ command" ~resolve:(fun _ (_, failures) -> failures) - ] ) + ] end module User_command = struct @@ -725,7 +725,7 @@ module Types = struct option ) typ = interface "UserCommand" ~doc:"Common interface for user commands" - ~fields:(fun _ -> + ~fields: [ abstract_field "id" ~typ:(non_null transaction_id) ~args:[] ; abstract_field "hash" ~typ:(non_null transaction_hash) ~args:[] ; abstract_field "kind" ~typ:(non_null kind) ~args:[] @@ -785,7 +785,7 @@ module Types = struct (Mina_base_graphql.Graphql_scalars.TransactionStatusFailure.typ () ) ~args:[] ~doc:"null is no failure, reason for failure otherwise." - ] ) + ] module With_status = struct type 'a t = { data : 'a; status : Command_status.t } @@ -918,7 +918,7 @@ module Types = struct ] let payment = - obj "UserCommandPayment" ~fields:(fun _ -> user_command_shared_fields) + obj "UserCommandPayment" ~fields:user_command_shared_fields let mk_payment = add_type user_command_interface payment @@ -945,7 +945,7 @@ module Types = struct (State.t, Zkapp_command.Stable.Latest.t) typ = Obj.magic x in - obj "ZkappCommandResult" ~fields:(fun _ -> + obj "ZkappCommandResult" ~fields: [ field_no_status "id" ~doc:"A Base64 string representing the zkApp command" ~typ:(non_null transaction_id) ~args:[] @@ -973,20 +973,20 @@ module Types = struct (List.map (Transaction_status.Failure.Collection.to_display failures ) ~f:(fun f -> Some f) ) ) - ] ) + ] end let block : (State.t, Unsigned.uint32 option) typ = let consensus_state = - obj "ConsensusState" ~fields:(fun _ -> + obj "ConsensusState" ~fields: [ field "blockHeight" ~typ:(non_null length) ~doc:"Height of the blockchain at this block" ~args:Arg.[] ~resolve:(fun _ asd -> asd) - ] ) + ] in let protocol_state = - obj "ProtocolState" ~fields:(fun _ -> + obj "ProtocolState" ~fields: [ field "consensusState" ~doc: "State specific to the minaboros Proof of Stake consensus \ @@ -994,32 +994,32 @@ module Types = struct ~typ:(non_null @@ consensus_state) ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] ) + ] in - obj "Block" ~fields:(fun _ -> + obj "Block" ~fields: [ field "protocolState" ~typ:(non_null protocol_state) ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] ) + ] module Payload = struct let send_payment = - obj "SendPaymentPayload" ~fields:(fun _ -> + obj "SendPaymentPayload" ~fields: [ field "payment" ~typ:(non_null User_command.user_command) ~doc:"Payment that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] ) + ] let send_zkapp = - obj "SendZkappPayload" ~fields:(fun _ -> + obj "SendZkappPayload" ~fields: [ field "zkapp" ~typ:(non_null Zkapp_command.zkapp_command) ~doc:"zkApp transaction that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] ) + ] end module Input = struct @@ -1322,7 +1322,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Block_info in - obj "BlockInfo" ~fields:(fun _ -> + obj "BlockInfo" ~fields: [ field "height" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.height) @@ -1350,7 +1350,7 @@ module Types = struct ; field "distanceFromMaxBlockHeight" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.distance_from_max_block_height) - ] ) + ] end module TransactionInfo = struct @@ -1358,7 +1358,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Transaction_info in - obj "TransactionInfo" ~fields:(fun _ -> + obj "TransactionInfo" ~fields: [ field "status" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> @@ -1384,7 +1384,7 @@ module Types = struct ~typ:(non_null @@ list @@ non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.zkapp_account_update_ids) - ] ) + ] end module StupidActionState = struct @@ -1392,7 +1392,7 @@ module Types = struct (* Who thought it would be better to not use array like normal node, but rather enumerate fields by word *) let t : ('context, t option) typ = - obj "ActionStates" ~fields:(fun _ -> + obj "ActionStates" ~fields: [ field "actionStateOne" ~typ:string ~args:Arg.[] ~resolve:(fun _ action_state -> @@ -1423,14 +1423,14 @@ module Types = struct Option.map (Pickles_types.Vector.nth action_state 4) ~f:Snark_params.Tick.Field.to_string ) - ] ) + ] end module ActionData = struct type t = Archive.Action.t * int * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "ActionData" ~fields:(fun _ -> + obj "ActionData" ~fields: [ field "accountUpdateId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (_, account_update_id, _) -> @@ -1444,14 +1444,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, _, transaction_info) -> transaction_info) - ] ) + ] end module EventData = struct type t = Archive.Event.t * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "EventData" ~fields:(fun _ -> + obj "EventData" ~fields: [ field "data" ~typ:(non_null @@ list @@ non_null string) ~args:Arg.[] @@ -1461,14 +1461,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, transaction_info) -> transaction_info) - ] ) + ] end module ActionOutput = struct type t = Archive.Account_update_actions.t let t : ('context, t option) typ = - obj "ActionOutput" ~fields:(fun _ -> + obj "ActionOutput" ~fields: let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -1486,14 +1486,14 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] ) + ] end module EventOutput = struct type t = Archive.Account_update_events.t let t : ('context, t option) typ = - obj "EventOutput" ~fields:(fun _ -> + obj "EventOutput" ~fields: let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -1503,7 +1503,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] ) + ] end end end diff --git a/src/lib/graphql_lib/consensus/graphql_scalars.ml b/src/lib/graphql_lib/consensus/graphql_scalars.ml index 6feddfd882..fc98411714 100644 --- a/src/lib/graphql_lib/consensus/graphql_scalars.ml +++ b/src/lib/graphql_lib/consensus/graphql_scalars.ml @@ -75,7 +75,7 @@ module Epoch_ledger = struct let typ () : ('ctx, Value.t option) Graphql_async.Schema.typ = let open Graphql_async in let open Schema in - obj "epochLedger" ~fields:(fun _ -> + obj "epochLedger" ~fields: [ field "hash" ~typ: (non_null @@ Mina_base_graphql.Graphql_scalars.LedgerHash.typ ()) @@ -85,7 +85,7 @@ module Epoch_ledger = struct ~typ:(non_null @@ Currency_graphql.Graphql_scalars.Amount.typ ()) ~args:Arg.[] ~resolve:(fun _ { Poly.total_currency; _ } -> total_currency) - ] ) + ] end module Epoch_data = struct @@ -94,7 +94,7 @@ module Epoch_data = struct let typ name = let open Graphql_async in let open Schema in - obj name ~fields:(fun _ -> + obj name ~fields: [ field "ledger" ~typ:(non_null @@ Epoch_ledger.typ ()) ~args:Arg.[] @@ -115,7 +115,7 @@ module Epoch_data = struct ~typ:(non_null @@ Mina_numbers_graphql.Graphql_scalars.Length.typ ()) ~args:Arg.[] ~resolve:(fun _ { Poly.epoch_length; _ } -> epoch_length) - ] ) + ] end module Consensus_state = struct @@ -128,7 +128,7 @@ module Consensus_state = struct let open Schema in let length = Mina_numbers_graphql.Graphql_scalars.Length.typ () in let amount = Currency_graphql.Graphql_scalars.Amount.typ () in - obj "ConsensusState" ~fields:(fun _ -> + obj "ConsensusState" ~fields: [ field "blockchainLength" ~typ:(non_null length) ~doc:"Length of the blockchain at this block" ~deprecated:(Deprecated (Some "use blockHeight instead")) @@ -200,7 +200,7 @@ module Consensus_state = struct ; field "coinbaseReceiever" ~typ:(non_null public_key) ~args:Arg.[] ~resolve:(const coinbase_receiver) - ] ) + ] end let%test_module "Roundtrip tests" = diff --git a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml index a6e9fe4fd6..0dda856da3 100644 --- a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml +++ b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml @@ -11,25 +11,25 @@ let fido : dog = { name = "Fido"; puppies = 2 } let cat = Schema.( - obj "Cat" ~fields:(fun _ -> + obj "Cat" ~fields: [ field "name" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (cat : cat) -> cat.name) ; field "kittens" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ (cat : cat) -> cat.kittens) - ] )) + ] ) let dog = Schema.( - obj "Dog" ~fields:(fun _ -> + obj "Dog" ~fields: [ field "name" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (dog : dog) -> dog.name) ; field "puppies" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ (dog : dog) -> dog.puppies) - ] )) + ] ) let pet : (unit, [ `pet ]) Schema.abstract_typ = Schema.union "Pet" @@ -39,8 +39,8 @@ let dog_as_pet = Schema.add_type pet dog let named : (unit, [ `named ]) Schema.abstract_typ = Schema.( - interface "Named" ~fields:(fun _ -> - [ abstract_field "name" ~typ:(non_null string) ~args:Arg.[] ] )) + interface "Named" ~fields: + [ abstract_field "name" ~typ:(non_null string) ~args:Arg.[] ] ) let cat_as_named = Schema.add_type named cat diff --git a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml index a92647e1ee..ba4a5b4d8a 100644 --- a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml +++ b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml @@ -49,11 +49,11 @@ let non_nullable_error_test () = let nested_nullable_error_test () = let obj_with_non_nullable_field = Schema.( - obj "obj" ~fields:(fun _ -> + obj "obj" ~fields: [ io_field "non_nullable" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ () -> Error "boom") - ] )) + ] ) in let schema = Schema.( @@ -79,12 +79,12 @@ let nested_nullable_error_test () = let error_in_list_test () = let foo = Schema.( - obj "Foo" ~fields:(fun _ -> + obj "Foo" ~fields: [ io_field "id" ~typ:int ~args:Arg.[] ~resolve:(fun _ (id, should_fail) -> if should_fail then Error "boom" else Ok (Some id) ) - ] )) + ] ) in let schema = Schema.( diff --git a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml index 546e70fc36..0ca264ec6f 100644 --- a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml +++ b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml @@ -20,7 +20,7 @@ let input_role = Schema.Arg.(enum "role" ~values:role_values) let user = Schema.( - obj "user" ~fields:(fun _ -> + obj "user" ~fields: [ field "id" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun { ctx = (); _ } p -> p.id) @@ -30,7 +30,7 @@ let user = ; field "role" ~typ:(non_null role) ~args:Arg.[] ~resolve:(fun _ p -> p.role) - ] )) + ] ) (* Not available in List before OCaml 4.07 *) let list_to_seq n l = From 53612defbb2ec36aed998252343d378ffa2dfd80 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 08:44:54 +0700 Subject: [PATCH 18/51] Add GraphQL const conversion for zkApp args --- src/app/zeko/sequencer/lib/gql.ml | 11 ++++++----- src/app/zeko/sequencer/tests/testing_ledger/gql.ml | 11 ++++++----- src/lib/mina_graphql/types.ml | 11 ++++++----- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/app/zeko/sequencer/lib/gql.ml b/src/app/zeko/sequencer/lib/gql.ml index df1ae5c367..9fd9e6c6da 100644 --- a/src/app/zeko/sequencer/lib/gql.ml +++ b/src/app/zeko/sequencer/lib/gql.ml @@ -1342,12 +1342,13 @@ module Types = struct Obj.magic x in let arg_typ = + let to_json x = + Yojson.Safe.to_basic + (Mina_base.Zkapp_command.zkapp_command_to_json x) + in { arg_typ = Mina_base.Zkapp_command.arg_typ () |> conv - ; to_json = - (function - | x -> - Yojson.Safe.to_basic - (Mina_base.Zkapp_command.zkapp_command_to_json x) ) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } in obj "SendZkappInput" ~coerce:Fn.id diff --git a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml index 8ffbd46c5c..72763377af 100644 --- a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml +++ b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml @@ -1252,12 +1252,13 @@ module Types = struct Obj.magic x in let arg_typ = + let to_json x = + Yojson.Safe.to_basic + (Mina_base.Zkapp_command.zkapp_command_to_json x) + in { arg_typ = Mina_base.Zkapp_command.arg_typ () |> conv - ; to_json = - (function - | x -> - Yojson.Safe.to_basic - (Mina_base.Zkapp_command.zkapp_command_to_json x) ) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } in obj "SendZkappInput" ~coerce:Fn.id diff --git a/src/lib/mina_graphql/types.ml b/src/lib/mina_graphql/types.ml index ac021894a7..e252d52c77 100644 --- a/src/lib/mina_graphql/types.ml +++ b/src/lib/mina_graphql/types.ml @@ -3029,12 +3029,13 @@ module Input = struct Obj.magic x in let arg_typ = + let to_json x = + Yojson.Safe.to_basic + (Mina_base.Zkapp_command.zkapp_command_to_json x) + in { arg_typ = Mina_base.Zkapp_command.arg_typ () |> conv - ; to_json = - (function - | x -> - Yojson.Safe.to_basic - (Mina_base.Zkapp_command.zkapp_command_to_json x) ) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } in obj "SendZkappInput" ~coerce:Fn.id From 7e83197292851b480f2b2686651977a75cc53659 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 08:59:04 +0700 Subject: [PATCH 19/51] Render Rosetta GraphQL input defaults for 0.14 --- src/lib/mina_graphql/types.ml | 50 ++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/lib/mina_graphql/types.ml b/src/lib/mina_graphql/types.ml index e252d52c77..ff6858ccff 100644 --- a/src/lib/mina_graphql/types.ml +++ b/src/lib/mina_graphql/types.ml @@ -3080,13 +3080,61 @@ module Input = struct module RosettaTransaction = struct type input = Yojson.Basic.t + let to_json (command : Signed_command.t) = + let public_key pk = `Pk (Public_key.Compressed.to_base58_check pk) in + let token_id token = `Token_id (Token_id.to_string token) in + let valid_until = + let slot = Signed_command.valid_until command in + if Mina_numbers.Global_slot_since_genesis.equal slot + Mina_numbers.Global_slot_since_genesis.max_value + then None + else Some (Mina_numbers.Global_slot_since_genesis.to_uint32 slot) + in + let command_kind = + match Signed_command.payload command |> Signed_command_payload.body with + | Payment _ -> + `Payment + | Stake_delegation _ -> + `Delegation + in + let command' : Rosetta_lib.User_command_info.Partial.t = + { kind = command_kind + ; fee_payer = public_key (Signed_command.fee_payer_pk command) + ; source = public_key (Signed_command.fee_payer_pk command) + ; receiver = public_key (Signed_command.receiver_pk command) + ; fee_token = token_id (Signed_command.fee_token command) + ; token = token_id (Signed_command.token command) + ; fee = Currency.Fee.to_uint64 (Signed_command.fee command) + ; amount = + Option.map (Signed_command.amount command) + ~f:Currency.Amount.to_uint64 + ; valid_until + ; memo = + (let memo = Signed_command.memo command in + if Signed_command_memo.equal memo Signed_command_memo.empty then + None + else Some (Signed_command_memo.to_string_hum memo) ) + } + in + let transaction : Rosetta_lib.Transaction.Signed.t = + { command = command' + ; nonce = Mina_numbers.Account_nonce.to_uint32 (Signed_command.nonce command) + ; signature = Signed_command.signature command + } + in + Rosetta_lib.Transaction.Signed.render transaction + |> Result.map ~f:Rosetta_lib.Transaction.Signed.Rendered.to_yojson + |> Result.map_error ~f:(fun err -> + Error.of_string (Rosetta_lib.Errors.show err) ) + |> Or_error.ok_exn |> Yojson.Safe.to_basic + let arg_typ = Schema.Arg.scalar "RosettaTransaction" ~doc:"A transaction encoded in the Rosetta format" ~coerce:(fun graphql_json -> Rosetta_lib.Transaction.to_mina_signed (Utils.to_yojson graphql_json) |> Result.map_error ~f:Error.to_string_hum ) - ~to_json:(Fn.id : input -> input) + ~to_json end module AddAccountInput = struct From 3c628ea3b26400d274f345a38cf21631e5884370 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 09:13:19 +0700 Subject: [PATCH 20/51] Keep Rosetta query variables JSON under GraphQL 0.14 --- src/app/zeko/sequencer/lib/gql.ml | 6 ++++-- src/app/zeko/sequencer/tests/testing_ledger/gql.ml | 6 ++++-- src/lib/graphql_wrapper/graphql_wrapper.ml | 9 +++++++-- src/lib/mina_graphql/types.ml | 7 ++++--- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/app/zeko/sequencer/lib/gql.ml b/src/app/zeko/sequencer/lib/gql.ml index 9fd9e6c6da..ad7ceafc85 100644 --- a/src/app/zeko/sequencer/lib/gql.ml +++ b/src/app/zeko/sequencer/lib/gql.ml @@ -2077,6 +2077,7 @@ module Types = struct let t : ('context, t option) typ = obj "ActionOutput" ~fields: + ( let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -2094,7 +2095,7 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] + ] ) end module EventOutput = struct @@ -2102,6 +2103,7 @@ module Types = struct let t : ('context, t option) typ = obj "EventOutput" ~fields: + ( let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -2111,7 +2113,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] + ] ) end end end diff --git a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml index 72763377af..5572e3504a 100644 --- a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml +++ b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml @@ -1470,6 +1470,7 @@ module Types = struct let t : ('context, t option) typ = obj "ActionOutput" ~fields: + ( let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -1487,7 +1488,7 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] + ] ) end module EventOutput = struct @@ -1495,6 +1496,7 @@ module Types = struct let t : ('context, t option) typ = obj "EventOutput" ~fields: + ( let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -1504,7 +1506,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] + ] ) end end end diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index 7cdc751dad..ecfd4630e6 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -157,13 +157,18 @@ module Make (Schema : Graphql_intf.Schema) = struct ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } - let scalar ?doc name ~coerce ~to_json = + let scalar_with_default_to_json ?doc name ~coerce ~to_json ~to_default_json = let to_json = Json.json_of_option to_json in + let to_default_json = Json.json_of_option to_default_json in { arg_typ = Schema.Arg.scalar ?doc name ~coerce ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_graphql_const = (fun x -> const_value_of_json (to_default_json x)) } + let scalar ?doc name ~coerce ~to_json = + scalar_with_default_to_json ?doc name ~coerce ~to_json + ~to_default_json:to_json + let string = let to_json = Json.json_of_option (function s -> `String s) in { arg_typ = Schema.Arg.string diff --git a/src/lib/mina_graphql/types.ml b/src/lib/mina_graphql/types.ml index ff6858ccff..c058e1db34 100644 --- a/src/lib/mina_graphql/types.ml +++ b/src/lib/mina_graphql/types.ml @@ -3080,7 +3080,7 @@ module Input = struct module RosettaTransaction = struct type input = Yojson.Basic.t - let to_json (command : Signed_command.t) = + let to_default_json (command : Signed_command.t) = let public_key pk = `Pk (Public_key.Compressed.to_base58_check pk) in let token_id token = `Token_id (Token_id.to_string token) in let valid_until = @@ -3129,12 +3129,13 @@ module Input = struct |> Or_error.ok_exn |> Yojson.Safe.to_basic let arg_typ = - Schema.Arg.scalar "RosettaTransaction" + Schema.Arg.scalar_with_default_to_json "RosettaTransaction" ~doc:"A transaction encoded in the Rosetta format" ~coerce:(fun graphql_json -> Rosetta_lib.Transaction.to_mina_signed (Utils.to_yojson graphql_json) |> Result.map_error ~f:Error.to_string_hum ) - ~to_json + ~to_json:(Fn.id : input -> input) + ~to_default_json end module AddAccountInput = struct From 77255f723b0e760b00b1f1d11295e48690abfc55 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 09:25:19 +0700 Subject: [PATCH 21/51] Declare init websocket dependency --- src/app/cli/src/init/dune | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/cli/src/init/dune b/src/app/cli/src/init/dune index af5c8d92ca..dc6706526e 100644 --- a/src/app/cli/src/init/dune +++ b/src/app/cli/src/init/dune @@ -28,6 +28,7 @@ base async cohttp + websocket graphql_parser async.async_command async_rpc_kernel From c5c19d1089430ef5222044bfee7e6b8f6f879c6e Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 09:39:11 +0700 Subject: [PATCH 22/51] Use GraphQL websocket module in init server --- src/app/cli/src/init/dune | 1 - src/app/cli/src/init/graphql_internal.ml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/cli/src/init/dune b/src/app/cli/src/init/dune index dc6706526e..af5c8d92ca 100644 --- a/src/app/cli/src/init/dune +++ b/src/app/cli/src/init/dune @@ -28,7 +28,6 @@ base async cohttp - websocket graphql_parser async.async_command async_rpc_kernel diff --git a/src/app/cli/src/init/graphql_internal.ml b/src/app/cli/src/init/graphql_internal.ml index dc0b08a8f4..46f2fe4479 100644 --- a/src/app/cli/src/init/graphql_internal.ml +++ b/src/app/cli/src/init/graphql_internal.ml @@ -138,7 +138,7 @@ module Make (Io : Cohttp.S.IO with type 'a t = 'a Schema.Io.t) (Body : HttpBody with type +'a io := 'a Schema.Io.t) = struct - module Ws = Websocket.Connection.Make (Io) + module Ws = Graphql_websocket.Connection.Make (Io) module Websocket_transport = Websocket_handler.Make (Schema.Io) (Ws) let ( >>= ) = Io.( >>= ) From ab56e82f6e2043d98cb285fc6fd69a1b44f278f4 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 09:54:50 +0700 Subject: [PATCH 23/51] Keep Zeko output fields recursive --- src/app/zeko/sequencer/lib/gql.ml | 102 +++++++++--------- .../sequencer/tests/testing_ledger/gql.ml | 98 ++++++++--------- 2 files changed, 100 insertions(+), 100 deletions(-) diff --git a/src/app/zeko/sequencer/lib/gql.ml b/src/app/zeko/sequencer/lib/gql.ml index ad7ceafc85..c8109df99e 100644 --- a/src/app/zeko/sequencer/lib/gql.ml +++ b/src/app/zeko/sequencer/lib/gql.ml @@ -58,7 +58,7 @@ module Types = struct let consensus_configuration : ('context, Consensus.Configuration.t option) typ = let open Consensus.Configuration in - obj "ConsensusConfiguration" ~fields: + obj "ConsensusConfiguration" ~fields:(fun _ -> [ field "epochDuration" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.epoch_duration) @@ -71,9 +71,9 @@ module Types = struct ; field "slotDuration" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.slot_duration) - ] + ]) in - obj "DaemonStatus" ~fields: + obj "DaemonStatus" ~fields:(fun _ -> [ field "chainId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> v.chain_id) @@ -81,13 +81,13 @@ module Types = struct ~typ:(non_null consensus_configuration) ~args:Arg.[] ~resolve:(fun _ v -> v.consensus_configuration) - ] + ]) end let merkle_path_element : (_, [ `Left of Zkapp_basic.F.t | `Right of Zkapp_basic.F.t ] option) typ = let field_elem = Mina_base_graphql.Graphql_scalars.FieldElem.typ () in - obj "MerklePathElement" ~fields: + obj "MerklePathElement" ~fields:(fun _ -> [ field "left" ~typ:field_elem ~args:Arg.[] ~resolve:(fun _ x -> @@ -96,10 +96,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ x -> match x with `Left _ -> None | `Right h -> Some h ) - ] + ]) let account_timing : (Zeko_sequencer.t, Account_timing.t option) typ = - obj "AccountTiming" ~fields: + obj "AccountTiming" ~fields:(fun _ -> [ field "initialMinimumBalance" ~typ:balance ~doc:"The initial minimum balance for a time-locked account" ~args:Arg.[] @@ -145,10 +145,10 @@ module Types = struct None | Timed timing_info -> Some timing_info.vesting_increment ) - ] + ]) let genesis_constants = - obj "GenesisConstants" ~fields: + obj "GenesisConstants" ~fields:(fun _ -> [ field "accountCreationFee" ~typ:(non_null fee) ~doc:"The fee charged to create a new account" ~args:Arg.[] @@ -162,7 +162,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ () -> Time.now () |> Time.to_string_iso8601_basic ~zone:Time.Zone.utc ) - ] + ]) module AccountObj = struct module AnnotatedBalance = struct @@ -200,7 +200,7 @@ module Types = struct ~doc: "A total balance annotated with the amount that is currently \ unknown with the invariant unknown <= total, as well as the \ - currently liquid and locked balances." ~fields: + currently liquid and locked balances." ~fields:(fun _ -> [ field "total" ~typ:(non_null balance) ~doc:"The amount of MINA owned by the account" ~args:Arg.[] @@ -258,7 +258,7 @@ module Types = struct ~resolve:(fun _ (b : t) -> Option.map b.breadcrumb ~f:(fun crumb -> Transition_frontier.Breadcrumb.state_hash crumb ) ) - ] + ]) end module Partial_account = struct @@ -398,7 +398,7 @@ module Types = struct ] let set_verification_key_perm = - obj "VerificationKeyPermission" ~fields: + obj "VerificationKeyPermission" ~fields:(fun _ -> [ field "auth" ~typ:(non_null auth_required) ~doc: "Authorization required to set the verification key of the \ @@ -409,10 +409,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ (_, version) -> Mina_numbers.Txn_version.to_string version ) - ] + ]) let account_permissions = - obj "AccountPermissions" ~fields: + obj "AccountPermissions" ~fields:(fun _ -> [ field "editState" ~typ:(non_null auth_required) ~doc:"Authorization required to edit zkApp state" ~args:Arg.[] @@ -482,11 +482,11 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ permission -> permission.Permissions.Poly.set_timing ) - ] + ]) let account_vk = obj "AccountVerificationKeyWithHash" ~doc:"Verification key with hash" - ~fields: + ~fields:(fun _ -> [ field "verificationKey" ~doc:"verification key in Base64 format" ~typ: ( non_null @@ -499,12 +499,12 @@ module Types = struct @@ Pickles_graphql.Graphql_scalars.VerificationKeyHash.typ () ) ~args:Arg.[] ~resolve:(fun _ (vk : _ With_hash.t) -> vk.hash) - ] + ]) let rec account = lazy (obj "Account" ~doc:"An account record according to the daemon" - ~fields: + ~fields:(fun _ -> [ field "publicKey" ~typ:(non_null public_key) ~doc:"The public identity of the account" ~args:Arg.[] @@ -692,7 +692,7 @@ module Types = struct ~typ:(list (non_null merkle_path_element)) ~args:Arg.[] ~resolve:(fun _ _ -> None) - ] ) + ] )) let account = Lazy.force account end @@ -705,7 +705,7 @@ module Types = struct [@@warning "-37"] let failure_reasons = - obj "ZkappCommandFailureReason" ~fields: + obj "ZkappCommandFailureReason" ~fields:(fun _ -> [ field "index" ~typ:(Graphql_basic_scalars.Index.typ ()) ~args:[] ~doc:"List index of the account update that failed" ~resolve:(fun _ (index, _) -> Some index) @@ -719,7 +719,7 @@ module Types = struct "Failure reason for the account update or any nested zkapp \ command" ~resolve:(fun _ (_, failures) -> failures) - ] + ]) end module User_command = struct @@ -745,7 +745,7 @@ module Types = struct option ) typ = interface "UserCommand" ~doc:"Common interface for user commands" - ~fields: + ~fields:(fun _ -> [ abstract_field "id" ~typ:(non_null transaction_id) ~args:[] ; abstract_field "hash" ~typ:(non_null transaction_hash) ~args:[] ; abstract_field "kind" ~typ:(non_null kind) ~args:[] @@ -805,7 +805,7 @@ module Types = struct (Mina_base_graphql.Graphql_scalars.TransactionStatusFailure.typ () ) ~args:[] ~doc:"null is no failure, reason for failure otherwise." - ] + ]) module With_status = struct type 'a t = { data : 'a; status : Command_status.t } @@ -944,7 +944,7 @@ module Types = struct ] let payment = - obj "UserCommandPayment" ~fields:user_command_shared_fields + obj "UserCommandPayment" ~fields:(fun _ -> user_command_shared_fields) let mk_payment = add_type user_command_interface payment @@ -971,7 +971,7 @@ module Types = struct (Zeko_sequencer.t, Zkapp_command.Stable.Latest.t) typ = Obj.magic x in - obj "ZkappCommandResult" ~fields: + obj "ZkappCommandResult" ~fields:(fun _ -> [ field_no_status "id" ~doc:"A Base64 string representing the zkApp command" ~typ:(non_null transaction_id) ~args:[] @@ -999,13 +999,13 @@ module Types = struct (List.map (Transaction_status.Failure.Collection.to_display failures ) ~f:(fun f -> Some f) ) ) - ] + ]) end module State_hashes = struct let t : (Zeko_sequencer.t, Zeko_sequencer.State_hashes.t option) typ = let open Snark_params.Tick in - obj "StateHashes" ~fields: + obj "StateHashes" ~fields:(fun _ -> [ field "provedLedgerHash" ~typ:(non_null string) ~doc:"Ledger hash of latest proved state" ~args:Arg.[] @@ -1024,7 +1024,7 @@ module Types = struct ~resolve:(fun _ t -> Field.to_string Zeko_sequencer.State_hashes.(t.committed_ledger_hash) ) - ] + ]) end module Circuits_config = struct @@ -1050,7 +1050,7 @@ module Types = struct s let t : (Zeko_sequencer.t, t option) typ = - obj "ZekoCircuitsConfig" ~fields: + obj "ZekoCircuitsConfig" ~fields:(fun _ -> [ field "zekoL1" ~typ:(non_null public_key) ~args:Arg.[] ~resolve:(fun _ t -> t.zeko_l1) @@ -1082,34 +1082,34 @@ module Types = struct ; field "outerActionDelay" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ t -> t.outer_action_delay) - ] + ]) end module Payload = struct let send_payment = - obj "SendPaymentPayload" ~fields: + obj "SendPaymentPayload" ~fields:(fun _ -> [ field "payment" ~typ:(non_null User_command.user_command) ~doc:"Payment that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] + ]) let send_zkapp = - obj "SendZkappPayload" ~fields: + obj "SendZkappPayload" ~fields:(fun _ -> [ field "zkapp" ~typ:(non_null Zkapp_command.zkapp_command) ~doc:"zkApp transaction that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] + ]) let proof_key = - obj "ProofKeyPayload" ~fields: + obj "ProofKeyPayload" ~fields:(fun _ -> [ field "key" ~typ:(non_null string) ~doc:"Key for querying the proof" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] + ]) end module Input = struct @@ -1930,7 +1930,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Block_info in - obj "BlockInfo" ~fields: + obj "BlockInfo" ~fields:(fun _ -> [ field "height" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.height) @@ -1958,7 +1958,7 @@ module Types = struct ; field "distanceFromMaxBlockHeight" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.distance_from_max_block_height) - ] + ]) end module TransactionInfo = struct @@ -1966,7 +1966,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Transaction_info in - obj "TransactionInfo" ~fields: + obj "TransactionInfo" ~fields:(fun _ -> [ field "status" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> @@ -1992,7 +1992,7 @@ module Types = struct ~typ:(non_null @@ list @@ non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.zkapp_account_update_ids) - ] + ]) end module StupidActionState = struct @@ -2000,7 +2000,7 @@ module Types = struct (* Who thought it would be better to not use array like normal node, but rather enumerate fields by word *) let t : ('context, t option) typ = - obj "ActionStates" ~fields: + obj "ActionStates" ~fields:(fun _ -> [ field "actionStateOne" ~typ:string ~args:Arg.[] ~resolve:(fun _ action_state -> @@ -2031,14 +2031,14 @@ module Types = struct Option.map (Pickles_types.Vector.nth action_state 4) ~f:Snark_params.Tick.Field.to_string ) - ] + ]) end module ActionData = struct type t = Archive.Action.t * int * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "ActionData" ~fields: + obj "ActionData" ~fields:(fun _ -> [ field "accountUpdateId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (_, account_update_id, _) -> @@ -2052,14 +2052,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, _, transaction_info) -> transaction_info) - ] + ]) end module EventData = struct type t = Archive.Event.t * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "EventData" ~fields: + obj "EventData" ~fields:(fun _ -> [ field "data" ~typ:(non_null @@ list @@ non_null string) ~args:Arg.[] @@ -2069,14 +2069,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, transaction_info) -> transaction_info) - ] + ]) end module ActionOutput = struct type t = Archive.Account_update_actions.t let t : ('context, t option) typ = - obj "ActionOutput" ~fields: + obj "ActionOutput" ~fields:(fun _ -> ( let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t @@ -2095,14 +2095,14 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] ) + ] )) end module EventOutput = struct type t = Archive.Account_update_events.t let t : ('context, t option) typ = - obj "EventOutput" ~fields: + obj "EventOutput" ~fields:(fun _ -> ( let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t @@ -2113,7 +2113,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] ) + ] )) end end end diff --git a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml index 5572e3504a..b55acf3112 100644 --- a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml +++ b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml @@ -56,17 +56,17 @@ module Types = struct type t = { chain_id : string } let t : ('context, t option) typ = - obj "DaemonStatus" ~fields: + obj "DaemonStatus" ~fields:(fun _ -> [ field "chainId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> v.chain_id) - ] + ]) end let merkle_path_element : (_, [ `Left of Zkapp_basic.F.t | `Right of Zkapp_basic.F.t ] option) typ = let field_elem = Mina_base_graphql.Graphql_scalars.FieldElem.typ () in - obj "MerklePathElement" ~fields: + obj "MerklePathElement" ~fields:(fun _ -> [ field "left" ~typ:field_elem ~args:Arg.[] ~resolve:(fun _ x -> @@ -75,10 +75,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ x -> match x with `Left _ -> None | `Right h -> Some h ) - ] + ]) let account_timing : (State.t, Account_timing.t option) typ = - obj "AccountTiming" ~fields: + obj "AccountTiming" ~fields:(fun _ -> [ field "initialMinimumBalance" ~typ:balance ~doc:"The initial minimum balance for a time-locked account" ~args:Arg.[] @@ -124,10 +124,10 @@ module Types = struct None | Timed timing_info -> Some timing_info.vesting_increment ) - ] + ]) let genesis_constants = - obj "GenesisConstants" ~fields: + obj "GenesisConstants" ~fields:(fun _ -> [ field "accountCreationFee" ~typ:(non_null fee) ~doc:"The fee charged to create a new account" ~args:Arg.[] @@ -142,7 +142,7 @@ module Types = struct ~resolve:(fun _ () -> State.Constants.genesis_timestamp |> Genesis_constants.of_time |> Genesis_constants.genesis_timestamp_to_string ) - ] + ]) module AccountObj = struct module AnnotatedBalance = struct @@ -180,7 +180,7 @@ module Types = struct ~doc: "A total balance annotated with the amount that is currently \ unknown with the invariant unknown <= total, as well as the \ - currently liquid and locked balances." ~fields: + currently liquid and locked balances." ~fields:(fun _ -> [ field "total" ~typ:(non_null balance) ~doc:"The amount of MINA owned by the account" ~args:Arg.[] @@ -238,7 +238,7 @@ module Types = struct ~resolve:(fun _ (b : t) -> Option.map b.breadcrumb ~f:(fun crumb -> Transition_frontier.Breadcrumb.state_hash crumb ) ) - ] + ]) end module Partial_account = struct @@ -378,7 +378,7 @@ module Types = struct ] let set_verification_key_perm = - obj "VerificationKeyPermission" ~fields: + obj "VerificationKeyPermission" ~fields:(fun _ -> [ field "auth" ~typ:(non_null auth_required) ~doc: "Authorization required to set the verification key of the \ @@ -389,10 +389,10 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ (_, version) -> Mina_numbers.Txn_version.to_string version ) - ] + ]) let account_permissions = - obj "AccountPermissions" ~fields: + obj "AccountPermissions" ~fields:(fun _ -> [ field "editState" ~typ:(non_null auth_required) ~doc:"Authorization required to edit zkApp state" ~args:Arg.[] @@ -462,11 +462,11 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ permission -> permission.Permissions.Poly.set_timing ) - ] + ]) let account_vk = obj "AccountVerificationKeyWithHash" ~doc:"Verification key with hash" - ~fields: + ~fields:(fun _ -> [ field "verificationKey" ~doc:"verification key in Base64 format" ~typ: ( non_null @@ -479,12 +479,12 @@ module Types = struct @@ Pickles_graphql.Graphql_scalars.VerificationKeyHash.typ () ) ~args:Arg.[] ~resolve:(fun _ (vk : _ With_hash.t) -> vk.hash) - ] + ]) let rec account = lazy (obj "Account" ~doc:"An account record according to the daemon" - ~fields: + ~fields:(fun _ -> [ field "publicKey" ~typ:(non_null public_key) ~doc:"The public identity of the account" ~args:Arg.[] @@ -672,7 +672,7 @@ module Types = struct ~typ:(list (non_null merkle_path_element)) ~args:Arg.[] ~resolve:(fun _ _ -> None) - ] ) + ] )) let account = Lazy.force account end @@ -685,7 +685,7 @@ module Types = struct [@@warning "-37"] let failure_reasons = - obj "ZkappCommandFailureReason" ~fields: + obj "ZkappCommandFailureReason" ~fields:(fun _ -> [ field "index" ~typ:(Graphql_basic_scalars.Index.typ ()) ~args:[] ~doc:"List index of the account update that failed" ~resolve:(fun _ (index, _) -> Some index) @@ -699,7 +699,7 @@ module Types = struct "Failure reason for the account update or any nested zkapp \ command" ~resolve:(fun _ (_, failures) -> failures) - ] + ]) end module User_command = struct @@ -725,7 +725,7 @@ module Types = struct option ) typ = interface "UserCommand" ~doc:"Common interface for user commands" - ~fields: + ~fields:(fun _ -> [ abstract_field "id" ~typ:(non_null transaction_id) ~args:[] ; abstract_field "hash" ~typ:(non_null transaction_hash) ~args:[] ; abstract_field "kind" ~typ:(non_null kind) ~args:[] @@ -785,7 +785,7 @@ module Types = struct (Mina_base_graphql.Graphql_scalars.TransactionStatusFailure.typ () ) ~args:[] ~doc:"null is no failure, reason for failure otherwise." - ] + ]) module With_status = struct type 'a t = { data : 'a; status : Command_status.t } @@ -918,7 +918,7 @@ module Types = struct ] let payment = - obj "UserCommandPayment" ~fields:user_command_shared_fields + obj "UserCommandPayment" ~fields:(fun _ -> user_command_shared_fields) let mk_payment = add_type user_command_interface payment @@ -945,7 +945,7 @@ module Types = struct (State.t, Zkapp_command.Stable.Latest.t) typ = Obj.magic x in - obj "ZkappCommandResult" ~fields: + obj "ZkappCommandResult" ~fields:(fun _ -> [ field_no_status "id" ~doc:"A Base64 string representing the zkApp command" ~typ:(non_null transaction_id) ~args:[] @@ -973,20 +973,20 @@ module Types = struct (List.map (Transaction_status.Failure.Collection.to_display failures ) ~f:(fun f -> Some f) ) ) - ] + ]) end let block : (State.t, Unsigned.uint32 option) typ = let consensus_state = - obj "ConsensusState" ~fields: + obj "ConsensusState" ~fields:(fun _ -> [ field "blockHeight" ~typ:(non_null length) ~doc:"Height of the blockchain at this block" ~args:Arg.[] ~resolve:(fun _ asd -> asd) - ] + ]) in let protocol_state = - obj "ProtocolState" ~fields: + obj "ProtocolState" ~fields:(fun _ -> [ field "consensusState" ~doc: "State specific to the minaboros Proof of Stake consensus \ @@ -994,32 +994,32 @@ module Types = struct ~typ:(non_null @@ consensus_state) ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] + ]) in - obj "Block" ~fields: + obj "Block" ~fields:(fun _ -> [ field "protocolState" ~typ:(non_null protocol_state) ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] + ]) module Payload = struct let send_payment = - obj "SendPaymentPayload" ~fields: + obj "SendPaymentPayload" ~fields:(fun _ -> [ field "payment" ~typ:(non_null User_command.user_command) ~doc:"Payment that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] + ]) let send_zkapp = - obj "SendZkappPayload" ~fields: + obj "SendZkappPayload" ~fields:(fun _ -> [ field "zkapp" ~typ:(non_null Zkapp_command.zkapp_command) ~doc:"zkApp transaction that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ] + ]) end module Input = struct @@ -1323,7 +1323,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Block_info in - obj "BlockInfo" ~fields: + obj "BlockInfo" ~fields:(fun _ -> [ field "height" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.height) @@ -1351,7 +1351,7 @@ module Types = struct ; field "distanceFromMaxBlockHeight" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.distance_from_max_block_height) - ] + ]) end module TransactionInfo = struct @@ -1359,7 +1359,7 @@ module Types = struct let t : ('context, t option) typ = let open Archive.Transaction_info in - obj "TransactionInfo" ~fields: + obj "TransactionInfo" ~fields:(fun _ -> [ field "status" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> @@ -1385,7 +1385,7 @@ module Types = struct ~typ:(non_null @@ list @@ non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.zkapp_account_update_ids) - ] + ]) end module StupidActionState = struct @@ -1393,7 +1393,7 @@ module Types = struct (* Who thought it would be better to not use array like normal node, but rather enumerate fields by word *) let t : ('context, t option) typ = - obj "ActionStates" ~fields: + obj "ActionStates" ~fields:(fun _ -> [ field "actionStateOne" ~typ:string ~args:Arg.[] ~resolve:(fun _ action_state -> @@ -1424,14 +1424,14 @@ module Types = struct Option.map (Pickles_types.Vector.nth action_state 4) ~f:Snark_params.Tick.Field.to_string ) - ] + ]) end module ActionData = struct type t = Archive.Action.t * int * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "ActionData" ~fields: + obj "ActionData" ~fields:(fun _ -> [ field "accountUpdateId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (_, account_update_id, _) -> @@ -1445,14 +1445,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, _, transaction_info) -> transaction_info) - ] + ]) end module EventData = struct type t = Archive.Event.t * Archive.Transaction_info.t option let t : ('context, t option) typ = - obj "EventData" ~fields: + obj "EventData" ~fields:(fun _ -> [ field "data" ~typ:(non_null @@ list @@ non_null string) ~args:Arg.[] @@ -1462,14 +1462,14 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, transaction_info) -> transaction_info) - ] + ]) end module ActionOutput = struct type t = Archive.Account_update_actions.t let t : ('context, t option) typ = - obj "ActionOutput" ~fields: + obj "ActionOutput" ~fields:(fun _ -> ( let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t @@ -1488,14 +1488,14 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] ) + ] )) end module EventOutput = struct type t = Archive.Account_update_events.t let t : ('context, t option) typ = - obj "EventOutput" ~fields: + obj "EventOutput" ~fields:(fun _ -> ( let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t @@ -1506,7 +1506,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] ) + ] )) end end end From 239044317f9e098499c72eef6c6c43eb28886d5e Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 10:08:12 +0700 Subject: [PATCH 24/51] Use CI opam switch in Zeko test scripts --- src/app/zeko/sequencer/tests/run-explorer-tests.sh | 4 ++-- src/app/zeko/sequencer/tests/run-sequencer-test.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/zeko/sequencer/tests/run-explorer-tests.sh b/src/app/zeko/sequencer/tests/run-explorer-tests.sh index fd20d7739c..c193a6e49f 100755 --- a/src/app/zeko/sequencer/tests/run-explorer-tests.sh +++ b/src/app/zeko/sequencer/tests/run-explorer-tests.sh @@ -28,7 +28,7 @@ cleanup docker run --rm --name "${CONTAINER_NAME}" -p 4222:4222 -d nats:2-alpine >/dev/null wait_for_port 4222 -opam exec --switch . -- env -u DUNE_RPC dune runtest --profile=devnet \ +opam exec -- env -u DUNE_RPC dune runtest --profile=devnet \ src/app/zeko/sequencer/explorer/tests -NATS_URL="${NATS_URL}" opam exec --switch . -- env -u DUNE_RPC dune exec --profile=devnet \ +NATS_URL="${NATS_URL}" opam exec -- env -u DUNE_RPC dune exec --profile=devnet \ src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe diff --git a/src/app/zeko/sequencer/tests/run-sequencer-test.sh b/src/app/zeko/sequencer/tests/run-sequencer-test.sh index 5ef0899aec..a5fd6f6d70 100755 --- a/src/app/zeko/sequencer/tests/run-sequencer-test.sh +++ b/src/app/zeko/sequencer/tests/run-sequencer-test.sh @@ -46,7 +46,7 @@ SEQUENCER_ROOT="$(git rev-parse --show-toplevel)/src/app/zeko/sequencer" SEQUENCER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/sequencer" SIGNER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/signer" -opam exec --switch . -- env -u DUNE_RPC dune build \ +opam exec -- env -u DUNE_RPC dune build \ src/app/zeko/sequencer/tests/testing_ledger/run.exe \ src/app/zeko/da_layer/cli.exe \ src/app/zeko/sequencer/prover/cli.exe \ From 7b5ca3050c5acaa922a995110a03d9d47a3c0e6c Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 17:45:53 +0700 Subject: [PATCH 25/51] Split explorer Gherkin CI and tighten tests --- .github/workflows/ci.yaml | 2 - .github/workflows/explorer-gherkin.yaml | 126 +++++++ .prototools | 4 - README.md | 5 +- scripts/update-opam-switch.sh | 156 +-------- src/app/zeko/sequencer/README.md | 9 +- .../explorer/explorer-indexing-plan.md | 326 ------------------ .../explorer/tests/explorer_gherkin_tests.ml | 56 ++- .../tests/explorer_nats_gherkin_tests.ml | 7 + .../sequencer/tests/run-explorer-tests.sh | 34 -- 10 files changed, 188 insertions(+), 537 deletions(-) create mode 100644 .github/workflows/explorer-gherkin.yaml delete mode 100644 .prototools delete mode 100644 src/app/zeko/sequencer/explorer/explorer-indexing-plan.md delete mode 100755 src/app/zeko/sequencer/tests/run-explorer-tests.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b6afaf8bf7..4e8206a5d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -115,5 +115,3 @@ jobs: opam exec -- dune build --profile=devnet src/app/zeko/signer - name: 🧪 Run sequencer tests run: ./src/app/zeko/sequencer/tests/run-sequencer-test.sh real 1 - - name: 🧪 Run explorer Gherkin tests - run: ./src/app/zeko/sequencer/tests/run-explorer-tests.sh diff --git a/.github/workflows/explorer-gherkin.yaml b/.github/workflows/explorer-gherkin.yaml new file mode 100644 index 0000000000..8c805925f6 --- /dev/null +++ b/.github/workflows/explorer-gherkin.yaml @@ -0,0 +1,126 @@ +# Runs the explorer Gherkin acceptance tests independently from the sequencer +# integration suite so PR feedback is not blocked on the longer sequencer flow. +--- +name: Explorer Gherkin + +on: + push: + +concurrency: + group: "${{ github.workflow }} @ ${{ github.head_ref || github.ref }}" + cancel-in-progress: true + +jobs: + explorer-gherkin: + name: "Explorer Gherkin" + runs-on: warp-ubuntu-latest-x64-16x + steps: + - name: 📥 Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + - name: 🐹 Setup go + uses: actions/setup-go@v6 + with: + go-version: "1.19.11" + - name: 🐹 Build and install capnpc-go + run: | + tmpdir=$(mktemp -d) + cd "$tmpdir" + go mod init local/build + go get capnproto.org/go/capnp/v3@v3.0.0-alpha.5 + go build -o capnpc-go capnproto.org/go/capnp/v3/capnpc-go + sudo cp capnpc-go /usr/local/bin/ + cd - + - uses: dtolnay/rust-toolchain@stable + - name: 📥 Install OPAM + run: | + sudo apt-get update + sudo apt-get install -y opam ocaml \ + libboost-dev \ + libboost-program-options-dev \ + libbz2-dev \ + libcap-dev \ + libffi-dev \ + libgflags-dev \ + libgmp-dev \ + libgmp3-dev \ + libjemalloc-dev \ + liblmdb-dev \ + liblmdb0 \ + libpq-dev \ + libsodium-dev \ + libssl-dev \ + pkg-config zlib1g-dev \ + capnproto \ + netcat-traditional + for i in 1 2 3 4 5; do + rm -rf ./opam-repository + git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository && break + echo "clone failed, retry $i" + getent hosts github.com || true + sleep 5 + done + git -C ./opam-repository fetch origin 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 + git -C ./opam-repository checkout 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 + for i in 1 2 3 4 5; do + rm -rf ./opam-repository-published + git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository-published && break + echo "clone failed, retry $i" + getent hosts github.com || true + sleep 5 + done + git -C ./opam-repository-published fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository-published checkout ba1ca7509cb2617776f017673de0f2a48be67105 + opam init --disable-sandboxing -k git -a ./opam-repository --bare + opam repository add --yes --all --set-default --kind=local published ./opam-repository-published + for i in 1 2 3 4 5; do + opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break + echo "opam repo add failed, retry $i" + sleep 5 + done + opam update default published o1-labs + opam switch create "4.14.0" + opam switch "4.14.0" + export OPAMERRLOGLEN=20 + export OPAMYES=1 + - name: 🧩 Get opam dependency cache + uses: WarpBuilds/cache@v1 + id: opam_dependencies + with: + path: ~/.opam + key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} + - name: 🏗️ Install opam dependencies + run: | + opam switch import opam.export --yes + eval "$(opam env)" + ./scripts/pin-external-packages.sh + opam clean --logs -cs --quiet + - name: 🧩 Get zeko build cache + uses: WarpBuilds/cache@v1 + id: zeko_build + with: + path: ./_build + key: zeko_build-${{ hashFiles('./opam.export') }} + - name: 🧪 Run explorer Gherkin tests + run: | + opam exec -- env -u DUNE_RPC dune runtest --profile=devnet \ + src/app/zeko/sequencer/explorer/tests + - name: 🧪 Run explorer NATS Gherkin tests + run: | + docker run --rm --name nats-sequencer-explorer-tests \ + -p 4222:4222 -d nats:2-alpine >/dev/null + for i in $(seq 1 30); do + nc -z 127.0.0.1 4222 && break + if [ "$i" -eq 30 ]; then + echo "Timed out waiting for NATS" >&2 + exit 1 + fi + sleep 1 + done + NATS_URL="nats://127.0.0.1:4222" \ + opam exec -- env -u DUNE_RPC dune exec --profile=devnet \ + src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe + - name: 🧹 Stop NATS + if: always() + run: docker rm -f nats-sequencer-explorer-tests >/dev/null 2>&1 || true diff --git a/.prototools b/.prototools deleted file mode 100644 index 4ec55a8388..0000000000 --- a/.prototools +++ /dev/null @@ -1,4 +0,0 @@ -ocaml = "4.14.0" - -[plugins.tools] -ocaml = "github://Hebilicious/proto-ocaml@v0.1.2" diff --git a/README.md b/README.md index d638af20e9..52da19c329 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Zeko [Zeko](https://twitter.com/ZekoLabs) is a fully isomorphic L2 for the Mina protocol ecosystem. -We support recursion in smart contracts natively. Ethereum deployment is under development, and will be a separate instance of Zeko. +We support recursion in smart contracts natively, the exact same as Mina. This repository is a fork of the [Mina codebase](https://github.com/MinaProtocol/mina). The Zeko related code is in the [`zeko`](./src/app/zeko) directory. The license for code under the `zeko` subdirectory is [custom](./src/app/zeko/LICENSE.md) -though it will be soon updated to the same Apache license at the root of the repository. +and is **NOT** the Apache license at the root of the repository. +The Apache license only applies to the original Mina source code. diff --git a/scripts/update-opam-switch.sh b/scripts/update-opam-switch.sh index d537523f54..af8745fbb7 100755 --- a/scripts/update-opam-switch.sh +++ b/scripts/update-opam-switch.sh @@ -1,117 +1,15 @@ #!/usr/bin/env bash -# Reuses a shared opam switch cache keyed by the exported toolchain. Each -# checkout keeps a local `_opam` symlink pointing at the shared cache entry so -# identical dependency snapshots can be reused across worktrees. The opam -# package universe is pinned to the same ocaml/opam-repository commits used in -# CI so local resolution matches CI. - set -eo pipefail SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" cd "$SCRIPT_DIR/.." -ensure_homebrew_deps() { - local missing=() - local formula - - for formula in \ - boost \ - capnp \ - gflags \ - gmp \ - jemalloc \ - libffi \ - libsodium \ - lmdb \ - pkg-config \ - rocksdb \ - zlib - do - brew list --formula "${formula}" >/dev/null 2>&1 || missing+=("${formula}") - done - - if ! brew list --formula openssl@1.1.1 >/dev/null 2>&1 \ - && ! brew list --formula openssl@3 >/dev/null 2>&1; then - missing+=("openssl@1.1.1") - fi - - if ! brew list --formula postgresql@14 >/dev/null 2>&1 \ - && ! brew list --formula postgresql@18 >/dev/null 2>&1 \ - && ! brew list --formula postgresql >/dev/null 2>&1; then - missing+=("postgresql@18") - fi - - if ((${#missing[@]} > 0)); then - brew install "${missing[@]}" - fi -} - -prepend_path_var() { - local var_name="$1" - local dir="$2" - local current="${!var_name:-}" - - [[ -d "${dir}" ]] || return 0 - - if [[ -z "${current}" ]]; then - printf -v "${var_name}" '%s' "${dir}" - elif [[ ":${current}:" != *":${dir}:"* ]]; then - printf -v "${var_name}" '%s:%s' "${dir}" "${current}" - fi - - export "${var_name}" -} - -configure_homebrew_env() { - local prefix - local openssl_prefix - - if brew list --formula openssl@1.1.1 >/dev/null 2>&1; then - openssl_prefix="$(brew --prefix openssl@1.1.1)" - else - openssl_prefix="$(brew --prefix openssl@3)" - fi - - for prefix in \ - "$(brew --prefix gmp)" \ - "$(brew --prefix libffi)" \ - "$(brew --prefix lmdb)" \ - "${openssl_prefix}" \ - "$(brew --prefix zlib)" - do - prepend_path_var CPATH "${prefix}/include" - prepend_path_var LIBRARY_PATH "${prefix}/lib" - prepend_path_var PKG_CONFIG_PATH "${prefix}/lib/pkgconfig" - done - - prepend_path_var PATH "$(brew --prefix lmdb)/bin" - - export CFLAGS="${CFLAGS:+${CFLAGS} }-I$(brew --prefix gmp)/include" - export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }-I$(brew --prefix gmp)/include" - export LDFLAGS="${LDFLAGS:+${LDFLAGS} }-L$(brew --prefix gmp)/lib" - - if [[ "${openssl_prefix}" == "$(brew --prefix openssl@1.1.1)" ]]; then - export CFLAGS="${CFLAGS} -Wno-implicit-function-declaration -Wno-incompatible-function-pointer-types -Wno-deprecated-declarations" - fi -} - # Don't do anything if we're in a nix shell [[ "$IN_NIX_SHELL$CI$BUILDKITE" == "" ]] || exit 0 -opam_repo_commit="08d8c16c16dc6b23a5278b06dff0ac6c7a217356" -published_opam_repo_commit="ba1ca7509cb2617776f017673de0f2a48be67105" -repo_cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/zeko" -opam_repo_dir="${repo_cache_root}/opam-repository/${opam_repo_commit}" -published_opam_repo_dir="${repo_cache_root}/opam-repository/${published_opam_repo_commit}" - -sum="$({ - printf '%s\n' "${opam_repo_commit}" - printf '%s\n' "${published_opam_repo_commit}" - cat opam.export -} | cksum | awk '{print $1}')" -cache_root="${repo_cache_root}/opam-switches" -switch_dir="${cache_root}/${sum}" +sum="$(cksum opam.export | grep -oE '^\S*')" +switch_dir=opam_switches/"$sum" if [[ -d _opam ]]; then read -rp "Directory '_opam' exists and will be removed. Continue? [y/N] " \ @@ -125,53 +23,13 @@ if [[ -d _opam ]]; then fi if [[ ! -d "${switch_dir}" ]]; then - if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then - ensure_homebrew_deps - configure_homebrew_env - fi - - mkdir -p "$(dirname "${opam_repo_dir}")" - if [[ ! -d "${opam_repo_dir}/.git" ]]; then - git clone https://github.com/ocaml/opam-repository.git --depth 1 \ - "${opam_repo_dir}" - fi - git -C "${opam_repo_dir}" fetch origin "${opam_repo_commit}" --depth 1 - git -C "${opam_repo_dir}" checkout --detach "${opam_repo_commit}" - - mkdir -p "$(dirname "${published_opam_repo_dir}")" - if [[ ! -d "${published_opam_repo_dir}/.git" ]]; then - git clone https://github.com/ocaml/opam-repository.git --depth 1 \ - "${published_opam_repo_dir}" - fi - git -C "${published_opam_repo_dir}" fetch origin \ - "${published_opam_repo_commit}" --depth 1 - git -C "${published_opam_repo_dir}" checkout --detach \ - "${published_opam_repo_commit}" - - if opam repository list --all --short | grep -qx default; then - opam repository set-url --kind=local default "${opam_repo_dir}" - opam repository add --yes --all --set-default default - else - opam repository add --yes --all --set-default --kind=local default \ - "${opam_repo_dir}" - fi - - if opam repository list --all --short | grep -qx published; then - opam repository set-url --kind=local published \ - "${published_opam_repo_dir}" - opam repository add --yes --all --set-default published - else - opam repository add --yes --all --set-default --kind=local published \ - "${published_opam_repo_dir}" - fi - - # We add o1-labs opam repository and make it default selection - # (if it's repeated, it's a no-op). + # We add o1-labs opam repository and make it default + # (if it's repeated, it's a no-op) opam repository add --yes --all --set-default o1-labs \ https://github.com/o1-labs/opam-repository.git - opam update default published o1-labs - opam switch import -y --assume-depexts --switch . opam.export - mkdir -p "${cache_root}" + opam update + opam switch import -y --switch . opam.export + mkdir -p opam_switches mv _opam "${switch_dir}" fi diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index aa54c94589..84ceb0ae47 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -25,7 +25,8 @@ DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer ```bash dune build ./src/app/zeko/sequencer/tests/run-sequencer-test.sh {fake | real} -./src/app/zeko/sequencer/tests/run-explorer-tests.sh +opam exec -- env -u DUNE_RPC dune runtest --profile=devnet src/app/zeko/sequencer/explorer/tests +NATS_URL="nats://127.0.0.1:4222" opam exec -- env -u DUNE_RPC dune exec --profile=devnet src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe ``` ## Run @@ -172,9 +173,9 @@ responses as GraphQL-SSE events. The explorer-specific tests now live under `src/app/zeko/sequencer/explorer/tests/` and use copied `.feature` files from -the explorer spike as the main test inventory. `run-explorer-tests.sh` runs the -Gherkin acceptance suite first and then a separate Gherkin-backed real-NATS -integration executable against a Docker NATS server. +the explorer spike as the main test inventory. CI runs them in the dedicated +Explorer Gherkin workflow, separate from the longer sequencer integration flow. +The real-NATS scenarios expect `NATS_URL` to point at a running NATS server. ## Deploy rollup contract to L1 diff --git a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md deleted file mode 100644 index 8880b15ef0..0000000000 --- a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md +++ /dev/null @@ -1,326 +0,0 @@ -# Explorer Indexing Plan - -## High Level - -This work adds a first-party NATS publishing path to the Zeko sequencer and a standalone backfill service in this repo. - -When it is done, this repo will be able to: - -- publish L2 transaction events as the sequencer processes diffs -- publish L2 finality events as batches move from proved to committed -- publish health heartbeats for freshness monitoring -- republish historical diff ranges into NATS through a standalone backfill service - -This matters because explorer and indexing systems need a direct, replayable event stream from the sequencer and DA layer, without depending on downstream reconstruction of sequencer state. - -## 1. Integrate `nats-ml` into this repo - -Work: - -- Bring `[nats-ml](https://github.com/Hebilicious/nats-ml)` into this repo from GitHub. -- Wire it into the repo build so sequencer code can depend on it. -- Make it available to both the sequencer and the backfill service. - -Result: - -- NATS publishing depends on a repo-local integration of `nats-ml`, not an external manual install step. - -## 2. Add sequencer NATS configuration - -Target files: - -- `src/app/zeko/sequencer/run.ml` -- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` -- `src/app/zeko/sequencer/dune` - -Work: - -- Add `--nats-url` to the sequencer CLI. -- Extend sequencer config and runtime state with an optional NATS client. -- Create the `nats-ml` client at startup when `--nats-url` is present. -- Keep all publishing paths as no-ops when NATS is not configured. - -Result: - -- The sequencer runs unchanged without NATS. -- The sequencer publishes to NATS when configured. - -## 3. Add one shared NATS event module - -Target files: - -- `src/app/zeko/sequencer/lib/` - -Work: - -- Add a small module that owns NATS subjects, headers, and JSON encoding. -- Use this module from both live sequencer publishing and the backfill service. -- Keep the event contract in one place. - -Subjects: - -- `zeko.l2.transactions` -- `zeko.l2.finality` -- `zeko.health` - -Headers: - -- `Nats-Msg-Id: ` on every `zeko.l2.transactions` message - -### `zeko.l2.transactions` schema - -Fields: - -- `kind` - - `user_command` - - `fee_transfer` - - `sync_replay` - - `genesis_replay` -- `target_ledger_hash` -- `genesis` -- `diff` - -`diff` fields: - -- `source_ledger_hash` -- `changed_accounts` -- `command_with_action_step_flags` -- `timestamp` -- `acc_set` - -Encoding rules: - -- `diff` uses the existing JSON shape derived from `Da_layer.Diff.Stable.V3`. -- `target_ledger_hash` is the post-diff ledger hash passed to `Da_layer.Client.enqueue_diff`. -- `genesis` matches the flag passed to `Da_layer.Client.enqueue_diff`. - -### `zeko.l2.finality` schema - -Fields: - -- `status` - - `proved` - - `committed` -- `source_ledger_hash` -- `target_ledger_hash` -- `timestamp` - -Encoding rules: - -- `proved` is emitted from the successful committer result. -- `committed` is emitted after `State.Last_committed_ledger.set`. - -### `zeko.health` schema - -Fields: - -- `service` -- `instance_id` -- `status` -- `last_published_hash` -- `unproved_hash` -- `timestamp` - -Encoding rules: - -- `service = "sequencer-nats-publisher"` -- `status = "ok"` - -Result: - -- This repo has one stable event boundary for explorer ingestion. - -## 4. Publish L2 transaction events from the sequencer - -Target file: - -- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` - -Integration points: - -- `apply_user_command` -- `apply_fee_transfer` -- `sync` - -Work: - -- After `Da_layer.Client.enqueue_diff` in `apply_user_command`, publish one `zeko.l2.transactions` message with `kind = user_command`. -- After `Da_layer.Client.enqueue_diff` in `apply_fee_transfer`, publish one `zeko.l2.transactions` message with `kind = fee_transfer`. -- During `sync`, publish replayed diffs after they are re-enqueued. -- Use `kind = genesis_replay` when the replayed diff was enqueued with `genesis = true`. -- Use `kind = sync_replay` for the rest of the replayed diffs. -- Set `Nats-Msg-Id` from `target_ledger_hash`. - -Result: - -- Live traffic and replayed traffic produce the same transaction event format. - -## 5. Publish finality events from the sequencer - -Target file: - -- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` - -Integration points: - -- `run_committer` -- `Merger.Commit.process` - -Work: - -- Publish `zeko.l2.finality` with `status = proved` when `run_committer` receives `Some (stmt, _)`. -- Publish `zeko.l2.finality` with `status = committed` after `State.Last_committed_ledger.set` in the commit path. -- Include both source and target ledger hashes in both cases. - -Result: - -- The explorer stack can track proved and committed progress directly from NATS. - -## 6. Publish sequencer health heartbeats - -Target file: - -- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` - -Work: - -- Add a periodic heartbeat loop. -- Emit `zeko.health` messages using the shared event module. -- Generate one sequencer instance id at startup and reuse it for all heartbeats from that process. - -Result: - -- The sequencer emits a freshness signal even when no transactions are being processed. - -## 7. Add the backfill service - -Target area: - -- new standalone executable under `src/app/zeko/sequencer/` -- build wiring in `src/app/zeko/sequencer/dune` - -Work: - -- Add a standalone OCaml service that reads diffs from the DA layer and republishes them to NATS. -- Use `nats-ml` for publishing. -- Reuse the shared NATS event module so backfilled messages match live messages exactly. -- Track backfill jobs in memory. -- Generate one service instance id at startup. - -GraphQL API: - -- `backfill(fromHash, toHash) -> BackfillJob` -- `backfillJob(id) -> BackfillJob` -- `health -> Health` - -GraphQL-SSE subscription: - -- `backfillProgress(id) -> BackfillProgress` - -`BackfillJob` fields: - -- `id` -- `fromHash` -- `toHash` -- `status` -- `diffsPublished` -- `error` -- `createdAt` -- `startedAt` -- `finishedAt` - -`BackfillProgress` fields: - -- `id` -- `status` -- `diffsPublished` -- `error` - -`Health` fields: - -- `ok` -- `instanceId` -- `startedAt` - -Transport: - -- Use GraphQL over HTTP for query and mutation. -- Use GraphQL-SSE for `backfillProgress`. - -Publishing rules: - -- Republish to `zeko.l2.transactions`. -- Use the same payload schema and `Nats-Msg-Id` rule as the live sequencer path. -- Emit `kind = genesis_replay` only when replaying a genesis diff. -- Emit `kind = sync_replay` for all other backfilled diffs. - -Result: - -- Missing L2 ranges can be republished into NATS without changing sequencer state. - -## 8. Update sequencer docs - -Target file: - -- `src/app/zeko/sequencer/README.md` - -Work: - -- Document `--nats-url`. -- Document the three emitted subjects. -- Document the exact payload families. -- Document how to run the backfill service. -- Document the backfill GraphQL and GraphQL-SSE surface. - -Result: - -- The repo docs describe the explorer-facing ingestion path end to end. - -## 9. Add tests as first-class work - -Work: - -- Add tests for the shared event module: - - transaction payload encoding - - finality payload encoding - - health payload encoding - - `Nats-Msg-Id` generation -- Add sequencer publishing tests for: - - `apply_user_command` - - `apply_fee_transfer` - - `sync` replay - - `run_committer` proved event - - commit-path committed event - - health heartbeat emission -- Add backfill service tests for: - - `backfill` job creation - - ordered republishing across a hash range - - progress updates over GraphQL-SSE - - `health` response contents - - matching payload shape between live and backfilled messages - -Result: - -- The explorer-facing boundary is covered directly in this repo. - -## Delivery Order - -1. Integrate `nats-ml` into this repo. -2. Add sequencer startup/config wiring for NATS. -3. Add the shared NATS event module. -4. Publish `zeko.l2.transactions`. -5. Publish `zeko.l2.finality`. -6. Publish `zeko.health`. -7. Add the backfill service with GraphQL and GraphQL-SSE. -8. Finish docs and tests. - -## Deliverable - -After this work, the `zeko` repo provides: - -- repo-local `nats-ml` integration -- optional sequencer publishing to NATS -- replay of historical diffs during sync -- finality and health events -- a standalone backfill service with GraphQL and GraphQL-SSE -- test coverage around the explorer-facing publish boundary diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml index 340716a50a..47834a47d2 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -127,6 +127,12 @@ let assert_basic_json_equal actual expected = failwithf "Expected %s but got %s" (Yojson.Basic.to_string expected) (Yojson.Basic.to_string actual) () +let assert_safe_json_equal actual expected = + if not (Yojson.Safe.equal actual expected) + then + failwithf "Expected %s but got %s" (Yojson.Safe.to_string expected) + (Yojson.Safe.to_string actual) () + let%test_unit "A genesis backfill marks only the first replayed diff as genesis" = Feature_parser.assert_scenario "backfill-api.feature" "A genesis backfill marks only the first replayed diff as genesis" ; @@ -205,9 +211,10 @@ let%test_unit "The backfill health query exposes the service instance" = "The backfill health query exposes the service instance" ; let health = graphql_data_exn (test_service ()) - {|query { health { instanceId startedAt } }|} + {|query { health { ok instanceId startedAt } }|} "health" in + assert_basic_json_equal (find_assoc_exn "ok" health) (`Bool true) ; assert_basic_json_equal (find_assoc_exn "instanceId" health) (`String "instance-1") ; [%test_eq: bool] @@ -232,7 +239,9 @@ let%test_unit "The backfill job query returns the current job snapshot" = in assert_basic_json_equal (find_assoc_exn "id" backfill_job) (`String job.id) ; assert_basic_json_equal (find_assoc_exn "status" backfill_job) - (`String "running") + (`String "running") ; + assert_basic_json_equal (find_assoc_exn "diffsPublished" backfill_job) + (`Int 0) let%test_unit "Backfill progress subscriptions stream GraphQL-SSE events" = Feature_parser.assert_scenario "backfill-api.feature" @@ -275,15 +284,21 @@ let%test_unit "Transaction events include the replay kind, diff payload, and NAT Feature_parser.assert_scenario "event-contract.feature" "Transaction events include the replay kind, diff payload, and NATS dedup header" ; let target_ledger_hash = Ledger_hash.empty_hash in + let diff = sample_diff () in let message = Explorer_events.build_transaction_message ~kind:Explorer_events.Transaction_kind.Sync_replay - ~target_ledger_hash ~genesis:false ~diff:(sample_diff ()) + ~target_ledger_hash ~genesis:false ~diff in [%test_eq: string] message.subject Explorer_events.Subject.transactions ; - ignore (find_assoc_exn "kind" message.payload : Yojson.Safe.t) ; - ignore (find_assoc_exn "target_ledger_hash" message.payload : Yojson.Safe.t) ; - ignore (find_assoc_exn "diff" message.payload : Yojson.Safe.t) ; + assert_safe_json_equal (find_assoc_exn "kind" message.payload) + (`String "sync_replay") ; + assert_safe_json_equal (find_assoc_exn "target_ledger_hash" message.payload) + (Ledger_hash.to_yojson target_ledger_hash) ; + assert_safe_json_equal (find_assoc_exn "genesis" message.payload) + (`Bool false) ; + assert_safe_json_equal (find_assoc_exn "diff" message.payload) + (Da_layer.Diff.Stable.V3.to_yojson diff) ; [%test_eq: string] (find_header_exn message.headers "Nats-Msg-Id") (Ledger_hash.to_decimal_string target_ledger_hash) @@ -298,11 +313,14 @@ let%test_unit "Finality events include status and ledger hashes" = ~target_ledger_hash:Ledger_hash.empty_hash in [%test_eq: string] message.subject Explorer_events.Subject.finality ; - ignore (find_assoc_exn "status" message.payload : Yojson.Safe.t) ; - ignore - (find_assoc_exn "source_ledger_hash" message.payload : Yojson.Safe.t) ; - ignore - (find_assoc_exn "target_ledger_hash" message.payload : Yojson.Safe.t) + assert_safe_json_equal (find_assoc_exn "status" message.payload) + (`String "proved") ; + assert_safe_json_equal + (find_assoc_exn "source_ledger_hash" message.payload) + (Ledger_hash.to_yojson Ledger_hash.empty_hash) ; + assert_safe_json_equal + (find_assoc_exn "target_ledger_hash" message.payload) + (Ledger_hash.to_yojson Ledger_hash.empty_hash) let%test_unit "Health events include the service identity and publishing state" = Feature_parser.assert_scenario "event-contract.feature" @@ -314,11 +332,17 @@ let%test_unit "Health events include the service identity and publishing state" ~unproved_hash:Ledger_hash.empty_hash () in [%test_eq: string] message.subject Explorer_events.Subject.health ; - ignore (find_assoc_exn "service" message.payload : Yojson.Safe.t) ; - ignore (find_assoc_exn "instance_id" message.payload : Yojson.Safe.t) ; - ignore - (find_assoc_exn "last_published_hash" message.payload : Yojson.Safe.t) ; - ignore (find_assoc_exn "unproved_hash" message.payload : Yojson.Safe.t) + assert_safe_json_equal (find_assoc_exn "service" message.payload) + (`String "sequencer-nats-publisher") ; + assert_safe_json_equal (find_assoc_exn "instance_id" message.payload) + (`String "instance-1") ; + assert_safe_json_equal (find_assoc_exn "status" message.payload) + (`String "ok") ; + assert_safe_json_equal + (find_assoc_exn "last_published_hash" message.payload) + (Ledger_hash.to_yojson Ledger_hash.empty_hash) ; + assert_safe_json_equal (find_assoc_exn "unproved_hash" message.payload) + (Ledger_hash.to_yojson Ledger_hash.empty_hash) let%test_unit "Sync replay from genesis marks the very first diff as genesis" = Feature_parser.assert_scenario "sequencer-hooks.feature" diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml index a4b602a654..c8349d3fcc 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml @@ -149,6 +149,13 @@ let run_backfill_scenario () = if not (String.equal message.subject Explorer_events.Subject.transactions) then failwithf "unexpected backfill subject: %s" message.subject () ; + let headers = + message.headers + |> Option.value ~default:Nats_client.Headers.empty + |> Nats_client.Headers.to_list + in + require_header headers ~name:"Nats-Msg-Id" + ~expected:(Ledger_hash.to_decimal_string target_ledger_hash) ; let payload = Yojson.Safe.from_string message.payload in assert_safe_json_equal (json_assoc_exn "kind" payload) (`String "genesis_replay") ; diff --git a/src/app/zeko/sequencer/tests/run-explorer-tests.sh b/src/app/zeko/sequencer/tests/run-explorer-tests.sh deleted file mode 100755 index c193a6e49f..0000000000 --- a/src/app/zeko/sequencer/tests/run-explorer-tests.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -CONTAINER_NAME="nats-sequencer-explorer-tests" -NATS_URL="${NATS_URL:-nats://127.0.0.1:4222}" - -cleanup() { - docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true -} - -trap cleanup EXIT - -wait_for_port() { - local port="$1" - local attempts=30 - while ! nc -z 127.0.0.1 "${port}" >/dev/null 2>&1; do - attempts=$((attempts - 1)) - if [ "${attempts}" -le 0 ]; then - echo "Timed out waiting for port ${port}" >&2 - exit 1 - fi - sleep 1 - done -} - -cleanup - -docker run --rm --name "${CONTAINER_NAME}" -p 4222:4222 -d nats:2-alpine >/dev/null -wait_for_port 4222 - -opam exec -- env -u DUNE_RPC dune runtest --profile=devnet \ - src/app/zeko/sequencer/explorer/tests -NATS_URL="${NATS_URL}" opam exec -- env -u DUNE_RPC dune exec --profile=devnet \ - src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe From 3201cb42685512581fb514c5030a2fba261c4138 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 23:24:47 +0700 Subject: [PATCH 26/51] Restore explorer plan and prototools --- .prototools | 4 + .../explorer/explorer-indexing-plan.md | 326 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 .prototools create mode 100644 src/app/zeko/sequencer/explorer/explorer-indexing-plan.md diff --git a/.prototools b/.prototools new file mode 100644 index 0000000000..4ec55a8388 --- /dev/null +++ b/.prototools @@ -0,0 +1,4 @@ +ocaml = "4.14.0" + +[plugins.tools] +ocaml = "github://Hebilicious/proto-ocaml@v0.1.2" diff --git a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md new file mode 100644 index 0000000000..8880b15ef0 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md @@ -0,0 +1,326 @@ +# Explorer Indexing Plan + +## High Level + +This work adds a first-party NATS publishing path to the Zeko sequencer and a standalone backfill service in this repo. + +When it is done, this repo will be able to: + +- publish L2 transaction events as the sequencer processes diffs +- publish L2 finality events as batches move from proved to committed +- publish health heartbeats for freshness monitoring +- republish historical diff ranges into NATS through a standalone backfill service + +This matters because explorer and indexing systems need a direct, replayable event stream from the sequencer and DA layer, without depending on downstream reconstruction of sequencer state. + +## 1. Integrate `nats-ml` into this repo + +Work: + +- Bring `[nats-ml](https://github.com/Hebilicious/nats-ml)` into this repo from GitHub. +- Wire it into the repo build so sequencer code can depend on it. +- Make it available to both the sequencer and the backfill service. + +Result: + +- NATS publishing depends on a repo-local integration of `nats-ml`, not an external manual install step. + +## 2. Add sequencer NATS configuration + +Target files: + +- `src/app/zeko/sequencer/run.ml` +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` +- `src/app/zeko/sequencer/dune` + +Work: + +- Add `--nats-url` to the sequencer CLI. +- Extend sequencer config and runtime state with an optional NATS client. +- Create the `nats-ml` client at startup when `--nats-url` is present. +- Keep all publishing paths as no-ops when NATS is not configured. + +Result: + +- The sequencer runs unchanged without NATS. +- The sequencer publishes to NATS when configured. + +## 3. Add one shared NATS event module + +Target files: + +- `src/app/zeko/sequencer/lib/` + +Work: + +- Add a small module that owns NATS subjects, headers, and JSON encoding. +- Use this module from both live sequencer publishing and the backfill service. +- Keep the event contract in one place. + +Subjects: + +- `zeko.l2.transactions` +- `zeko.l2.finality` +- `zeko.health` + +Headers: + +- `Nats-Msg-Id: ` on every `zeko.l2.transactions` message + +### `zeko.l2.transactions` schema + +Fields: + +- `kind` + - `user_command` + - `fee_transfer` + - `sync_replay` + - `genesis_replay` +- `target_ledger_hash` +- `genesis` +- `diff` + +`diff` fields: + +- `source_ledger_hash` +- `changed_accounts` +- `command_with_action_step_flags` +- `timestamp` +- `acc_set` + +Encoding rules: + +- `diff` uses the existing JSON shape derived from `Da_layer.Diff.Stable.V3`. +- `target_ledger_hash` is the post-diff ledger hash passed to `Da_layer.Client.enqueue_diff`. +- `genesis` matches the flag passed to `Da_layer.Client.enqueue_diff`. + +### `zeko.l2.finality` schema + +Fields: + +- `status` + - `proved` + - `committed` +- `source_ledger_hash` +- `target_ledger_hash` +- `timestamp` + +Encoding rules: + +- `proved` is emitted from the successful committer result. +- `committed` is emitted after `State.Last_committed_ledger.set`. + +### `zeko.health` schema + +Fields: + +- `service` +- `instance_id` +- `status` +- `last_published_hash` +- `unproved_hash` +- `timestamp` + +Encoding rules: + +- `service = "sequencer-nats-publisher"` +- `status = "ok"` + +Result: + +- This repo has one stable event boundary for explorer ingestion. + +## 4. Publish L2 transaction events from the sequencer + +Target file: + +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` + +Integration points: + +- `apply_user_command` +- `apply_fee_transfer` +- `sync` + +Work: + +- After `Da_layer.Client.enqueue_diff` in `apply_user_command`, publish one `zeko.l2.transactions` message with `kind = user_command`. +- After `Da_layer.Client.enqueue_diff` in `apply_fee_transfer`, publish one `zeko.l2.transactions` message with `kind = fee_transfer`. +- During `sync`, publish replayed diffs after they are re-enqueued. +- Use `kind = genesis_replay` when the replayed diff was enqueued with `genesis = true`. +- Use `kind = sync_replay` for the rest of the replayed diffs. +- Set `Nats-Msg-Id` from `target_ledger_hash`. + +Result: + +- Live traffic and replayed traffic produce the same transaction event format. + +## 5. Publish finality events from the sequencer + +Target file: + +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` + +Integration points: + +- `run_committer` +- `Merger.Commit.process` + +Work: + +- Publish `zeko.l2.finality` with `status = proved` when `run_committer` receives `Some (stmt, _)`. +- Publish `zeko.l2.finality` with `status = committed` after `State.Last_committed_ledger.set` in the commit path. +- Include both source and target ledger hashes in both cases. + +Result: + +- The explorer stack can track proved and committed progress directly from NATS. + +## 6. Publish sequencer health heartbeats + +Target file: + +- `src/app/zeko/sequencer/lib/zeko_sequencer.ml` + +Work: + +- Add a periodic heartbeat loop. +- Emit `zeko.health` messages using the shared event module. +- Generate one sequencer instance id at startup and reuse it for all heartbeats from that process. + +Result: + +- The sequencer emits a freshness signal even when no transactions are being processed. + +## 7. Add the backfill service + +Target area: + +- new standalone executable under `src/app/zeko/sequencer/` +- build wiring in `src/app/zeko/sequencer/dune` + +Work: + +- Add a standalone OCaml service that reads diffs from the DA layer and republishes them to NATS. +- Use `nats-ml` for publishing. +- Reuse the shared NATS event module so backfilled messages match live messages exactly. +- Track backfill jobs in memory. +- Generate one service instance id at startup. + +GraphQL API: + +- `backfill(fromHash, toHash) -> BackfillJob` +- `backfillJob(id) -> BackfillJob` +- `health -> Health` + +GraphQL-SSE subscription: + +- `backfillProgress(id) -> BackfillProgress` + +`BackfillJob` fields: + +- `id` +- `fromHash` +- `toHash` +- `status` +- `diffsPublished` +- `error` +- `createdAt` +- `startedAt` +- `finishedAt` + +`BackfillProgress` fields: + +- `id` +- `status` +- `diffsPublished` +- `error` + +`Health` fields: + +- `ok` +- `instanceId` +- `startedAt` + +Transport: + +- Use GraphQL over HTTP for query and mutation. +- Use GraphQL-SSE for `backfillProgress`. + +Publishing rules: + +- Republish to `zeko.l2.transactions`. +- Use the same payload schema and `Nats-Msg-Id` rule as the live sequencer path. +- Emit `kind = genesis_replay` only when replaying a genesis diff. +- Emit `kind = sync_replay` for all other backfilled diffs. + +Result: + +- Missing L2 ranges can be republished into NATS without changing sequencer state. + +## 8. Update sequencer docs + +Target file: + +- `src/app/zeko/sequencer/README.md` + +Work: + +- Document `--nats-url`. +- Document the three emitted subjects. +- Document the exact payload families. +- Document how to run the backfill service. +- Document the backfill GraphQL and GraphQL-SSE surface. + +Result: + +- The repo docs describe the explorer-facing ingestion path end to end. + +## 9. Add tests as first-class work + +Work: + +- Add tests for the shared event module: + - transaction payload encoding + - finality payload encoding + - health payload encoding + - `Nats-Msg-Id` generation +- Add sequencer publishing tests for: + - `apply_user_command` + - `apply_fee_transfer` + - `sync` replay + - `run_committer` proved event + - commit-path committed event + - health heartbeat emission +- Add backfill service tests for: + - `backfill` job creation + - ordered republishing across a hash range + - progress updates over GraphQL-SSE + - `health` response contents + - matching payload shape between live and backfilled messages + +Result: + +- The explorer-facing boundary is covered directly in this repo. + +## Delivery Order + +1. Integrate `nats-ml` into this repo. +2. Add sequencer startup/config wiring for NATS. +3. Add the shared NATS event module. +4. Publish `zeko.l2.transactions`. +5. Publish `zeko.l2.finality`. +6. Publish `zeko.health`. +7. Add the backfill service with GraphQL and GraphQL-SSE. +8. Finish docs and tests. + +## Deliverable + +After this work, the `zeko` repo provides: + +- repo-local `nats-ml` integration +- optional sequencer publishing to NATS +- replay of historical diffs during sync +- finality and health events +- a standalone backfill service with GraphQL and GraphQL-SSE +- test coverage around the explorer-facing publish boundary From 8af520f876ed017c0de72bf76bc5c4e7aebb5b3a Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 17 Apr 2026 23:42:31 +0700 Subject: [PATCH 27/51] Tighten explorer Gherkin CI wiring --- .github/workflows/ci.yaml | 16 ++-------- .github/workflows/explorer-gherkin.yaml | 34 +++++++--------------- src/app/zeko/sequencer/README.md | 6 ++-- src/app/zeko/sequencer/explorer/tests/dune | 5 ++++ 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4e8206a5d2..34591ca7f9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,25 +62,15 @@ jobs: getent hosts github.com || true sleep 5 done - git -C ./opam-repository fetch origin 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 - git -C ./opam-repository checkout 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 - for i in 1 2 3 4 5; do - rm -rf ./opam-repository-published - git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository-published && break - echo "clone failed, retry $i" - getent hosts github.com || true - sleep 5 - done - git -C ./opam-repository-published fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository-published checkout ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 opam init --disable-sandboxing -k git -a ./opam-repository --bare - opam repository add --yes --all --set-default --kind=local published ./opam-repository-published for i in 1 2 3 4 5; do opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break echo "opam repo add failed, retry $i" sleep 5 done - opam update default published o1-labs + opam update default o1-labs opam switch create "4.14.0" opam switch "4.14.0" export OPAMERRLOGLEN=20 diff --git a/.github/workflows/explorer-gherkin.yaml b/.github/workflows/explorer-gherkin.yaml index 8c805925f6..4bd6c4ac23 100644 --- a/.github/workflows/explorer-gherkin.yaml +++ b/.github/workflows/explorer-gherkin.yaml @@ -14,6 +14,11 @@ jobs: explorer-gherkin: name: "Explorer Gherkin" runs-on: warp-ubuntu-latest-x64-16x + services: + nats: + image: nats:2-alpine + ports: + - 4222:4222 steps: - name: 📥 Checkout uses: actions/checkout@v5 @@ -61,25 +66,15 @@ jobs: getent hosts github.com || true sleep 5 done - git -C ./opam-repository fetch origin 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 - git -C ./opam-repository checkout 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 - for i in 1 2 3 4 5; do - rm -rf ./opam-repository-published - git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository-published && break - echo "clone failed, retry $i" - getent hosts github.com || true - sleep 5 - done - git -C ./opam-repository-published fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository-published checkout ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 opam init --disable-sandboxing -k git -a ./opam-repository --bare - opam repository add --yes --all --set-default --kind=local published ./opam-repository-published for i in 1 2 3 4 5; do opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break echo "opam repo add failed, retry $i" sleep 5 done - opam update default published o1-labs + opam update default o1-labs opam switch create "4.14.0" opam switch "4.14.0" export OPAMERRLOGLEN=20 @@ -104,12 +99,6 @@ jobs: key: zeko_build-${{ hashFiles('./opam.export') }} - name: 🧪 Run explorer Gherkin tests run: | - opam exec -- env -u DUNE_RPC dune runtest --profile=devnet \ - src/app/zeko/sequencer/explorer/tests - - name: 🧪 Run explorer NATS Gherkin tests - run: | - docker run --rm --name nats-sequencer-explorer-tests \ - -p 4222:4222 -d nats:2-alpine >/dev/null for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && break if [ "$i" -eq 30 ]; then @@ -119,8 +108,5 @@ jobs: sleep 1 done NATS_URL="nats://127.0.0.1:4222" \ - opam exec -- env -u DUNE_RPC dune exec --profile=devnet \ - src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe - - name: 🧹 Stop NATS - if: always() - run: docker rm -f nats-sequencer-explorer-tests >/dev/null 2>&1 || true + opam exec -- env -u DUNE_RPC dune runtest --profile=devnet \ + src/app/zeko/sequencer/explorer/tests diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index 84ceb0ae47..c59f688a6f 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -25,10 +25,12 @@ DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer ```bash dune build ./src/app/zeko/sequencer/tests/run-sequencer-test.sh {fake | real} -opam exec -- env -u DUNE_RPC dune runtest --profile=devnet src/app/zeko/sequencer/explorer/tests -NATS_URL="nats://127.0.0.1:4222" opam exec -- env -u DUNE_RPC dune exec --profile=devnet src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe +NATS_URL="nats://127.0.0.1:4222" opam exec -- env -u DUNE_RPC dune runtest --profile=devnet src/app/zeko/sequencer/explorer/tests ``` +The explorer Gherkin suite includes NATS-backed scenarios. `NATS_URL` must be +set and point at a running NATS server when running those tests outside CI. + ## Run Running the sequencer exposes the Graphql API on the port `-p`. The Graphql schema is a subset of the L1 Graphql API joined with the L1 Graphql API for fetching of actions/events. diff --git a/src/app/zeko/sequencer/explorer/tests/dune b/src/app/zeko/sequencer/explorer/tests/dune index a498b9196b..e39d4e5211 100644 --- a/src/app/zeko/sequencer/explorer/tests/dune +++ b/src/app/zeko/sequencer/explorer/tests/dune @@ -59,3 +59,8 @@ nats-client-async) (preprocess (pps ppx_let ppx_jane ppx_mina))) + +(rule + (alias runtest) + (action + (run ./explorer_nats_gherkin_tests.exe))) From 62b193e72ccbeb8fe299aee94ab963bc9db81d1c Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 03:17:56 +0700 Subject: [PATCH 28/51] Document explorer Gherkin harnesses --- .../zeko/sequencer/explorer/tests/readme.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/app/zeko/sequencer/explorer/tests/readme.md diff --git a/src/app/zeko/sequencer/explorer/tests/readme.md b/src/app/zeko/sequencer/explorer/tests/readme.md new file mode 100644 index 0000000000..958574ba16 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/readme.md @@ -0,0 +1,86 @@ +# Explorer Gherkin Tests + +These tests cover the explorer-facing behavior added by the sequencer NATS and +backfill work. The feature files stay in Gherkin so the behavior is readable, +while the OCaml harnesses execute those scenarios against the in-repo modules +and, where needed, a real NATS server. + +## Harnesses + +There are two harnesses for now. + +### Pure Gherkin harness + +File: + +- `explorer_gherkin_tests.ml` + +This is an inline-test library. It checks behavior that does not require an +external process: + +- explorer event payload and header contracts +- backfill GraphQL schema behavior +- GraphQL-SSE progress stream formatting +- sequencer replay genesis classification + +Run it directly with: + +```bash +opam exec -- env -u DUNE_RPC dune runtest --profile=devnet src/app/zeko/sequencer/explorer/tests +``` + +This command also runs the NATS harness if `NATS_URL` is present, because the +tests directory wires both harnesses into the Dune `runtest` alias. + +### NATS Gherkin harness + +File: + +- `explorer_nats_gherkin_tests.ml` + +This is a standalone Async executable because it connects to a real NATS server. +It checks that the shared explorer publisher paths produce messages that a real +subscriber receives with the expected subject, payload, and `Nats-Msg-Id` +header. + +Run it separately with: + +```bash +NATS_URL="nats://127.0.0.1:4222" \ + opam exec -- env -u DUNE_RPC dune exec --profile=devnet \ + src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.exe +``` + +Run the whole explorer suite with NATS enabled: + +```bash +NATS_URL="nats://127.0.0.1:4222" \ + opam exec -- env -u DUNE_RPC dune runtest --profile=devnet \ + src/app/zeko/sequencer/explorer/tests +``` + +The `Explorer Gherkin` CI workflow uses this full-suite command and provides +NATS through a GitHub Actions service container. + +## Adding Coverage + +Keep scenarios declarative: describe the explorer behavior, not the OCaml +implementation steps. Add one scenario per behavior. + +Use the pure harness for deterministic module-level behavior that does not need +external infrastructure. Use the NATS harness when the behavior depends on NATS +delivery, subjects, or headers. + +Process-level end-to-end coverage should be added as a third, slower Gherkin +harness rather than folded into the fast pure harness. That harness should boot +real services and assert behavior from the outside: + +- start the sequencer with `--nats-url`, submit a transaction through the + sequencer API, and assert a NATS subscriber receives `zeko.l2.transactions` +- start the backfill HTTP server with a DA fixture and real NATS, call the + GraphQL `backfill` mutation over HTTP, and assert both GraphQL-SSE progress + and replayed NATS messages + +The process-level harness should run in CI, but it should remain separate from +the fast harnesses so failures identify whether the break is in the contract, +NATS publishing, or full service orchestration. From 4b460ab4aafbd9a6d069534df4ca979d06c064a2 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 03:30:41 +0700 Subject: [PATCH 29/51] Rename explorer tests README --- .../explorer/tests/{readme.md => README.md} | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) rename src/app/zeko/sequencer/explorer/tests/{readme.md => README.md} (78%) diff --git a/src/app/zeko/sequencer/explorer/tests/readme.md b/src/app/zeko/sequencer/explorer/tests/README.md similarity index 78% rename from src/app/zeko/sequencer/explorer/tests/readme.md rename to src/app/zeko/sequencer/explorer/tests/README.md index 958574ba16..9ea3c0f426 100644 --- a/src/app/zeko/sequencer/explorer/tests/readme.md +++ b/src/app/zeko/sequencer/explorer/tests/README.md @@ -9,14 +9,14 @@ and, where needed, a real NATS server. There are two harnesses for now. -### Pure Gherkin harness +### Contract Gherkin harness File: - `explorer_gherkin_tests.ml` -This is an inline-test library. It checks behavior that does not require an -external process: +This is an inline-test library. It checks explorer contracts that do not +require an external process: - explorer event payload and header contracts - backfill GraphQL schema behavior @@ -29,10 +29,10 @@ Run it directly with: opam exec -- env -u DUNE_RPC dune runtest --profile=devnet src/app/zeko/sequencer/explorer/tests ``` -This command also runs the NATS harness if `NATS_URL` is present, because the -tests directory wires both harnesses into the Dune `runtest` alias. +This command also runs the NATS integration harness when the tests directory +wires both harnesses into the Dune `runtest` alias. -### NATS Gherkin harness +### NATS Integration Gherkin harness File: @@ -67,13 +67,13 @@ NATS through a GitHub Actions service container. Keep scenarios declarative: describe the explorer behavior, not the OCaml implementation steps. Add one scenario per behavior. -Use the pure harness for deterministic module-level behavior that does not need -external infrastructure. Use the NATS harness when the behavior depends on NATS -delivery, subjects, or headers. +Use the contract harness for deterministic module-level behavior that does not +need external infrastructure. Use the NATS integration harness when the behavior +depends on NATS delivery, subjects, or headers. Process-level end-to-end coverage should be added as a third, slower Gherkin -harness rather than folded into the fast pure harness. That harness should boot -real services and assert behavior from the outside: +harness rather than folded into the fast contract harness. That harness should +boot real services and assert behavior from the outside: - start the sequencer with `--nats-url`, submit a transaction through the sequencer API, and assert a NATS subscriber receives `zeko.l2.transactions` From 32dcd72b358905b3f9cf4cf8b22382f875bc2d81 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 03:40:21 +0700 Subject: [PATCH 30/51] Address explorer review feedback --- .github/actions/setup-zeko-ci/action.yaml | 88 ++++++++ .github/workflows/ci.yaml | 81 +------ .github/workflows/explorer-gherkin.yaml | 76 +------ README.md | 5 +- .../explorer/explorer_backfill_service.ml | 210 ++++++++++++------ .../sequencer/explorer/explorer_events.ml | 13 +- src/app/zeko/sequencer/lib/zeko_sequencer.ml | 37 ++- src/lib/graphql_wrapper/graphql_wrapper.ml | 7 +- 8 files changed, 275 insertions(+), 242 deletions(-) create mode 100644 .github/actions/setup-zeko-ci/action.yaml diff --git a/.github/actions/setup-zeko-ci/action.yaml b/.github/actions/setup-zeko-ci/action.yaml new file mode 100644 index 0000000000..c98ab8bc28 --- /dev/null +++ b/.github/actions/setup-zeko-ci/action.yaml @@ -0,0 +1,88 @@ +name: Setup Zeko CI +description: Install the shared Zeko CI toolchain and restore dependency/build caches. +inputs: + opam-cache-key: + description: Cache key for the opam switch cache. + required: true + build-cache-key: + description: Cache key for the dune build cache. + required: true +runs: + using: composite + steps: + - name: 🐹 Setup go + uses: actions/setup-go@v6 + with: + go-version: "1.19.11" + - name: 🐹 Build and install capnpc-go + shell: bash + run: | + tmpdir=$(mktemp -d) + cd "$tmpdir" + go mod init local/build + go get capnproto.org/go/capnp/v3@v3.0.0-alpha.5 + go build -o capnpc-go capnproto.org/go/capnp/v3/capnpc-go + sudo cp capnpc-go /usr/local/bin/ + cd - + - uses: dtolnay/rust-toolchain@stable + - name: 📥 Install OPAM + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y opam ocaml \ + libboost-dev \ + libboost-program-options-dev \ + libbz2-dev \ + libcap-dev \ + libffi-dev \ + libgflags-dev \ + libgmp-dev \ + libgmp3-dev \ + libjemalloc-dev \ + liblmdb-dev \ + liblmdb0 \ + libpq-dev \ + libsodium-dev \ + libssl-dev \ + pkg-config zlib1g-dev \ + capnproto \ + netcat-traditional + for i in 1 2 3 4 5; do + rm -rf ./opam-repository + git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository && break + echo "clone failed, retry $i" + getent hosts github.com || true + sleep 5 + done + git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 + opam init --disable-sandboxing -k git -a ./opam-repository --bare + for i in 1 2 3 4 5; do + opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break + echo "opam repo add failed, retry $i" + sleep 5 + done + opam update default o1-labs + opam switch create "4.14.0" + opam switch "4.14.0" + export OPAMERRLOGLEN=20 + export OPAMYES=1 + - name: 🧩 Get opam dependency cache + uses: WarpBuilds/cache@v1 + id: opam_dependencies + with: + path: ~/.opam + key: ${{ inputs.opam-cache-key }} + - name: 🏗️ Install opam dependencies + shell: bash + run: | + opam switch import opam.export --yes + eval "$(opam env)" + ./scripts/pin-external-packages.sh + opam clean --logs -cs --quiet + - name: 🧩 Get zeko build cache + uses: WarpBuilds/cache@v1 + id: zeko_build + with: + path: ./_build + key: ${{ inputs.build-cache-key }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 34591ca7f9..fe556e0d73 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,84 +18,11 @@ jobs: uses: actions/checkout@v5 with: submodules: recursive - # Prepare build environment(Go/OCaml/Rust) - - name: 🐹 Setup go - uses: actions/setup-go@v6 + - name: 🧰 Setup Zeko CI + uses: ./.github/actions/setup-zeko-ci with: - go-version: "1.19.11" - - name: 🐹 Build and install capnpc-go - run: | - tmpdir=$(mktemp -d) - cd "$tmpdir" - go mod init local/build - go get capnproto.org/go/capnp/v3@v3.0.0-alpha.5 - go build -o capnpc-go capnproto.org/go/capnp/v3/capnpc-go - sudo cp capnpc-go /usr/local/bin/ - cd - - - uses: dtolnay/rust-toolchain@stable - # Install OPAM and needed dependencies - - name: 📥 Install OPAM - run: | - sudo apt-get update - sudo apt-get install -y opam ocaml \ - libboost-dev \ - libboost-program-options-dev \ - libbz2-dev \ - libcap-dev \ - libffi-dev \ - libgflags-dev \ - libgmp-dev \ - libgmp3-dev \ - libjemalloc-dev \ - liblmdb-dev \ - liblmdb0 \ - libpq-dev \ - libsodium-dev \ - libssl-dev \ - pkg-config zlib1g-dev \ - capnproto \ - netcat-traditional - for i in 1 2 3 4 5; do - rm -rf ./opam-repository - git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository && break - echo "clone failed, retry $i" - getent hosts github.com || true - sleep 5 - done - git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 - opam init --disable-sandboxing -k git -a ./opam-repository --bare - for i in 1 2 3 4 5; do - opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break - echo "opam repo add failed, retry $i" - sleep 5 - done - opam update default o1-labs - opam switch create "4.14.0" - opam switch "4.14.0" - export OPAMERRLOGLEN=20 - export OPAMYES=1 - # Retrieve .opam from cache - - name: 🧩 Get opam dependency cache - uses: WarpBuilds/cache@v1 - id: opam_dependencies - with: - path: ~/.opam - key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} - # Install opam dependencies - - name: 🏗️ Install opam dependencies - run: | - opam switch import opam.export --yes - eval "$(opam env)" - ./scripts/pin-external-packages.sh - opam clean --logs -cs --quiet - # Retrieve _build dir from cache before initiating a build - - name: 🧩 Get zeko build cache - uses: WarpBuilds/cache@v1 - id: zeko_build - with: - path: ./_build - key: zeko_build-${{ hashFiles('./opam.export') }} + opam-cache-key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} + build-cache-key: zeko_build-${{ hashFiles('./opam.export') }} # Build! - name: 📦 Build zeko packages run: | diff --git a/.github/workflows/explorer-gherkin.yaml b/.github/workflows/explorer-gherkin.yaml index 4bd6c4ac23..5d824be349 100644 --- a/.github/workflows/explorer-gherkin.yaml +++ b/.github/workflows/explorer-gherkin.yaml @@ -24,79 +24,11 @@ jobs: uses: actions/checkout@v5 with: submodules: recursive - - name: 🐹 Setup go - uses: actions/setup-go@v6 + - name: 🧰 Setup Zeko CI + uses: ./.github/actions/setup-zeko-ci with: - go-version: "1.19.11" - - name: 🐹 Build and install capnpc-go - run: | - tmpdir=$(mktemp -d) - cd "$tmpdir" - go mod init local/build - go get capnproto.org/go/capnp/v3@v3.0.0-alpha.5 - go build -o capnpc-go capnproto.org/go/capnp/v3/capnpc-go - sudo cp capnpc-go /usr/local/bin/ - cd - - - uses: dtolnay/rust-toolchain@stable - - name: 📥 Install OPAM - run: | - sudo apt-get update - sudo apt-get install -y opam ocaml \ - libboost-dev \ - libboost-program-options-dev \ - libbz2-dev \ - libcap-dev \ - libffi-dev \ - libgflags-dev \ - libgmp-dev \ - libgmp3-dev \ - libjemalloc-dev \ - liblmdb-dev \ - liblmdb0 \ - libpq-dev \ - libsodium-dev \ - libssl-dev \ - pkg-config zlib1g-dev \ - capnproto \ - netcat-traditional - for i in 1 2 3 4 5; do - rm -rf ./opam-repository - git clone https://github.com/ocaml/opam-repository.git --depth 1 ./opam-repository && break - echo "clone failed, retry $i" - getent hosts github.com || true - sleep 5 - done - git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 - opam init --disable-sandboxing -k git -a ./opam-repository --bare - for i in 1 2 3 4 5; do - opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break - echo "opam repo add failed, retry $i" - sleep 5 - done - opam update default o1-labs - opam switch create "4.14.0" - opam switch "4.14.0" - export OPAMERRLOGLEN=20 - export OPAMYES=1 - - name: 🧩 Get opam dependency cache - uses: WarpBuilds/cache@v1 - id: opam_dependencies - with: - path: ~/.opam - key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} - - name: 🏗️ Install opam dependencies - run: | - opam switch import opam.export --yes - eval "$(opam env)" - ./scripts/pin-external-packages.sh - opam clean --logs -cs --quiet - - name: 🧩 Get zeko build cache - uses: WarpBuilds/cache@v1 - id: zeko_build - with: - path: ./_build - key: zeko_build-${{ hashFiles('./opam.export') }} + opam-cache-key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} + build-cache-key: zeko_build-${{ hashFiles('./opam.export') }} - name: 🧪 Run explorer Gherkin tests run: | for i in $(seq 1 30); do diff --git a/README.md b/README.md index 52da19c329..d638af20e9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Zeko [Zeko](https://twitter.com/ZekoLabs) is a fully isomorphic L2 for the Mina protocol ecosystem. -We support recursion in smart contracts natively, the exact same as Mina. +We support recursion in smart contracts natively. Ethereum deployment is under development, and will be a separate instance of Zeko. This repository is a fork of the [Mina codebase](https://github.com/MinaProtocol/mina). The Zeko related code is in the [`zeko`](./src/app/zeko) directory. The license for code under the `zeko` subdirectory is [custom](./src/app/zeko/LICENSE.md) -and is **NOT** the Apache license at the root of the repository. -The Apache license only applies to the original Mina source code. +though it will be soon updated to the same Apache license at the root of the repository. diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index f347e2b77f..9513f53ee1 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -87,6 +87,10 @@ let is_terminal = function | Queued | Running -> false +let terminal_job_retention = Time.Span.of_hr 1. + +let backfill_interval_size = 1000 + let genesis_hash = Da_layer.Diff.empty_ledger_hash ~depth:constraint_constants.ledger_depth @@ -151,6 +155,11 @@ let notify_subscribers job = in job.subscribers := if terminal then [] else subscribers +let remove_subscriber job subscriber = + job.subscribers := + List.filter !(job.subscribers) ~f:(fun subscriber' -> + not (phys_equal subscriber subscriber') ) + let update_job job ~status ?error ?started_at ?finished_at ?diffs_published () = job.status <- status ; Option.iter error ~f:(fun value -> job.error <- Some value) ; @@ -187,6 +196,24 @@ let shutdown t = let find_job t id = Hashtbl.find t.jobs id +let prune_terminal_jobs t = + let cutoff = Time.sub (Time.now ()) terminal_job_retention in + Hashtbl.filter_inplace t.jobs ~f:(fun job -> + (not (is_terminal job.status)) + || + match job.finished_at with + | None -> + true + | Some finished_at -> + Time.( > ) finished_at cutoff ) + +let find_active_job_by_range t ~from_hash ~to_hash = + Hashtbl.data t.jobs + |> List.find ~f:(fun job -> + (not (is_terminal job.status)) + && Ledger_hash.equal job.from_hash from_hash + && Ledger_hash.equal job.to_hash to_hash ) + let subscribe_progress t ~id = match find_job t id with | None -> @@ -194,8 +221,11 @@ let subscribe_progress t ~id = | Some job -> let reader, writer = Pipe.create () in let subscriber = create_subscriber writer in - if not (is_terminal job.status) then + if not (is_terminal job.status) then ( job.subscribers := subscriber :: !(job.subscribers) ; + don't_wait_for + ( Pipe.closed reader + >>| fun () -> remove_subscriber job subscriber ) ) ; ignore (enqueue_progress subscriber (progress_of_job job) ~terminal:(is_terminal job.status) : bool ) ; @@ -226,70 +256,119 @@ let publish_backfill_diff t ~from_hash ~index ~target_ledger_hash diff = | Some client -> publish_message_result client message +let source_hash = function + | `Genesis -> + genesis_hash + | `Specific ledger_hash -> + ledger_hash + +let backfill_intervals t ~source_ledger_hash ~target_ledger_hash = + let rec get_intervals ~target_ledger_hash = + let%bind.Deferred.Result chain = + Da_layer.Client.get_ledger_hashes_chain ~logger:t.logger + ~config:t.da_config ~max_length:backfill_interval_size + ~source_ledger_hash:(`Specific source_ledger_hash) ~target_ledger_hash + () + in + match chain with + | [] -> + return (Ok []) + | [ last ] -> + return (Ok [ (source_ledger_hash, last) ]) + | chain -> + let interval_source = List.hd_exn chain in + let interval_target = List.last_exn chain in + let%bind.Deferred.Result intervals = + get_intervals ~target_ledger_hash:interval_source + in + return (Ok ((interval_source, interval_target) :: intervals)) + in + get_intervals ~target_ledger_hash >>| Result.map ~f:List.rev + +let publish_target t job ~current_source ~index ~target_ledger_hash = + let%bind.Deferred.Result diff = + Da_layer.Client.get_diff ~logger:t.logger ~config:t.da_config + ~ledger_hash:target_ledger_hash + in + if + not + (Ledger_hash.equal + (Da_layer.Diff.Stable.V3.source_ledger_hash diff) + current_source ) + then + Deferred.return + (Error + (Error.of_string + "Backfill diff chain does not match requested ledger hash \ + progression") ) + else + match + publish_backfill_diff t ~from_hash:job.from_hash ~index ~target_ledger_hash + diff + with + | `Queued -> + update_job job ~status:Running + ~diffs_published:(job.diffs_published + 1) + () ; + Deferred.return (Ok target_ledger_hash) + | `Dropped -> + Deferred.return + (Error (Error.of_string "NATS publish dropped while backfilling diffs")) + let run_job t job = let now = Time.now () in update_job job ~status:Running ~started_at:now () ; let source = source_query job.from_hash in don't_wait_for (Monitor.try_with_or_error (fun () -> - let source_ledger_hash = - match source with - | `Genesis -> - genesis_hash - | `Specific ledger_hash -> - ledger_hash - in - let%bind.Deferred.Result target_ledger_hashes = - Da_layer.Client.get_ledger_hashes_chain ~logger:t.logger - ~config:t.da_config ~source_ledger_hash:source - ~target_ledger_hash:job.to_hash () + let source_ledger_hash = source_hash source in + let%bind.Deferred.Result intervals = + backfill_intervals t ~source_ledger_hash + ~target_ledger_hash:job.to_hash in - let rec publish_targets current_source index = function - | [] -> - if Ledger_hash.equal current_source job.to_hash - then Deferred.return (Ok ()) - else - Deferred.return - (Error - (Error.of_string - "Ledger hash chain ended before reaching backfill \ - target") ) - | target_ledger_hash :: rest -> - let%bind.Deferred.Result diff = - Da_layer.Client.get_diff ~logger:t.logger ~config:t.da_config - ~ledger_hash:target_ledger_hash - in - if - not - (Ledger_hash.equal - (Da_layer.Diff.Stable.V3.source_ledger_hash diff) - current_source ) - then - Deferred.return - (Error - (Error.of_string - "Backfill diff chain does not match requested ledger \ - hash progression") ) - else ( - match - publish_backfill_diff t ~from_hash:job.from_hash ~index - ~target_ledger_hash diff - with - | `Queued -> - update_job job ~status:Running - ~diffs_published:(job.diffs_published + 1) - () ; - publish_targets target_ledger_hash (index + 1) rest - | `Dropped -> - Deferred.return - (Error - (Error.of_string - "NATS publish dropped while backfilling diffs") ) ) + let index = ref 0 in + let%bind.Deferred.Result final_source = + Deferred.List.fold intervals ~init:(Ok source_ledger_hash) + ~f:(fun acc (interval_source, interval_target) -> + match acc with + | Error _ as error -> + return error + | Ok current_source -> + if not (Ledger_hash.equal current_source interval_source) + then + return + (Error + (Error.of_string + "Backfill intervals do not match requested ledger \ + hash progression") ) + else + let%bind.Deferred.Result target_ledger_hashes = + Da_layer.Client.get_ledger_hashes_chain ~logger:t.logger + ~config:t.da_config ~max_length:backfill_interval_size + ~source_ledger_hash:(`Specific interval_source) + ~target_ledger_hash:interval_target () + in + Deferred.List.fold target_ledger_hashes + ~init:(Ok interval_source) + ~f:(fun acc target_ledger_hash -> + match acc with + | Error _ as error -> + return error + | Ok current_source -> + let%map result = + publish_target t job ~current_source ~index:!index + ~target_ledger_hash + in + (match result with Ok _ -> incr index | Error _ -> ()) ; + result ) ) in - let%bind.Deferred.Result () = - publish_targets source_ledger_hash 0 target_ledger_hashes - in - Deferred.Result.return () ) + if Ledger_hash.equal final_source job.to_hash + then Deferred.Result.return () + else + Deferred.return + (Error + (Error.of_string + "Ledger hash chain ended before reaching backfill target") ) ) >>= function | Ok (Ok ()) -> update_job job ~status:Completed ~finished_at:(Time.now ()) () ; @@ -323,12 +402,17 @@ let register_job t job = job let start_backfill t ~from_hash ~to_hash = - let job = - create_job ~status:Queued ~from_hash ~to_hash () - in - let job = register_job t job in - run_job t job ; - job + prune_terminal_jobs t ; + match find_active_job_by_range t ~from_hash ~to_hash with + | Some job -> + job + | None -> + let job = + create_job ~status:Queued ~from_hash ~to_hash () + in + let job = register_job t job in + run_job t job ; + job let failed_job_snapshot_from_strings ~from_hash ~to_hash error = let now = Time.now () in diff --git a/src/app/zeko/sequencer/explorer/explorer_events.ml b/src/app/zeko/sequencer/explorer/explorer_events.ml index 15a7e00bbd..67f7db4204 100644 --- a/src/app/zeko/sequencer/explorer/explorer_events.ml +++ b/src/app/zeko/sequencer/explorer/explorer_events.ml @@ -55,10 +55,19 @@ let nats_msg_id target_ledger_hash = let nats_msg_id_headers target_ledger_hash = [ ("Nats-Msg-Id", nats_msg_id target_ledger_hash) ] -let create_nats_sink client : sink = +let create_nats_sink ?logger client : sink = fun { subject; headers; payload } -> let headers = Nats_client.Headers.of_list headers in - Nats_client_async.publish_json client ~subject ~headers payload + match + Nats_client_async.publish_result client ~subject ~headers + (Yojson.Safe.to_string payload) + with + | `Queued -> + () + | `Dropped -> + Option.iter logger ~f:(fun logger -> + [%log warn] "Dropped explorer NATS message" + ~metadata:[ ("subject", `String subject) ] ) let build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff = { subject = Subject.transactions diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index 496d736c7a..a175032709 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -75,7 +75,7 @@ module Sequencer = struct type t = { provers : Zeko_prover.Client.t ; da_client : Da_layer.Client.t - ; nats_client : Nats_client_async.client option + ; nats_sink : Explorer_events.sink ; executor : Executor.t ; config : Config.t ; sequencer_state : State.t @@ -139,7 +139,7 @@ module Sequencer = struct let process ({ da_client - ; nats_client + ; nats_sink ; provers ; executor ; config @@ -195,15 +195,8 @@ module Sequencer = struct in State.Last_committed_ledger.set sequencer_state ~data:new_inner_ledger ; - let sink = - match nats_client with - | Some client -> - Explorer_events.create_nats_sink client - | None -> - Explorer_events.noop_sink - in let () = - Explorer_events.publish_finality sink ~logger + Explorer_events.publish_finality nats_sink ~logger ~status:Explorer_events.Finality_status.Committed ~source_ledger_hash ~target_ledger_hash in @@ -258,6 +251,7 @@ module Sequencer = struct ; merger_ctx : Merger.Context.t ; da_client : Da_layer.Client.t ; nats_client : Nats_client_async.client option + ; nats_sink : Explorer_events.sink ; instance_id : string ; closed : unit Ivar.t ; apply_q : unit Sequencer.t @@ -309,24 +303,17 @@ module Sequencer = struct ; committed_ledger_hash = Field.zero } - let nats_sink = function - | Some client -> - Explorer_events.create_nats_sink client - | None -> - Explorer_events.noop_sink - let publish_transaction_event t ~kind ~target_ledger_hash ~genesis ~diff = - Explorer_events.publish_transaction (nats_sink t.nats_client) ~kind + Explorer_events.publish_transaction t.nats_sink ~kind ~target_ledger_hash ~genesis ~diff let publish_finality_event t ~status ~source_ledger_hash ~target_ledger_hash = - Explorer_events.publish_finality (nats_sink t.nats_client) ~logger:t.logger - ~status + Explorer_events.publish_finality t.nats_sink ~logger:t.logger ~status ~source_ledger_hash ~target_ledger_hash let publish_health_event t = let { State_hashes.unproved_ledger_hash; _ } = get_latest_state t in - Explorer_events.publish_health (nats_sink t.nats_client) ~logger:t.logger + Explorer_events.publish_health t.nats_sink ~logger:t.logger ~service:"sequencer-nats-publisher" ~instance_id:t.instance_id ~status:"ok" ~last_published_hash:(get_root t) ~unproved_hash:unproved_ledger_hash () @@ -1204,6 +1191,13 @@ module Sequencer = struct let%map client = Nats_client_async.connect (Some uri) in Some client in + let nats_sink = + match nats_client with + | None -> + Explorer_events.noop_sink + | Some client -> + Explorer_events.create_nats_sink ~logger client + in let kvdb = L.Db.zeko_kvdb ledger in let%bind provers = Zeko_prover.Client.create ~logger ~db_pool ~mq_host in let executor = @@ -1215,7 +1209,7 @@ module Sequencer = struct Merger.Context. { provers ; da_client - ; nats_client + ; nats_sink ; executor ; config ; sequencer_state = kvdb @@ -1236,6 +1230,7 @@ module Sequencer = struct ; config ; da_client ; nats_client + ; nats_sink ; instance_id = Uuid.to_string (Uuid_unix.create ()) ; bridge_prover = Bridge_prover.create ~provers ~proof_cache_db ; merger diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index ecfd4630e6..302a738454 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -200,14 +200,13 @@ module Make (Schema : Graphql_intf.Schema) = struct let obj ?doc name ~fields ~coerce ~split = let build_obj_json = arg_obj_to_json fields [] in let gql_server_fields = to_ocaml_graphql_server_args fields in + let to_json = Json.json_of_option @@ split build_obj_json in let arg_typ = Schema.Arg.obj name ?doc ~fields:gql_server_fields ~coerce in { arg_typ - ; to_json = Json.json_of_option @@ split build_obj_json - ; to_graphql_const = - (fun _ -> - failwith "GraphQL input object default values are not supported" ) + ; to_json + ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) } let non_null (arg_typ : _ arg_typ) = From 64fc302c81f1597ccf53d00697b99930e6205882 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 03:46:04 +0700 Subject: [PATCH 31/51] Add explorer E2E Gherkin harness --- .github/workflows/explorer-e2e.yaml | 43 +++ .../zeko/sequencer/explorer/tests/README.md | 56 ++-- src/app/zeko/sequencer/explorer/tests/dune | 26 ++ .../tests/explorer_e2e_gherkin_tests.ml | 257 ++++++++++++++++++ .../explorer/tests/features/e2e.feature | 12 + .../sequencer/tests/run-sequencer-test.sh | 29 +- src/app/zeko/sequencer/tests/test_spec.ml | 4 +- 7 files changed, 403 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/explorer-e2e.yaml create mode 100644 src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml create mode 100644 src/app/zeko/sequencer/explorer/tests/features/e2e.feature diff --git a/.github/workflows/explorer-e2e.yaml b/.github/workflows/explorer-e2e.yaml new file mode 100644 index 0000000000..79cbd6e692 --- /dev/null +++ b/.github/workflows/explorer-e2e.yaml @@ -0,0 +1,43 @@ +# Runs the slow explorer end-to-end Gherkin scenarios independently from the +# faster explorer integration suite and the existing sequencer integration run. +--- +name: Explorer E2E + +on: + push: + +concurrency: + group: "${{ github.workflow }} @ ${{ github.head_ref || github.ref }}" + cancel-in-progress: true + +jobs: + explorer-e2e: + name: "Explorer E2E" + runs-on: warp-ubuntu-latest-x64-16x + services: + nats: + image: nats:2-alpine + ports: + - 4222:4222 + steps: + - name: 📥 Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + - name: 🧰 Setup Zeko CI + uses: ./.github/actions/setup-zeko-ci + with: + opam-cache-key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} + build-cache-key: zeko_build-${{ hashFiles('./opam.export') }} + - name: 🧪 Run explorer E2E Gherkin tests + run: | + for i in $(seq 1 30); do + nc -z 127.0.0.1 4222 && break + if [ "$i" -eq 30 ]; then + echo "Timed out waiting for NATS" >&2 + exit 1 + fi + sleep 1 + done + NATS_URL="nats://127.0.0.1:4222" \ + ./src/app/zeko/sequencer/tests/run-sequencer-test.sh real 1 explorer-e2e diff --git a/src/app/zeko/sequencer/explorer/tests/README.md b/src/app/zeko/sequencer/explorer/tests/README.md index 9ea3c0f426..e48881eab2 100644 --- a/src/app/zeko/sequencer/explorer/tests/README.md +++ b/src/app/zeko/sequencer/explorer/tests/README.md @@ -5,18 +5,19 @@ backfill work. The feature files stay in Gherkin so the behavior is readable, while the OCaml harnesses execute those scenarios against the in-repo modules and, where needed, a real NATS server. -## Harnesses +## Explorer Gherkin Integration Suite -There are two harnesses for now. +There is one explorer Gherkin integration suite with two execution categories +for now. -### Contract Gherkin harness +### In-process scenarios File: - `explorer_gherkin_tests.ml` -This is an inline-test library. It checks explorer contracts that do not -require an external process: +This is an inline-test library. It checks behavior that does not require an +external broker or service process: - explorer event payload and header contracts - backfill GraphQL schema behavior @@ -29,10 +30,11 @@ Run it directly with: opam exec -- env -u DUNE_RPC dune runtest --profile=devnet src/app/zeko/sequencer/explorer/tests ``` -This command also runs the NATS integration harness when the tests directory -wires both harnesses into the Dune `runtest` alias. +This command also runs the broker-backed scenarios when `NATS_URL` points at a +running NATS server, because the tests directory wires both current entry points +into the Dune `runtest` alias. -### NATS Integration Gherkin harness +### Broker-backed scenarios File: @@ -67,13 +69,19 @@ NATS through a GitHub Actions service container. Keep scenarios declarative: describe the explorer behavior, not the OCaml implementation steps. Add one scenario per behavior. -Use the contract harness for deterministic module-level behavior that does not -need external infrastructure. Use the NATS integration harness when the behavior -depends on NATS delivery, subjects, or headers. +Use in-process scenarios for deterministic behavior that does not need external +infrastructure. Use broker-backed scenarios when the behavior depends on NATS +delivery, subjects, or headers. -Process-level end-to-end coverage should be added as a third, slower Gherkin -harness rather than folded into the fast contract harness. That harness should -boot real services and assert behavior from the outside: +## Process-level E2E Scenarios + +File: + +- `explorer_e2e_gherkin_tests.ml` + +Process-level end-to-end coverage is a separate, slower Gherkin entry point +rather than part of the fast integration suite. It should boot real services +and assert behavior from the outside where practical: - start the sequencer with `--nats-url`, submit a transaction through the sequencer API, and assert a NATS subscriber receives `zeko.l2.transactions` @@ -81,6 +89,20 @@ boot real services and assert behavior from the outside: GraphQL `backfill` mutation over HTTP, and assert both GraphQL-SSE progress and replayed NATS messages -The process-level harness should run in CI, but it should remain separate from -the fast harnesses so failures identify whether the break is in the contract, -NATS publishing, or full service orchestration. +The current E2E entry point reuses the existing sequencer integration service +setup: real L1 test ledger, DA nodes, signers, RabbitMQ, Postgres, and NATS. +The sequencer runtime is still driven through `Sequencer.create`, matching the +existing sequencer integration tests. A later follow-up can move this to a +fully external `run.exe` process once the bootstrap/deploy path is ready for +that shape. + +Run it with: + +```bash +NATS_URL="nats://127.0.0.1:4222" \ + ./src/app/zeko/sequencer/tests/run-sequencer-test.sh real 1 explorer-e2e +``` + +The E2E harness should remain separate from the faster integration suite so +failures identify whether the break is in the in-process behavior, NATS broker +delivery, or full service orchestration. diff --git a/src/app/zeko/sequencer/explorer/tests/dune b/src/app/zeko/sequencer/explorer/tests/dune index e39d4e5211..ca7552d8ef 100644 --- a/src/app/zeko/sequencer/explorer/tests/dune +++ b/src/app/zeko/sequencer/explorer/tests/dune @@ -60,6 +60,32 @@ (preprocess (pps ppx_let ppx_jane ppx_mina))) +(executable + (name explorer_e2e_gherkin_tests) + (modules explorer_e2e_gherkin_tests) + (libraries + explorer_feature_parser + explorer_events + explorer_backfill + sequencer_lib + test_spec + is_compile_simple_real_real + da_layer + zeko_constants + ;; mina ;; + mina_base + snark_params + ;; opam libraries ;; + yojson + core + core_kernel + async + async_unix + nats-client + nats-client-async) + (preprocess + (pps ppx_let ppx_jane ppx_mina))) + (rule (alias runtest) (action diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml new file mode 100644 index 0000000000..9f7e7fe38d --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml @@ -0,0 +1,257 @@ +(* Runs slow Gherkin-backed explorer E2E scenarios against the same external + services used by the sequencer integration test: L1 test ledger, DA nodes, + signers, RabbitMQ, Postgres, and a real NATS broker. *) + +open Core +open Async +open Mina_base +open Sequencer_lib +open Zeko_sequencer +open Test_spec +open Handle.Operator + +let logger = + Cli_lib.Stdout_log.setup false Logger.Level.Spam ; + Logger.create () + +let run = Thread_safe.block_on_async_exn + +let gql_uri = Uri.of_string "http://localhost:8080/graphql" + +let nats_url () = + match Sys.getenv "NATS_URL" with + | Some value -> + Uri.of_string value + | None -> + failwith "NATS_URL must be set for explorer E2E Gherkin tests" + +let da_config = + Da_layer.Client.Config.of_string_list [ "127.0.0.1:8555"; "127.0.0.1:8556" ] + +let da_keys = + run (fun () -> Da_layer.Client.Config.fetch_public_keys ~logger da_config) + +let da_quorum = 2 + +let mq_host = Host_and_port.of_string "localhost:5672" + +let slot_acceptance = Time.Span.of_min 60. + +let postgres_uri () = + run (fun () -> + Relational_db.For_tests.create_database ~port:5433 + "explorer_e2e_gherkin" ) + +let drop_postgres () = + run (fun () -> + Relational_db.For_tests.drop_database ~port:5433 + "explorer_e2e_gherkin" ) + +let shutdown_sequencer sequencer = + Gc.full_major () ; + run (fun () -> Sequencer.shutdown !sequencer) ; + Handle.invalidate sequencer + +let connect_or_fail uri = + Clock_ns.with_timeout (Time_ns.Span.of_sec 5.) + (Nats_client_async.connect (Some uri)) + >>= function + | `Timeout -> + failwithf "timed out connecting to %s" (Uri.to_string uri) () + | `Result client -> + return client + +let expect_ok label = function + | Ok value -> + value + | Error err -> + failwithf "%s failed: %s" label (Error.to_string_hum err) () + +let subscribe_transactions subscriber = + let%map subscription = + Nats_client_async.subscribe subscriber + ~subject:Explorer_events.Subject.transactions () + in + expect_ok "transaction subscription" subscription + +let wait_for_subscription_registration () = + Clock_ns.after (Time_ns.Span.of_ms 100.) + +let read_message label reader = + Clock_ns.with_timeout (Time_ns.Span.of_sec 10.) (Pipe.read reader) + >>= function + | `Timeout -> + failwithf "%s timed out waiting for a message" label () + | `Result `Eof -> + failwithf "%s closed before delivering a message" label () + | `Result (`Ok message) -> + return message + +let read_until label reader ~f = + let rec go remaining = + if remaining <= 0 + then failwithf "%s did not observe the expected message" label () ; + let%bind message = read_message label reader in + match f message with + | Some value -> + return value + | None -> + go (remaining - 1) + in + go 20 + +let headers message = + message.headers + |> Option.value ~default:Nats_client.Headers.empty + |> Nats_client.Headers.to_list + +let require_header message ~name ~expected = + match List.Assoc.find (headers message) ~equal:String.equal name with + | Some value when String.equal value expected -> + () + | Some value -> + failwithf "expected header %s=%s but received %s" name expected value () + | None -> + failwithf "missing header %s" name () + +let json_assoc key json = + match json with + | `Assoc fields -> + List.Assoc.find fields key ~equal:String.equal + | _ -> + None + +let json_assoc_exn key json = + Option.value_exn (json_assoc key json) + ~message:(sprintf "missing JSON field %s" key) + +let message_payload message = Yojson.Safe.from_string message.payload + +let assert_safe_json_equal actual expected = + if not (Yojson.Safe.equal actual expected) + then + failwithf "Expected %s but got %s" (Yojson.Safe.to_string expected) + (Yojson.Safe.to_string actual) () + +let target_hash_json target_ledger_hash = + Ledger_hash.to_yojson target_ledger_hash + +let target_hash_string target_ledger_hash = + Ledger_hash.to_decimal_string target_ledger_hash + +let create_sequencer_spec ~postgres_uri = + Quickcheck.random_value + (Sequencer_spec.gen ~logger ~number_of_transactions:1 ~postgres_uri + ~gql_uri ~da_config ~da_keys ~da_quorum ~mq_host ~slot_acceptance + ~nats_url:(nats_url ()) () ) + +let apply_first_transaction sequencer specs = + let spec = List.hd_exn specs in + let command = + User_command.Signed_command + (command_send ~chain:Zeko_circuits_config.Inputs.chain_l2 spec) + in + run (fun () -> + Sequencer.apply_user_command !sequencer command >>| Or_error.ok_exn) ; + Sequencer.get_root !sequencer + +let assert_user_command_message message ~target_ledger_hash = + if + not + (String.equal message.subject Explorer_events.Subject.transactions) + then + failwithf "unexpected subject %s" message.subject () ; + require_header message ~name:"Nats-Msg-Id" + ~expected:(target_hash_string target_ledger_hash) ; + let payload = message_payload message in + assert_safe_json_equal (json_assoc_exn "kind" payload) + (`String "user_command") ; + assert_safe_json_equal (json_assoc_exn "target_ledger_hash" payload) + (target_hash_json target_ledger_hash) + +let wait_for_backfill_completion job = + let rec go remaining = + if remaining <= 0 then failwith "backfill job did not complete" ; + if Explorer_backfill_service.is_terminal job.status + then return () + else Clock_ns.after (Time_ns.Span.of_ms 200.) >>= fun () -> go (remaining - 1) + in + go 100 + +let run_scenarios () = + Feature_parser.assert_scenario "e2e.feature" + "Sequencer-applied transactions are published to NATS" ; + Feature_parser.assert_scenario "e2e.feature" + "Backfill replays sequencer DA diffs to NATS" ; + let postgres_uri = postgres_uri () in + let nats_uri = nats_url () in + let subscriber = ref None in + let sequencer = ref None in + let backfill_service = ref None in + Exn.protect + ~finally:(fun () -> + Option.iter !backfill_service ~f:(fun service -> + run (fun () -> Explorer_backfill_service.shutdown service)) ; + Option.iter !sequencer ~f:(fun sequencer -> + ignore (shutdown_sequencer sequencer : (_, Handle.invalid) Handle.t)) ; + Option.iter !subscriber ~f:(fun client -> + run (fun () -> Nats_client_async.close client)) ; + drop_postgres ()) + ~f:(fun () -> + let client = run (fun () -> connect_or_fail nats_uri) in + subscriber := Some client ; + let live_subscription = + run (fun () -> + let%bind subscription = subscribe_transactions client in + let%map () = wait_for_subscription_registration () in + subscription) + in + let { Sequencer_spec.sequencer = sequencer_handle; specs; _ } = + create_sequencer_spec ~postgres_uri + in + sequencer := Some sequencer_handle ; + let target_ledger_hash = apply_first_transaction sequencer_handle specs in + let live_message = + run (fun () -> + read_message "sequencer publish" live_subscription.messages) + in + assert_user_command_message live_message ~target_ledger_hash ; + let backfill_subscription = + run (fun () -> + let%bind subscription = subscribe_transactions client in + let%map () = wait_for_subscription_registration () in + subscription) + in + let service = + run (fun () -> + Explorer_backfill_service.create ~logger ~da_config + ~nats_url:nats_uri) + in + backfill_service := Some service ; + let job = + Explorer_backfill_service.start_backfill service + ~from_hash:Explorer_backfill_service.genesis_hash + ~to_hash:target_ledger_hash + in + run (fun () -> wait_for_backfill_completion job) ; + let replay_payload = + run (fun () -> + read_until "backfill replay" backfill_subscription.messages + ~f:(fun message -> + let payload = message_payload message in + match json_assoc "target_ledger_hash" payload with + | Some target + when Yojson.Safe.equal target + (target_hash_json target_ledger_hash) -> + require_header message ~name:"Nats-Msg-Id" + ~expected:(target_hash_string target_ledger_hash) ; + Some payload + | _ -> + None)) + in + assert_safe_json_equal (json_assoc_exn "kind" replay_payload) + (`String "sync_replay") ; + printf "explorer E2E Gherkin scenarios passed against %s\n" + (Uri.to_string nats_uri)) + +let () = run_scenarios () diff --git a/src/app/zeko/sequencer/explorer/tests/features/e2e.feature b/src/app/zeko/sequencer/explorer/tests/features/e2e.feature new file mode 100644 index 0000000000..8d91c4357e --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/features/e2e.feature @@ -0,0 +1,12 @@ +Feature: Explorer End-to-End Publishing + + Scenario: Sequencer-applied transactions are published to NATS + Given the Zeko integration services are running with a NATS broker + When the sequencer applies a user transaction + Then an explorer subscriber receives a "user_command" transaction event + And the transaction event includes the target ledger hash as "Nats-Msg-Id" + + Scenario: Backfill replays sequencer DA diffs to NATS + Given a sequencer-applied transaction has been stored in the DA layer + When the backfill service replays from genesis to that transaction + Then an explorer subscriber receives a replay event for that target ledger hash diff --git a/src/app/zeko/sequencer/tests/run-sequencer-test.sh b/src/app/zeko/sequencer/tests/run-sequencer-test.sh index a5fd6f6d70..7ad764e9b5 100755 --- a/src/app/zeko/sequencer/tests/run-sequencer-test.sh +++ b/src/app/zeko/sequencer/tests/run-sequencer-test.sh @@ -2,16 +2,17 @@ set -euo pipefail usage() { - echo "Usage: $0 " >&2 + echo "Usage: $0 [explorer-e2e]" >&2 exit 1 } -if [ "$#" -ne 2 ]; then +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then usage fi MODE="$1" NUM_PROVERS="$2" +TEST_TARGET="${3:-sequencer}" PROVER_PIDS=() SIGNER_PIDS=() PROVERS=() @@ -29,6 +30,14 @@ if ! [[ "$NUM_PROVERS" =~ ^[1-9][0-9]*$ ]]; then usage fi +case "$TEST_TARGET" in +sequencer | explorer-e2e) ;; +*) + echo "Error: optional third argument must be 'explorer-e2e'" >&2 + usage + ;; +esac + cleanup() { local exit_status=$1 echo "Cleaning up..." @@ -46,13 +55,21 @@ SEQUENCER_ROOT="$(git rev-parse --show-toplevel)/src/app/zeko/sequencer" SEQUENCER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/sequencer" SIGNER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/signer" +if [ "$TEST_TARGET" = "explorer-e2e" ]; then + TEST_DUNE_TARGET="src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.exe" +else + TEST_DUNE_TARGET="src/app/zeko/sequencer/tests/sequencer_test.exe" + if [ "$MODE" = "fake" ]; then + TEST_DUNE_TARGET="src/app/zeko/sequencer/tests/sequencer_test_fake.exe" + fi +fi + opam exec -- env -u DUNE_RPC dune build \ src/app/zeko/sequencer/tests/testing_ledger/run.exe \ src/app/zeko/da_layer/cli.exe \ src/app/zeko/sequencer/prover/cli.exe \ src/app/zeko/sequencer/prover/cli_fake.exe \ - src/app/zeko/sequencer/tests/sequencer_test.exe \ - src/app/zeko/sequencer/tests/sequencer_test_fake.exe + "$TEST_DUNE_TARGET" export ZEKO_SIGNATURE_KIND=testnet export ZEKO_CIRCUITS_CONFIG=test @@ -168,7 +185,9 @@ wait_for_port 8557 $da3_pid echo "All services started successfully" -if [ "$MODE" = "fake" ]; then +if [ "$TEST_TARGET" = "explorer-e2e" ]; then + ZEKO_CIRCUITS_MODE=$MODE "$SEQUENCER_BUILD_ROOT/explorer/tests/explorer_e2e_gherkin_tests.exe" +elif [ "$MODE" = "fake" ]; then ZEKO_CIRCUITS_MODE=$MODE $SEQUENCER_BUILD_ROOT/tests/sequencer_test_fake.exe "${PROVERS[@]}" else ZEKO_CIRCUITS_MODE=$MODE $SEQUENCER_BUILD_ROOT/tests/sequencer_test.exe "${PROVERS[@]}" diff --git a/src/app/zeko/sequencer/tests/test_spec.ml b/src/app/zeko/sequencer/tests/test_spec.ml index 091533c1f5..fcb33057fc 100644 --- a/src/app/zeko/sequencer/tests/test_spec.ml +++ b/src/app/zeko/sequencer/tests/test_spec.ml @@ -369,7 +369,7 @@ module Sequencer_spec = struct ; l1_config : Utils.Slot.l1_config } - let gen ?(delay_deposit = 0) ?(number_of_transactions = 5) ?db_dir + let gen ?(delay_deposit = 0) ?(number_of_transactions = 5) ?db_dir ?nats_url ?checkpoints_dir ?(commit_validity_period = Global_slot_span.of_int 10) ~logger ~postgres_uri ~gql_uri ~da_config ~da_keys ~da_quorum ~mq_host ~slot_acceptance () = @@ -522,7 +522,7 @@ module Sequencer_spec = struct let sequencer = run (fun () -> - Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 + Sequencer.create ?nats_url ~logger ~max_pool_size:10 ~commitment_period_sec:0. ~da_config ~da_keys ~da_quorum ~db_dir ~postgres_uri ~l1_uri:gql_uri ~archive_uri:gql_uri From 5c9ceb424c05dc7e7c8c26a6d3ec7eb3669ae954 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 05:00:50 +0700 Subject: [PATCH 32/51] Restore GraphQL object default handling --- src/lib/graphql_wrapper/graphql_wrapper.ml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index 302a738454..ecfd4630e6 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -200,13 +200,14 @@ module Make (Schema : Graphql_intf.Schema) = struct let obj ?doc name ~fields ~coerce ~split = let build_obj_json = arg_obj_to_json fields [] in let gql_server_fields = to_ocaml_graphql_server_args fields in - let to_json = Json.json_of_option @@ split build_obj_json in let arg_typ = Schema.Arg.obj name ?doc ~fields:gql_server_fields ~coerce in { arg_typ - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = Json.json_of_option @@ split build_obj_json + ; to_graphql_const = + (fun _ -> + failwith "GraphQL input object default values are not supported" ) } let non_null (arg_typ : _ arg_typ) = From c34582cf0a583e20e02a4276749a7122d58436f2 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 05:11:00 +0700 Subject: [PATCH 33/51] Fix explorer E2E NATS message typing --- .../explorer/tests/explorer_e2e_gherkin_tests.ml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml index 9f7e7fe38d..bdd888bc93 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml @@ -100,12 +100,12 @@ let read_until label reader ~f = in go 20 -let headers message = +let headers (message : Nats_client.Protocol.message) = message.headers |> Option.value ~default:Nats_client.Headers.empty |> Nats_client.Headers.to_list -let require_header message ~name ~expected = +let require_header (message : Nats_client.Protocol.message) ~name ~expected = match List.Assoc.find (headers message) ~equal:String.equal name with | Some value when String.equal value expected -> () @@ -125,7 +125,8 @@ let json_assoc_exn key json = Option.value_exn (json_assoc key json) ~message:(sprintf "missing JSON field %s" key) -let message_payload message = Yojson.Safe.from_string message.payload +let message_payload (message : Nats_client.Protocol.message) = + Yojson.Safe.from_string message.payload let assert_safe_json_equal actual expected = if not (Yojson.Safe.equal actual expected) @@ -155,7 +156,8 @@ let apply_first_transaction sequencer specs = Sequencer.apply_user_command !sequencer command >>| Or_error.ok_exn) ; Sequencer.get_root !sequencer -let assert_user_command_message message ~target_ledger_hash = +let assert_user_command_message + (message : Nats_client.Protocol.message) ~target_ledger_hash = if not (String.equal message.subject Explorer_events.Subject.transactions) From 6c2ee87be600587bfdadfc7bf6dddf17e7cdfc3f Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 05:17:45 +0700 Subject: [PATCH 34/51] Fix explorer E2E backfill job typing --- .../zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml index bdd888bc93..094c28c97c 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml @@ -171,7 +171,7 @@ let assert_user_command_message assert_safe_json_equal (json_assoc_exn "target_ledger_hash" payload) (target_hash_json target_ledger_hash) -let wait_for_backfill_completion job = +let wait_for_backfill_completion (job : Explorer_backfill_service.job) = let rec go remaining = if remaining <= 0 then failwith "backfill job did not complete" ; if Explorer_backfill_service.is_terminal job.status From a95b8c858aa27dce9bd8feb279bcaf130e122708 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 05:24:31 +0700 Subject: [PATCH 35/51] Fix explorer E2E cleanup refs --- .../sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml index 094c28c97c..51b7dfdc9b 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml @@ -192,11 +192,11 @@ let run_scenarios () = let backfill_service = ref None in Exn.protect ~finally:(fun () -> - Option.iter !backfill_service ~f:(fun service -> + Option.iter backfill_service.contents ~f:(fun service -> run (fun () -> Explorer_backfill_service.shutdown service)) ; - Option.iter !sequencer ~f:(fun sequencer -> + Option.iter sequencer.contents ~f:(fun sequencer -> ignore (shutdown_sequencer sequencer : (_, Handle.invalid) Handle.t)) ; - Option.iter !subscriber ~f:(fun client -> + Option.iter subscriber.contents ~f:(fun client -> run (fun () -> Nats_client_async.close client)) ; drop_postgres ()) ~f:(fun () -> From 9a1c39b265f21691114255a50eb86dfede8a9ea6 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 05:33:57 +0700 Subject: [PATCH 36/51] Wait for matching explorer E2E events --- .../tests/explorer_e2e_gherkin_tests.ml | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml index 51b7dfdc9b..39f60f0e20 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.ml @@ -140,6 +140,23 @@ let target_hash_json target_ledger_hash = let target_hash_string target_ledger_hash = Ledger_hash.to_decimal_string target_ledger_hash +let matching_transaction_payload + (message : Nats_client.Protocol.message) ~kind ~target_ledger_hash = + if not (String.equal message.subject Explorer_events.Subject.transactions) + then None + else + let payload = message_payload message in + match + ( json_assoc "kind" payload + , json_assoc "target_ledger_hash" payload ) + with + | Some (`String actual_kind), Some target + when String.equal actual_kind kind + && Yojson.Safe.equal target (target_hash_json target_ledger_hash) -> + Some payload + | _ -> + None + let create_sequencer_spec ~postgres_uri = Quickcheck.random_value (Sequencer_spec.gen ~logger ~number_of_transactions:1 ~postgres_uri @@ -215,7 +232,12 @@ let run_scenarios () = let target_ledger_hash = apply_first_transaction sequencer_handle specs in let live_message = run (fun () -> - read_message "sequencer publish" live_subscription.messages) + read_until "sequencer publish" live_subscription.messages + ~f:(fun message -> + Option.map + (matching_transaction_payload message ~kind:"user_command" + ~target_ledger_hash ) + ~f:(fun _payload -> message))) in assert_user_command_message live_message ~target_ledger_hash ; let backfill_subscription = @@ -240,15 +262,15 @@ let run_scenarios () = run (fun () -> read_until "backfill replay" backfill_subscription.messages ~f:(fun message -> - let payload = message_payload message in - match json_assoc "target_ledger_hash" payload with - | Some target - when Yojson.Safe.equal target - (target_hash_json target_ledger_hash) -> + match + matching_transaction_payload message ~kind:"sync_replay" + ~target_ledger_hash + with + | Some payload -> require_header message ~name:"Nats-Msg-Id" ~expected:(target_hash_string target_ledger_hash) ; Some payload - | _ -> + | None -> None)) in assert_safe_json_equal (json_assoc_exn "kind" replay_payload) From f8c6bf214001bf727eeb4f577ed1d63d2a05af50 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sat, 18 Apr 2026 10:50:52 +0700 Subject: [PATCH 37/51] Add explorer debug logs --- .../explorer/explorer_backfill_service.ml | 73 ++++++++++++++++++- src/app/zeko/sequencer/lib/zeko_sequencer.ml | 23 ++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index 9513f53ee1..ea6962a4f7 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -179,6 +179,8 @@ let source_query from_hash = let create ~logger ~da_config ~nats_url = let%map nats_client = Nats_client_async.connect (Some nats_url) in + [%log debug] "Explorer backfill service connected to NATS: url=%s" + (Uri.to_string nats_url) ; { logger ; da_config ; nats_client = Some nats_client @@ -192,6 +194,9 @@ let shutdown t = | None -> Deferred.unit | Some client -> + let logger = t.logger in + [%log debug] "Shutting down explorer backfill NATS client: instance_id=%s" + t.instance_id ; Nats_client_async.close client let find_job t id = Hashtbl.find t.jobs id @@ -244,17 +249,36 @@ let publish_message_result client ({ subject; headers; payload } : (Yojson.Safe.to_string payload) let publish_backfill_diff t ~from_hash ~index ~target_ledger_hash diff = + let logger = t.logger in let genesis = is_genesis_hash from_hash && Int.equal index 0 in + let kind = backfill_kind ~from_hash ~index in + [%log debug] + "Publishing explorer backfill transaction event: subject=%s kind=%s \ + from_hash=%s target_ledger_hash=%s index=%d genesis=%b" + Explorer_events.Subject.transactions + (Explorer_events.Transaction_kind.to_string kind) + (Ledger_hash.to_decimal_string from_hash) + (Ledger_hash.to_decimal_string target_ledger_hash) + index genesis ; let message = Explorer_events.build_transaction_message - ~kind:(backfill_kind ~from_hash ~index) - ~target_ledger_hash ~genesis ~diff + ~kind ~target_ledger_hash ~genesis ~diff in match t.nats_client with | None -> + [%log debug] + "Dropped explorer backfill transaction event: no NATS client \ + target_ledger_hash=%s" + (Ledger_hash.to_decimal_string target_ledger_hash) ; `Dropped | Some client -> - publish_message_result client message + let result = publish_message_result client message in + [%log debug] + "Explorer backfill transaction event publish result: \ + target_ledger_hash=%s result=%s" + (Ledger_hash.to_decimal_string target_ledger_hash) + (Format.asprintf "%a" Nats_client_async.pp_publish_result result) ; + result let source_hash = function | `Genesis -> @@ -286,6 +310,14 @@ let backfill_intervals t ~source_ledger_hash ~target_ledger_hash = get_intervals ~target_ledger_hash >>| Result.map ~f:List.rev let publish_target t job ~current_source ~index ~target_ledger_hash = + let logger = t.logger in + [%log debug] + "Backfill fetching diff before explorer publish: job_id=%s \ + current_source=%s target_ledger_hash=%s index=%d" + job.id + (Ledger_hash.to_decimal_string current_source) + (Ledger_hash.to_decimal_string target_ledger_hash) + index ; let%bind.Deferred.Result diff = Da_layer.Client.get_diff ~logger:t.logger ~config:t.da_config ~ledger_hash:target_ledger_hash @@ -310,13 +342,30 @@ let publish_target t job ~current_source ~index ~target_ledger_hash = update_job job ~status:Running ~diffs_published:(job.diffs_published + 1) () ; + [%log debug] + "Backfill queued explorer transaction event: job_id=%s \ + target_ledger_hash=%s diffs_published=%d" + job.id + (Ledger_hash.to_decimal_string target_ledger_hash) + job.diffs_published ; Deferred.return (Ok target_ledger_hash) | `Dropped -> + [%log debug] + "Backfill failed to queue explorer transaction event: job_id=%s \ + target_ledger_hash=%s" + job.id + (Ledger_hash.to_decimal_string target_ledger_hash) ; Deferred.return (Error (Error.of_string "NATS publish dropped while backfilling diffs")) let run_job t job = + let logger = t.logger in let now = Time.now () in + [%log debug] + "Starting explorer backfill job: job_id=%s from_hash=%s to_hash=%s" + job.id + (Ledger_hash.to_decimal_string job.from_hash) + (Ledger_hash.to_decimal_string job.to_hash) ; update_job job ~status:Running ~started_at:now () ; let source = source_query job.from_hash in don't_wait_for @@ -371,9 +420,15 @@ let run_job t job = "Ledger hash chain ended before reaching backfill target") ) ) >>= function | Ok (Ok ()) -> + [%log debug] + "Completed explorer backfill job: job_id=%s diffs_published=%d" + job.id job.diffs_published ; update_job job ~status:Completed ~finished_at:(Time.now ()) () ; Deferred.unit | Ok (Error error) | Error error -> + [%log debug] + "Failed explorer backfill job: job_id=%s error=%s" + job.id (Error.to_string_hum error) ; update_job job ~status:Failed ~finished_at:(Time.now ()) ~error:(Error.to_string_hum error) () ; Deferred.unit ) @@ -397,14 +452,26 @@ let create_job ?error ?started_at ?finished_at ~status ~from_hash ~to_hash () = } let register_job t job = + let logger = t.logger in Hashtbl.set t.jobs ~key:job.id ~data:job ; + [%log debug] + "Registered explorer backfill job: job_id=%s from_hash=%s to_hash=%s" + job.id + (Ledger_hash.to_decimal_string job.from_hash) + (Ledger_hash.to_decimal_string job.to_hash) ; notify_subscribers job ; job let start_backfill t ~from_hash ~to_hash = + let logger = t.logger in prune_terminal_jobs t ; match find_active_job_by_range t ~from_hash ~to_hash with | Some job -> + [%log debug] + "Reusing active explorer backfill job: job_id=%s from_hash=%s to_hash=%s" + job.id + (Ledger_hash.to_decimal_string from_hash) + (Ledger_hash.to_decimal_string to_hash) ; job | None -> let job = diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index a175032709..b075ce4984 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -304,15 +304,38 @@ module Sequencer = struct } let publish_transaction_event t ~kind ~target_ledger_hash ~genesis ~diff = + let logger = t.logger in + [%log debug] + "Publishing explorer transaction event: subject=%s kind=%s \ + target_ledger_hash=%s genesis=%b" + Explorer_events.Subject.transactions + (Explorer_events.Transaction_kind.to_string kind) + (Ledger_hash.to_decimal_string target_ledger_hash) + genesis ; Explorer_events.publish_transaction t.nats_sink ~kind ~target_ledger_hash ~genesis ~diff let publish_finality_event t ~status ~source_ledger_hash ~target_ledger_hash = + let logger = t.logger in + [%log debug] + "Publishing explorer finality event: subject=%s status=%s \ + source_ledger_hash=%s target_ledger_hash=%s" + Explorer_events.Subject.finality + (Explorer_events.Finality_status.to_string status) + (Ledger_hash.to_decimal_string source_ledger_hash) + (Ledger_hash.to_decimal_string target_ledger_hash) ; Explorer_events.publish_finality t.nats_sink ~logger:t.logger ~status ~source_ledger_hash ~target_ledger_hash let publish_health_event t = + let logger = t.logger in let { State_hashes.unproved_ledger_hash; _ } = get_latest_state t in + [%log debug] + "Publishing explorer health event: subject=%s service=%s \ + last_published_hash=%s unproved_hash=%s" + Explorer_events.Subject.health "sequencer-nats-publisher" + (Ledger_hash.to_decimal_string (get_root t)) + (Ledger_hash.to_decimal_string unproved_ledger_hash) ; Explorer_events.publish_health t.nats_sink ~logger:t.logger ~service:"sequencer-nats-publisher" ~instance_id:t.instance_id ~status:"ok" ~last_published_hash:(get_root t) From e357c7de881f183ba81263be65c7576b981668c8 Mon Sep 17 00:00:00 2001 From: Heb Date: Thu, 14 May 2026 22:13:52 +0700 Subject: [PATCH 38/51] feat: implement Zeko JetStream publish contract (#407) --- .github/actions/setup-zeko-ci/action.yaml | 11 +- .github/workflows/ci.yaml | 1 + .github/workflows/explorer-e2e.yaml | 2 + .github/workflows/explorer-gherkin.yaml | 2 + opam.export | 8 +- src/app/zeko/sequencer/README.md | 21 +- .../sequencer/explorer/MESSAGE_CONTRACT.md | 155 ++++++++ src/app/zeko/sequencer/explorer/dune | 2 + .../explorer/explorer-indexing-plan.md | 28 +- .../explorer/explorer_backfill_service.ml | 65 ++-- .../sequencer/explorer/explorer_events.ml | 363 +++++++++++++++++- .../zeko/sequencer/explorer/tests/README.md | 8 +- .../explorer/tests/explorer_gherkin_tests.ml | 273 +++++++------ .../tests/explorer_nats_gherkin_tests.ml | 92 +++++ .../tests/features/event-contract.feature | 23 +- .../tests/features/nats-integration.feature | 5 + src/app/zeko/sequencer/lib/zeko_sequencer.ml | 3 +- .../sequencer/tests/run-sequencer-test.sh | 33 +- 18 files changed, 904 insertions(+), 191 deletions(-) create mode 100644 src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md diff --git a/.github/actions/setup-zeko-ci/action.yaml b/.github/actions/setup-zeko-ci/action.yaml index c98ab8bc28..692975b01f 100644 --- a/.github/actions/setup-zeko-ci/action.yaml +++ b/.github/actions/setup-zeko-ci/action.yaml @@ -54,17 +54,22 @@ runs: getent hosts github.com || true sleep 5 done - git -C ./opam-repository fetch origin ba1ca7509cb2617776f017673de0f2a48be67105 - git -C ./opam-repository checkout ba1ca7509cb2617776f017673de0f2a48be67105 + git -C ./opam-repository fetch origin 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 + git -C ./opam-repository checkout 08d8c16c16dc6b23a5278b06dff0ac6c7a217356 opam init --disable-sandboxing -k git -a ./opam-repository --bare for i in 1 2 3 4 5; do opam repository add --yes --all --set-default o1-labs https://github.com/o1-labs/opam-repository.git && break echo "opam repo add failed, retry $i" sleep 5 done - opam update default o1-labs + for i in 1 2 3 4 5; do + opam repository add --yes --all --set-default --rank=-1 opam-latest https://github.com/ocaml/opam-repository.git && break + echo "opam repo add failed, retry $i" + sleep 5 + done opam switch create "4.14.0" opam switch "4.14.0" + opam repository set-repos --yes --this-switch o1-labs default opam-latest export OPAMERRLOGLEN=20 export OPAMYES=1 - name: 🧩 Get opam dependency cache diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fe556e0d73..78718b1cdb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,6 +20,7 @@ jobs: submodules: recursive - name: 🧰 Setup Zeko CI uses: ./.github/actions/setup-zeko-ci + timeout-minutes: 20 with: opam-cache-key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} build-cache-key: zeko_build-${{ hashFiles('./opam.export') }} diff --git a/.github/workflows/explorer-e2e.yaml b/.github/workflows/explorer-e2e.yaml index 79cbd6e692..5e608bf4cc 100644 --- a/.github/workflows/explorer-e2e.yaml +++ b/.github/workflows/explorer-e2e.yaml @@ -17,6 +17,7 @@ jobs: services: nats: image: nats:2-alpine + command: -js ports: - 4222:4222 steps: @@ -26,6 +27,7 @@ jobs: submodules: recursive - name: 🧰 Setup Zeko CI uses: ./.github/actions/setup-zeko-ci + timeout-minutes: 20 with: opam-cache-key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} build-cache-key: zeko_build-${{ hashFiles('./opam.export') }} diff --git a/.github/workflows/explorer-gherkin.yaml b/.github/workflows/explorer-gherkin.yaml index 5d824be349..025a2eaf2f 100644 --- a/.github/workflows/explorer-gherkin.yaml +++ b/.github/workflows/explorer-gherkin.yaml @@ -17,6 +17,7 @@ jobs: services: nats: image: nats:2-alpine + command: -js ports: - 4222:4222 steps: @@ -26,6 +27,7 @@ jobs: submodules: recursive - name: 🧰 Setup Zeko CI uses: ./.github/actions/setup-zeko-ci + timeout-minutes: 20 with: opam-cache-key: zeko_opam_dependencies-${{ hashFiles('./opam.export') }} build-cache-key: zeko_build-${{ hashFiles('./opam.export') }} diff --git a/opam.export b/opam.export index 5c097d17f0..80929b2f49 100644 --- a/opam.export +++ b/opam.export @@ -30,8 +30,8 @@ roots: [ "lmdb.1.0" "menhir.20210419" "merlin.4.18-414" - "nats-client.0.0.7" - "nats-client-async.0.0.7" + "nats-client.0.0.10" + "nats-client-async.0.0.10" "ocaml-base-compiler.4.14.0" "ocamlformat.0.20.1" "ocamlgraph.1.8.8" @@ -190,8 +190,8 @@ installed: [ "mirage-crypto-rng.0.11.0" "mirage-crypto-rng-async.0.11.0" "mmap.1.1.0" - "nats-client.0.0.7" - "nats-client-async.0.0.7" + "nats-client.0.0.10" + "nats-client-async.0.0.10" "num.1.1" "ocaml.4.14.0" "ocaml-base-compiler.4.14.0" diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index c59f688a6f..71f731bb97 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -29,7 +29,9 @@ NATS_URL="nats://127.0.0.1:4222" opam exec -- env -u DUNE_RPC dune runtest --pro ``` The explorer Gherkin suite includes NATS-backed scenarios. `NATS_URL` must be -set and point at a running NATS server when running those tests outside CI. +set and point at a running NATS server with JetStream enabled when running +those tests outside CI. The message contract is documented in +`src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md`. ## Run @@ -85,6 +87,10 @@ transaction message includes `Nats-Msg-Id: `. - `target_ledger_hash` - `timestamp` +Every finality message includes +`Nats-Msg-Id: finality--`, for example +`finality-proved-...` or `finality-committed-...`. + `zeko.health` carries: - `service` @@ -94,6 +100,12 @@ transaction message includes `Nats-Msg-Id: `. - `unproved_hash` - `timestamp` +When NATS is configured, the sequencer startup path best-effort configures the +Zeko-owned JetStream stream `ZEKO_L2` for `zeko.l2.transactions`, +`zeko.l2.finality`, and `zeko.health`. If NATS is down or JetStream setup fails, +startup logs a warning and explorer publishing stays disabled or drops messages +rather than crashing the sequencer. + Run help to see the options: ```bash @@ -128,7 +140,9 @@ the same port. The backfill GraphQL HTTP endpoint stays on `/graphql`. The service requires `--nats-url` because each backfill republishes historical -diffs into NATS. +diffs into NATS. Unlike the live sequencer publisher, the standalone backfill +service fails startup when it cannot connect to JetStream, and a dropped publish +marks the backfill job as failed. Available GraphQL operations: @@ -177,7 +191,8 @@ The explorer-specific tests now live under `src/app/zeko/sequencer/explorer/tests/` and use copied `.feature` files from the explorer spike as the main test inventory. CI runs them in the dedicated Explorer Gherkin workflow, separate from the longer sequencer integration flow. -The real-NATS scenarios expect `NATS_URL` to point at a running NATS server. +The real-NATS scenarios expect `NATS_URL` to point at a running NATS server +with JetStream enabled. ## Deploy rollup contract to L1 diff --git a/src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md b/src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md new file mode 100644 index 0000000000..7fce545acf --- /dev/null +++ b/src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md @@ -0,0 +1,155 @@ +# Zeko Explorer JetStream Message Contract + +This document is the Zeko-owned contract for explorer-facing NATS/JetStream +publishing from the sequencer and backfill service. + +## Streams + +- Stream name: `zeko-l2` + - Subjects: `zeko.l2.>` + - Duplicate window: 120 seconds + - Max age: 90 days + - Storage: file + - Retention: limits +- Stream name: `zeko-health` + - Subjects: `zeko.health` + - Max messages: 1000 + - Storage: file + - Retention: limits + +Sequencer startup best-effort ensures the streams exist with these subjects when +`--nats-url` is configured. If NATS is not configured, sequencer publishing is a +no-op. If NATS is configured but unavailable, the sequencer logs a warning and +explorer publishing remains disabled rather than crashing. + +The standalone backfill service is stricter: `--nats-url` is required, startup +fails if it cannot connect to JetStream, and dropped backfill publishes fail the +backfill job. + +## Transaction Diffs + +Subject: `zeko.l2.transactions` + +Headers: + +```text +Nats-Msg-Id: +``` + +`target_ledger_hash_decimal` is `Ledger_hash.to_decimal_string +target_ledger_hash`. Publishing the same transaction diff with the same target +ledger hash must deduplicate at the JetStream stream level. + +Payload: + +```json +{ + "kind": "user_command | fee_transfer | sync_replay | genesis_replay", + "source_ledger_hash": "", + "target_ledger_hash": "", + "timestamp": "", + "acc_set": "", + "command": { + "type": "signed_command | zkapp_command", + "raw": "", + "action_step_flags": [true] + }, + "changed_accounts": [ + { + "index": 0, + "account": "" + } + ], + "command_with_action_step_flags": "", + "genesis": false, + "diff": { + "...": "Da_layer.Diff.Stable.V3.to_yojson diff" + } +} +``` + +Fields: + +- `kind`: why this diff is being published. +- `source_ledger_hash`: source ledger hash encoded with + `Ledger_hash.to_yojson`. +- `target_ledger_hash`: target ledger hash encoded with + `Ledger_hash.to_yojson`. +- `timestamp`: diff timestamp encoded with `Block_time.to_yojson`. +- `acc_set`: account-set root from the DA diff. +- `command`: normalized command envelope. `null` for fee transfers and diffs + without a command. +- `changed_accounts`: post-state accounts affected by the diff, normalized as + `{index, account}` objects. +- `command_with_action_step_flags`: raw DA-layer command field, retained for + consumers that need the exact OCaml-derived encoding. +- `genesis`: `true` when replaying the first diff from the genesis source hash. +- `diff`: DA-layer diff encoded with `Da_layer.Diff.Stable.V3.to_yojson`. + +## Finality + +Subject: `zeko.l2.finality` + +Headers: + +```text +Nats-Msg-Id: finality-- +``` + +`status` is the payload status string, and `target_ledger_hash_decimal` is +`Ledger_hash.to_decimal_string target_ledger_hash`. This keeps `proved` and +`committed` finality messages independently deduplicated for the same target +ledger hash. + +Payload: + +```json +{ + "status": "proved | committed", + "level": "proved | committed", + "ledger_hash": "", + "source_ledger_hash": "", + "target_ledger_hash": "", + "timestamp": "" +} +``` + +Fields: + +- `level`: finality state for the target ledger hash. +- `ledger_hash`: target ledger hash encoded with `Ledger_hash.to_yojson`. +- `status`: alias of `level`, retained for compatibility with existing + Zeko-side consumers. +- `source_ledger_hash`: source ledger hash encoded with + `Ledger_hash.to_yojson`. +- `target_ledger_hash`: target ledger hash encoded with + `Ledger_hash.to_yojson`. +- `timestamp`: publisher timestamp encoded with `Block_time.to_yojson`. + +## Health + +Subject: `zeko.health` + +Headers: none required. + +Payload: + +```json +{ + "service": "sequencer-nats-publisher", + "instance_id": "", + "status": "ok", + "last_published_hash": "", + "unproved_hash": "", + "timestamp": "" +} +``` + +Fields: + +- `service`: publishing service identity. +- `instance_id`: publisher instance identity. +- `status`: publisher health/status string. +- `last_published_hash`: optional last published ledger hash. +- `unproved_hash`: optional unproved ledger hash. +- `timestamp`: publisher timestamp encoded with `Block_time.to_yojson`. diff --git a/src/app/zeko/sequencer/explorer/dune b/src/app/zeko/sequencer/explorer/dune index 8c422bb3e4..0efb676f91 100644 --- a/src/app/zeko/sequencer/explorer/dune +++ b/src/app/zeko/sequencer/explorer/dune @@ -12,7 +12,9 @@ snark_params ;; opam libraries ;; yojson + uri core_kernel + async ppx_inline_test.config) (preprocess (pps ppx_jane ppx_mina))) diff --git a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md index 8880b15ef0..2821a7631f 100644 --- a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md +++ b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md @@ -76,21 +76,26 @@ Fields: - `fee_transfer` - `sync_replay` - `genesis_replay` -- `target_ledger_hash` -- `genesis` -- `diff` - -`diff` fields: - - `source_ledger_hash` -- `changed_accounts` -- `command_with_action_step_flags` +- `target_ledger_hash` - `timestamp` - `acc_set` +- `command` +- `changed_accounts` +- `command_with_action_step_flags` +- `genesis` +- `diff` Encoding rules: -- `diff` uses the existing JSON shape derived from `Da_layer.Diff.Stable.V3`. +- `source_ledger_hash`, `timestamp`, `acc_set`, and + `command_with_action_step_flags` come from the JSON shape derived from + `Da_layer.Diff.Stable.V3`. +- `command` is a normalized envelope with `type`, `raw`, and + `action_step_flags`, or `null` when the diff has no command. +- `changed_accounts` is normalized to `{index, account}` objects. +- `diff` retains the existing JSON shape derived from + `Da_layer.Diff.Stable.V3`. - `target_ledger_hash` is the post-diff ledger hash passed to `Da_layer.Client.enqueue_diff`. - `genesis` matches the flag passed to `Da_layer.Client.enqueue_diff`. @@ -98,17 +103,20 @@ Encoding rules: Fields: -- `status` +- `level` - `proved` - `committed` +- `ledger_hash` - `source_ledger_hash` - `target_ledger_hash` +- `status` - `timestamp` Encoding rules: - `proved` is emitted from the successful committer result. - `committed` is emitted after `State.Last_committed_ledger.set`. +- `status` is retained as an alias of `level`. ### `zeko.health` schema diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index ea6962a4f7..883af4f3bb 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -178,9 +178,19 @@ let source_query from_hash = else `Specific from_hash let create ~logger ~da_config ~nats_url = - let%map nats_client = Nats_client_async.connect (Some nats_url) in - [%log debug] "Explorer backfill service connected to NATS: url=%s" - (Uri.to_string nats_url) ; + let%map nats_client = + Explorer_events.connect_and_ensure_jetstream_strict ~logger nats_url + in + let nats_client = + match nats_client with + | Ok client -> + [%log debug] "Explorer backfill service connected to NATS: url=%s" + (Uri.to_string nats_url) ; + client + | Error error -> + failwithf "Explorer backfill service requires JetStream: %s" + (Error.to_string_hum error) () + in { logger ; da_config ; nats_client = Some nats_client @@ -280,6 +290,28 @@ let publish_backfill_diff t ~from_hash ~index ~target_ledger_hash diff = (Format.asprintf "%a" Nats_client_async.pp_publish_result result) ; result +let handle_backfill_publish_result t job ~target_ledger_hash result = + let logger = t.logger in + match result with + | `Queued -> + update_job job ~status:Running + ~diffs_published:(job.diffs_published + 1) + () ; + [%log debug] + "Backfill queued explorer transaction event: job_id=%s \ + target_ledger_hash=%s diffs_published=%d" + job.id + (Ledger_hash.to_decimal_string target_ledger_hash) + job.diffs_published ; + Ok target_ledger_hash + | `Dropped -> + [%log debug] + "Backfill failed to queue explorer transaction event: job_id=%s \ + target_ledger_hash=%s" + job.id + (Ledger_hash.to_decimal_string target_ledger_hash) ; + Error (Error.of_string "NATS publish dropped while backfilling diffs") + let source_hash = function | `Genesis -> genesis_hash @@ -334,29 +366,10 @@ let publish_target t job ~current_source ~index ~target_ledger_hash = "Backfill diff chain does not match requested ledger hash \ progression") ) else - match - publish_backfill_diff t ~from_hash:job.from_hash ~index ~target_ledger_hash - diff - with - | `Queued -> - update_job job ~status:Running - ~diffs_published:(job.diffs_published + 1) - () ; - [%log debug] - "Backfill queued explorer transaction event: job_id=%s \ - target_ledger_hash=%s diffs_published=%d" - job.id - (Ledger_hash.to_decimal_string target_ledger_hash) - job.diffs_published ; - Deferred.return (Ok target_ledger_hash) - | `Dropped -> - [%log debug] - "Backfill failed to queue explorer transaction event: job_id=%s \ - target_ledger_hash=%s" - job.id - (Ledger_hash.to_decimal_string target_ledger_hash) ; - Deferred.return - (Error (Error.of_string "NATS publish dropped while backfilling diffs")) + publish_backfill_diff t ~from_hash:job.from_hash ~index ~target_ledger_hash + diff + |> handle_backfill_publish_result t job ~target_ledger_hash + |> Deferred.return let run_job t job = let logger = t.logger in diff --git a/src/app/zeko/sequencer/explorer/explorer_events.ml b/src/app/zeko/sequencer/explorer/explorer_events.ml index 67f7db4204..3bc6ced579 100644 --- a/src/app/zeko/sequencer/explorer/explorer_events.ml +++ b/src/app/zeko/sequencer/explorer/explorer_events.ml @@ -2,14 +2,11 @@ publishing helpers used by both the live sequencer path and backfill jobs. *) open Core_kernel +open Async open Mina_base module Transaction_kind = struct - type t = - | User_command - | Fee_transfer - | Sync_replay - | Genesis_replay + type t = User_command | Fee_transfer | Sync_replay | Genesis_replay let to_string = function | User_command -> @@ -23,19 +20,79 @@ module Transaction_kind = struct end module Finality_status = struct - type t = - | Proved - | Committed + type t = Proved | Committed let to_string = function Proved -> "proved" | Committed -> "committed" end module Subject = struct let transactions = "zeko.l2.transactions" + let finality = "zeko.l2.finality" + let health = "zeko.health" end +module Jetstream = struct + type stream = + { name : string + ; subjects : string list + ; max_msgs : int + ; max_age_ns : int + ; duplicate_window_ns : int option + } + + let duplicate_window_ns = 120_000_000_000 + + let l2_stream = + { name = "zeko-l2" + ; subjects = [ "zeko.l2.>" ] + ; max_msgs = -1 + ; max_age_ns = 7_776_000_000_000_000 + ; duplicate_window_ns = Some duplicate_window_ns + } + + let health_stream = + { name = "zeko-health" + ; subjects = [ Subject.health ] + ; max_msgs = 1_000 + ; max_age_ns = 0 + ; duplicate_window_ns = None + } + + let streams = [ l2_stream; health_stream ] + + let stream_name = l2_stream.name + + let stream_config stream = + let duplicate_window = + match stream.duplicate_window_ns with + | None -> + [] + | Some duplicate_window_ns -> + [ ("duplicate_window", `Int duplicate_window_ns) ] + in + `Assoc + ( [ ("name", `String stream.name) + ; ( "subjects" + , `List (List.map stream.subjects ~f:(fun subject -> `String subject)) + ) + ; ("retention", `String "limits") + ; ("storage", `String "file") + ; ("discard", `String "old") + ; ("max_msgs", `Int stream.max_msgs) + ; ("max_bytes", `Int (-1)) + ; ("max_age", `Int stream.max_age_ns) + ; ("max_msgs_per_subject", `Int (-1)) + ; ("max_msg_size", `Int (-1)) + ; ("num_replicas", `Int 1) + ] + @ duplicate_window ) + + let api_subject ?(stream_name = stream_name) operation = + sprintf "$JS.API.STREAM.%s.%s" operation stream_name +end + type message = { subject : string ; headers : (string * string) list @@ -55,6 +112,14 @@ let nats_msg_id target_ledger_hash = let nats_msg_id_headers target_ledger_hash = [ ("Nats-Msg-Id", nats_msg_id target_ledger_hash) ] +let finality_nats_msg_id ~status target_ledger_hash = + sprintf "finality-%s-%s" + (Finality_status.to_string status) + (Ledger_hash.to_decimal_string target_ledger_hash) + +let finality_nats_msg_id_headers ~status target_ledger_hash = + [ ("Nats-Msg-Id", finality_nats_msg_id ~status target_ledger_hash) ] + let create_nats_sink ?logger client : sink = fun { subject; headers; payload } -> let headers = Nats_client.Headers.of_list headers in @@ -69,15 +134,284 @@ let create_nats_sink ?logger client : sink = [%log warn] "Dropped explorer NATS message" ~metadata:[ ("subject", `String subject) ] ) +let jetstream_request_timeout = Time_ns.Span.of_sec 5. + +let warn_jetstream ?(stream_name = Jetstream.stream_name) ~logger message + ~metadata = + [%log warn] "%s" message + ~metadata:(("stream", `String stream_name) :: metadata) + +let response_error json = + match json with + | `Assoc fields -> ( + match List.Assoc.find fields "error" ~equal:String.equal with + | Some (`Assoc error_fields) -> + Some error_fields + | _ -> + None ) + | _ -> + None + +let response_error_int fields name = + match List.Assoc.find fields name ~equal:String.equal with + | Some (`Int value) -> + Some value + | _ -> + None + +let response_error_string fields name = + match List.Assoc.find fields name ~equal:String.equal with + | Some (`String value) -> + Some value + | _ -> + None + +let is_stream_not_found fields = + Option.value_map + (response_error_int fields "err_code") + ~default:false ~f:(Int.equal 10059) + || Option.value_map + (response_error_int fields "code") + ~default:false ~f:(Int.equal 404) + +let jetstream_request client ~stream_name ~operation payload = + let subject = Jetstream.api_subject ~stream_name operation in + Nats_client_async.request client ~subject ~timeout:jetstream_request_timeout + (Yojson.Safe.to_string payload) + +let jetstream_response_result ~operation response = + match Or_error.try_with (fun () -> Yojson.Safe.from_string response) with + | Error error -> + Error (Error.tag error ~tag:"Could not parse JetStream response") + | Ok json -> ( + match response_error json with + | None -> + Ok () + | Some fields -> + let description = + Option.value + (response_error_string fields "description") + ~default:"unknown JetStream error" + in + Or_error.errorf "JetStream stream %s failed: %s" operation + description ) + +let create_or_update_jetstream_stream_result client stream ~operation = + jetstream_request client ~stream_name:stream.Jetstream.name ~operation + (Jetstream.stream_config stream) + >>| function + | Error error -> + Error + (Error.tag error + ~tag:(sprintf "JetStream stream %s request failed" operation) ) + | Ok response -> + jetstream_response_result ~operation response + +let ensure_jetstream_stream_result_one client stream = + let open Deferred.Let_syntax in + let%bind info = + jetstream_request client ~stream_name:stream.Jetstream.name + ~operation:"INFO" (`Assoc []) + in + match info with + | Error error -> + return (Error (Error.tag error ~tag:"JetStream stream INFO request failed")) + | Ok response -> ( + match Or_error.try_with (fun () -> Yojson.Safe.from_string response) with + | Error error -> + return + (Error + (Error.tag error + ~tag:"Could not parse JetStream stream INFO response" ) ) + | Ok json -> ( + match response_error json with + | Some fields when is_stream_not_found fields -> + create_or_update_jetstream_stream_result client stream + ~operation:"CREATE" + | Some fields -> + let description = + Option.value + (response_error_string fields "description") + ~default:"unknown JetStream error" + in + Deferred.return + (Or_error.errorf "JetStream stream INFO failed: %s" + description ) + | None -> + create_or_update_jetstream_stream_result client stream + ~operation:"UPDATE" ) ) + +let ensure_jetstream_stream_result client = + let rec go = function + | [] -> + Deferred.return (Ok ()) + | stream :: streams -> ( + let%bind result = ensure_jetstream_stream_result_one client stream in + match result with + | Error _ as error -> + Deferred.return error + | Ok () -> + go streams ) + in + go Jetstream.streams + +let ensure_jetstream_stream ~logger client = + ensure_jetstream_stream_result client >>| function + | Ok () -> + () + | Error error -> + warn_jetstream ~logger "JetStream stream setup failed" + ~metadata:[ ("error", `String (Error.to_string_hum error)) ] + +let nats_uri_available ?(timeout = Time_ns.Span.of_sec 3.) ~logger uri = + match Uri.host uri with + | None -> + warn_jetstream ~logger "NATS URL is missing a host" + ~metadata:[ ("uri", `String (Uri.to_string uri)) ] ; + Deferred.return false + | Some host -> ( + let port = Uri.port uri |> Option.value ~default:4222 in + let where = + Tcp.Where_to_connect.of_host_and_port (Host_and_port.create ~host ~port) + in + Clock_ns.with_timeout timeout + (Monitor.try_with_or_error (fun () -> + let%bind _socket, reader, writer = Tcp.connect where in + let%bind () = Writer.close writer in + Reader.close reader ) ) + >>| function + | `Timeout -> + warn_jetstream ~logger + "Timed out checking NATS availability; explorer publishing disabled" + ~metadata:[ ("uri", `String (Uri.to_string uri)) ] ; + false + | `Result (Error error) -> + warn_jetstream ~logger + "NATS is unavailable; explorer publishing disabled" + ~metadata: + [ ("uri", `String (Uri.to_string uri)) + ; ("error", `String (Error.to_string_hum error)) + ] ; + false + | `Result (Ok ()) -> + true ) + +let connect_and_ensure_jetstream ?(timeout = Time_ns.Span.of_sec 5.) ~logger uri + = + let open Deferred.Let_syntax in + let%bind available = nats_uri_available ~logger uri in + if not available then return None + else + let connect = + Monitor.try_with_or_error (fun () -> Nats_client_async.connect (Some uri)) + in + let%bind connected = + Clock_ns.with_timeout timeout connect + in + match connected with + | `Timeout -> + Deferred.upon connect (function + | Ok client -> + don't_wait_for (Nats_client_async.close client) + | Error _ -> + () ) ; + warn_jetstream ~logger + "Timed out connecting to NATS; explorer publishing disabled" + ~metadata:[ ("uri", `String (Uri.to_string uri)) ] ; + return None + | `Result (Error error) -> + warn_jetstream ~logger + "Failed to connect to NATS; explorer publishing disabled" + ~metadata: + [ ("uri", `String (Uri.to_string uri)) + ; ("error", `String (Error.to_string_hum error)) + ] ; + return None + | `Result (Ok client) -> + let%map ensure_result = + Monitor.try_with_or_error (fun () -> + ensure_jetstream_stream ~logger client ) + in + Result.iter_error ensure_result ~f:(fun error -> + warn_jetstream ~logger "JetStream setup raised an exception" + ~metadata:[ ("error", `String (Error.to_string_hum error)) ] ) ; + Some client + +let connect_and_ensure_jetstream_strict ?timeout ~logger uri = + let open Deferred.Let_syntax in + let%bind client = connect_and_ensure_jetstream ?timeout ~logger uri in + match client with + | None -> + Deferred.return + (Or_error.errorf "Could not connect to NATS at %s" (Uri.to_string uri)) + | Some client -> + let%map result = ensure_jetstream_stream_result client in + Result.map_error result ~f:(fun error -> + don't_wait_for (Nats_client_async.close client) ; + Error.tag error ~tag:"Could not configure JetStream" ) + |> Result.map ~f:(fun () -> client) + +let assoc_field_exn fields name = + List.Assoc.find_exn fields name ~equal:String.equal + +let transaction_command_payload command_with_action_step_flags = + match command_with_action_step_flags with + | None -> + `Null + | Some (command, action_step_flags) -> + let command_type, command_json = + match command with + | User_command.Signed_command command -> + ("signed_command", Signed_command.Stable.V2.to_yojson command) + | User_command.Zkapp_command command -> + ("zkapp_command", Zkapp_command.Stable.V1.to_yojson command) + in + `Assoc + [ ("type", `String command_type) + ; ("raw", command_json) + ; ( "action_step_flags" + , `List (List.map action_step_flags ~f:(fun flag -> `Bool flag)) + ) + ] + +let changed_accounts_payload changed_accounts = + `List + (List.map changed_accounts ~f:(fun (index, account) -> + `Assoc + [ ("index", `Int index) + ; ("account", Account.Stable.V2.to_yojson account) + ] ) ) + let build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff = + let diff_json = Da_layer.Diff.Stable.V3.to_yojson diff in + let diff_fields = + match diff_json with + | `Assoc fields -> + fields + | _ -> + [] + in + let command_with_action_step_flags = + Da_layer.Diff.Stable.V3.command_with_action_step_flags diff + in { subject = Subject.transactions ; headers = nats_msg_id_headers target_ledger_hash ; payload = `Assoc [ ("kind", `String (Transaction_kind.to_string kind)) + ; ("source_ledger_hash", assoc_field_exn diff_fields "source_ledger_hash") ; ("target_ledger_hash", Ledger_hash.to_yojson target_ledger_hash) + ; ("timestamp", assoc_field_exn diff_fields "timestamp") + ; ("acc_set", assoc_field_exn diff_fields "acc_set") + ; ( "command" + , transaction_command_payload command_with_action_step_flags ) + ; ( "changed_accounts" + , changed_accounts_payload + (Da_layer.Diff.Stable.V3.changed_accounts diff) ) + ; ( "command_with_action_step_flags" + , assoc_field_exn diff_fields "command_with_action_step_flags" ) ; ("genesis", `Bool genesis) - ; ("diff", Da_layer.Diff.Stable.V3.to_yojson diff) + ; ("diff", diff_json) ] } @@ -87,10 +421,12 @@ let build_live_diff ~logger ~diff ~acc_set_root = let build_finality_message ~logger ~status ~source_ledger_hash ~target_ledger_hash = { subject = Subject.finality - ; headers = [] + ; headers = finality_nats_msg_id_headers ~status target_ledger_hash ; payload = `Assoc [ ("status", `String (Finality_status.to_string status)) + ; ("level", `String (Finality_status.to_string status)) + ; ("ledger_hash", Ledger_hash.to_yojson target_ledger_hash) ; ("source_ledger_hash", Ledger_hash.to_yojson source_ledger_hash) ; ("target_ledger_hash", Ledger_hash.to_yojson target_ledger_hash) ; ("timestamp", timestamp_json ~logger) @@ -99,8 +435,7 @@ let build_finality_message ~logger ~status ~source_ledger_hash let build_health_message ~logger ~service ~instance_id ~status ?last_published_hash ?unproved_hash () = - let optional_hash name = - function + let optional_hash name = function | None -> [] | Some hash -> @@ -125,8 +460,8 @@ let publish_transaction sink ~kind ~target_ledger_hash ~genesis ~diff = publish sink @@ build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff -let publish_finality sink ~logger ~status ~source_ledger_hash ~target_ledger_hash - = +let publish_finality sink ~logger ~status ~source_ledger_hash + ~target_ledger_hash = publish sink @@ build_finality_message ~logger ~status ~source_ledger_hash ~target_ledger_hash diff --git a/src/app/zeko/sequencer/explorer/tests/README.md b/src/app/zeko/sequencer/explorer/tests/README.md index e48881eab2..94be470242 100644 --- a/src/app/zeko/sequencer/explorer/tests/README.md +++ b/src/app/zeko/sequencer/explorer/tests/README.md @@ -43,7 +43,9 @@ File: This is a standalone Async executable because it connects to a real NATS server. It checks that the shared explorer publisher paths produce messages that a real subscriber receives with the expected subject, payload, and `Nats-Msg-Id` -header. +header. It also checks the Zeko JetStream stream contract by publishing the +same transaction message twice with the same `Nats-Msg-Id` and asserting the +stream stores one message. Run it separately with: @@ -62,7 +64,7 @@ NATS_URL="nats://127.0.0.1:4222" \ ``` The `Explorer Gherkin` CI workflow uses this full-suite command and provides -NATS through a GitHub Actions service container. +NATS through a GitHub Actions service container with JetStream enabled. ## Adding Coverage @@ -71,7 +73,7 @@ implementation steps. Add one scenario per behavior. Use in-process scenarios for deterministic behavior that does not need external infrastructure. Use broker-backed scenarios when the behavior depends on NATS -delivery, subjects, or headers. +delivery, subjects, headers, or JetStream storage semantics. ## Process-level E2E Scenarios diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml index 47834a47d2..0904f99b04 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -1,10 +1,8 @@ (* Executes minimal Gherkin acceptance tests for the explorer-facing behavior - introduced in this PR: event payloads, backfill GraphQL/SSE behavior, and - the sequencer replay classification hook. *) + introduced in this PR: event payloads and backfill GraphQL/SSE behavior. *) open Core open Async -open Sequencer_lib open Mina_base let find_assoc_exn key json = @@ -17,7 +15,8 @@ let find_assoc_exn key json = let find_header_exn headers key = List.Assoc.find_exn headers key ~equal:String.equal -let test_service ?(instance_id = "instance-1") () : Explorer_backfill_service.t = +let test_service ?(instance_id = "instance-1") () : Explorer_backfill_service.t + = { logger = Logger.create () ; da_config = Da_layer.Client.Config.of_string_list [] ; nats_client = None @@ -43,31 +42,21 @@ let graphql_response service query = failwith (Yojson.Basic.to_string err) ) ) let graphql_data_exn service query field = - find_assoc_exn field - (find_assoc_exn "data" (graphql_response service query)) + find_assoc_exn field (find_assoc_exn "data" (graphql_response service query)) let pipe_read_exn reader = Thread_safe.block_on_async_exn (fun () -> Pipe.read reader - >>| function - | `Ok value -> - value - | `Eof -> - failwith "Unexpected EOF" ) + >>| function `Ok value -> value | `Eof -> failwith "Unexpected EOF" ) let pipe_read_string_exn reader = Thread_safe.block_on_async_exn (fun () -> Pipe.read reader - >>| function - | `Ok value -> - value - | `Eof -> - failwith "Unexpected EOF" ) + >>| function `Ok value -> value | `Eof -> failwith "Unexpected EOF" ) let add_job (service : Explorer_backfill_service.t) - ?(status = Explorer_backfill_service.Queued) - ?(diffs_published = 0) ?error ?started_at ?finished_at ?(id = "job-1") - () = + ?(status = Explorer_backfill_service.Queued) ?(diffs_published = 0) ?error + ?started_at ?finished_at ?(id = "job-1") () = let job : Explorer_backfill_service.job = { id ; from_hash = Explorer_backfill_service.genesis_hash @@ -92,15 +81,19 @@ let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = ~acc_set_root:Snark_params.Tick.Field.zero let sse_request body = - let headers = Cohttp.Header.of_list [ ("Content-Type", "application/json") ] in - Cohttp.Request.make ~meth:`POST ~headers - (Uri.of_string "http://localhost/graphql/stream"), - body + let headers = + Cohttp.Header.of_list [ ("Content-Type", "application/json") ] + in + ( Cohttp.Request.make ~meth:`POST ~headers + (Uri.of_string "http://localhost/graphql/stream") + , body ) let sse_reader_exn service body = let req, body = sse_request body in Thread_safe.block_on_async_exn (fun () -> - match%bind Explorer_backfill_sse.execute_subscription service req body with + match%bind + Explorer_backfill_sse.execute_subscription service req body + with | Error err -> failwith (Error.to_string_hum err) | Ok stream -> @@ -110,8 +103,8 @@ let sse_reader_exn service body = let parse_sse_next_event_exn event = let prefix = "event: next\ndata: " in - if not (String.is_prefix event ~prefix) - then failwithf "Unexpected SSE event: %s" event () + if not (String.is_prefix event ~prefix) then + failwithf "Unexpected SSE event: %s" event () else String.drop_prefix event (String.length prefix) |> String.chop_suffix_exn ~suffix:"\n\n" @@ -122,18 +115,21 @@ let backfill_progress_event_exn event = (find_assoc_exn "data" (parse_sse_next_event_exn event)) let assert_basic_json_equal actual expected = - if not (Yojson.Basic.equal actual expected) - then - failwithf "Expected %s but got %s" (Yojson.Basic.to_string expected) - (Yojson.Basic.to_string actual) () + if not (Yojson.Basic.equal actual expected) then + failwithf "Expected %s but got %s" + (Yojson.Basic.to_string expected) + (Yojson.Basic.to_string actual) + () let assert_safe_json_equal actual expected = - if not (Yojson.Safe.equal actual expected) - then - failwithf "Expected %s but got %s" (Yojson.Safe.to_string expected) - (Yojson.Safe.to_string actual) () + if not (Yojson.Safe.equal actual expected) then + failwithf "Expected %s but got %s" + (Yojson.Safe.to_string expected) + (Yojson.Safe.to_string actual) + () -let%test_unit "A genesis backfill marks only the first replayed diff as genesis" = +let%test_unit "A genesis backfill marks only the first replayed diff as genesis" + = Feature_parser.assert_scenario "backfill-api.feature" "A genesis backfill marks only the first replayed diff as genesis" ; if @@ -151,7 +147,8 @@ let%test_unit "A genesis backfill marks only the first replayed diff as genesis" Explorer_events.Transaction_kind.Sync_replay ) then failwith "expected later genesis replay diffs to be marked as sync" -let%test_unit "The backfill mutation returns a failed job snapshot for invalid hashes" = +let%test_unit + "The backfill mutation returns a failed job snapshot for invalid hashes" = Feature_parser.assert_scenario "backfill-api.feature" "The backfill mutation returns a failed job snapshot for invalid hashes" ; let service = test_service () in @@ -160,10 +157,11 @@ let%test_unit "The backfill mutation returns a failed job snapshot for invalid h {|mutation { backfill(fromHash: "nonexistent", toHash: "D") { status error } }|} "backfill" in - assert_basic_json_equal (find_assoc_exn "status" backfill_job) + assert_basic_json_equal + (find_assoc_exn "status" backfill_job) (`String "failed") ; [%test_eq: bool] - (match find_assoc_exn "error" backfill_job with + ( match find_assoc_exn "error" backfill_job with | `String error -> String.is_substring error ~substring:"Invalid ledger hash" | _ -> @@ -179,31 +177,29 @@ let%test_unit "Backfill progress subscriptions stream job updates" = sse_reader_exn service (Yojson.Basic.to_string (`Assoc - [ ( "query" - , `String - (sprintf - {|subscription { backfillProgress(id: "%s") { id status diffsPublished error } }|} - job.id ) ) - ]) ) - in - let initial = - pipe_read_string_exn reader |> backfill_progress_event_exn + [ ( "query" + , `String + (sprintf + {|subscription { backfillProgress(id: "%s") { id status diffsPublished error } }|} + job.id ) ) + ] ) ) in + let initial = pipe_read_string_exn reader |> backfill_progress_event_exn in assert_basic_json_equal (find_assoc_exn "diffsPublished" initial) (`Int 0) ; Explorer_backfill_service.update_job job ~status:Running ~diffs_published:1 ~started_at:Time.epoch () ; - let first = - pipe_read_string_exn reader |> backfill_progress_event_exn - in + let first = pipe_read_string_exn reader |> backfill_progress_event_exn in assert_basic_json_equal (find_assoc_exn "diffsPublished" first) (`Int 1) ; - Explorer_backfill_service.update_job job ~status:Completed - ~diffs_published:2 ~finished_at:Time.epoch () ; + Explorer_backfill_service.update_job job ~status:Completed ~diffs_published:2 + ~finished_at:Time.epoch () ; let final_progress = pipe_read_string_exn reader |> backfill_progress_event_exn in - assert_basic_json_equal (find_assoc_exn "diffsPublished" final_progress) + assert_basic_json_equal + (find_assoc_exn "diffsPublished" final_progress) (`Int 2) ; - assert_basic_json_equal (find_assoc_exn "status" final_progress) + assert_basic_json_equal + (find_assoc_exn "status" final_progress) (`String "completed") let%test_unit "The backfill health query exposes the service instance" = @@ -211,14 +207,14 @@ let%test_unit "The backfill health query exposes the service instance" = "The backfill health query exposes the service instance" ; let health = graphql_data_exn (test_service ()) - {|query { health { ok instanceId startedAt } }|} - "health" + {|query { health { ok instanceId startedAt } }|} "health" in assert_basic_json_equal (find_assoc_exn "ok" health) (`Bool true) ; - assert_basic_json_equal (find_assoc_exn "instanceId" health) + assert_basic_json_equal + (find_assoc_exn "instanceId" health) (`String "instance-1") ; [%test_eq: bool] - (match find_assoc_exn "startedAt" health with + ( match find_assoc_exn "startedAt" health with | `String _ -> true | _ -> @@ -232,16 +228,15 @@ let%test_unit "The backfill job query returns the current job snapshot" = let job = add_job service ~status:Explorer_backfill_service.Running () in let backfill_job = graphql_data_exn service - (sprintf - {|query { backfillJob(id: "%s") { id status diffsPublished } }|} + (sprintf {|query { backfillJob(id: "%s") { id status diffsPublished } }|} job.id ) "backfillJob" in assert_basic_json_equal (find_assoc_exn "id" backfill_job) (`String job.id) ; - assert_basic_json_equal (find_assoc_exn "status" backfill_job) + assert_basic_json_equal + (find_assoc_exn "status" backfill_job) (`String "running") ; - assert_basic_json_equal (find_assoc_exn "diffsPublished" backfill_job) - (`Int 0) + assert_basic_json_equal (find_assoc_exn "diffsPublished" backfill_job) (`Int 0) let%test_unit "Backfill progress subscriptions stream GraphQL-SSE events" = Feature_parser.assert_scenario "backfill-api.feature" @@ -252,111 +247,149 @@ let%test_unit "Backfill progress subscriptions stream GraphQL-SSE events" = sse_reader_exn service (Yojson.Basic.to_string (`Assoc - [ ( "query" - , `String - (sprintf - {|subscription { backfillProgress(id: "%s") { id status diffsPublished error } }|} - job.id ) ) - ]) ) - in - let first = - pipe_read_string_exn reader |> backfill_progress_event_exn + [ ( "query" + , `String + (sprintf + {|subscription { backfillProgress(id: "%s") { id status diffsPublished error } }|} + job.id ) ) + ] ) ) in + let first = pipe_read_string_exn reader |> backfill_progress_event_exn in assert_basic_json_equal (find_assoc_exn "diffsPublished" first) (`Int 0) ; Explorer_backfill_service.update_job job ~status:Running ~diffs_published:1 ~started_at:Time.epoch () ; - let running = - pipe_read_string_exn reader |> backfill_progress_event_exn - in + let running = pipe_read_string_exn reader |> backfill_progress_event_exn in assert_basic_json_equal (find_assoc_exn "diffsPublished" running) (`Int 1) ; - Explorer_backfill_service.update_job job ~status:Completed - ~diffs_published:2 ~finished_at:Time.epoch () ; - let completed = - pipe_read_string_exn reader |> backfill_progress_event_exn - in - assert_basic_json_equal (find_assoc_exn "status" completed) + Explorer_backfill_service.update_job job ~status:Completed ~diffs_published:2 + ~finished_at:Time.epoch () ; + let completed = pipe_read_string_exn reader |> backfill_progress_event_exn in + assert_basic_json_equal + (find_assoc_exn "status" completed) (`String "completed") ; [%test_eq: string] (pipe_read_string_exn reader) Explorer_backfill_sse.complete_event -let%test_unit "Transaction events include the replay kind, diff payload, and NATS dedup header" = +let%test_unit + "Transaction events expose diff fields and NATS dedup header" = Feature_parser.assert_scenario "event-contract.feature" - "Transaction events include the replay kind, diff payload, and NATS dedup header" ; + "Transaction events expose diff fields and NATS dedup header" ; let target_ledger_hash = Ledger_hash.empty_hash in let diff = sample_diff () in + let diff_json = Da_layer.Diff.Stable.V3.to_yojson diff in let message = Explorer_events.build_transaction_message - ~kind:Explorer_events.Transaction_kind.Sync_replay - ~target_ledger_hash ~genesis:false ~diff + ~kind:Explorer_events.Transaction_kind.Sync_replay ~target_ledger_hash + ~genesis:false ~diff in [%test_eq: string] message.subject Explorer_events.Subject.transactions ; - assert_safe_json_equal (find_assoc_exn "kind" message.payload) + assert_safe_json_equal + (find_assoc_exn "kind" message.payload) (`String "sync_replay") ; - assert_safe_json_equal (find_assoc_exn "target_ledger_hash" message.payload) + assert_safe_json_equal + (find_assoc_exn "target_ledger_hash" message.payload) (Ledger_hash.to_yojson target_ledger_hash) ; - assert_safe_json_equal (find_assoc_exn "genesis" message.payload) - (`Bool false) ; - assert_safe_json_equal (find_assoc_exn "diff" message.payload) - (Da_layer.Diff.Stable.V3.to_yojson diff) ; + assert_safe_json_equal + (find_assoc_exn "source_ledger_hash" message.payload) + (find_assoc_exn "source_ledger_hash" diff_json) ; + assert_safe_json_equal + (find_assoc_exn "changed_accounts" message.payload) + (`List []) ; + assert_safe_json_equal (find_assoc_exn "command" message.payload) `Null ; + assert_safe_json_equal (find_assoc_exn "genesis" message.payload) (`Bool false) ; + assert_safe_json_equal + (find_assoc_exn "diff" message.payload) + diff_json ; [%test_eq: string] (find_header_exn message.headers "Nats-Msg-Id") (Ledger_hash.to_decimal_string target_ledger_hash) -let%test_unit "Finality events include status and ledger hashes" = +let%test_unit + "Finality events include level, ledger hashes, and NATS dedup header" = Feature_parser.assert_scenario "event-contract.feature" - "Finality events include status and ledger hashes" ; + "Finality events include level, ledger hashes, and NATS dedup header" ; + let target_ledger_hash = Ledger_hash.empty_hash in let message = Explorer_events.build_finality_message ~logger:(Logger.create ()) ~status:Explorer_events.Finality_status.Proved - ~source_ledger_hash:Ledger_hash.empty_hash - ~target_ledger_hash:Ledger_hash.empty_hash + ~source_ledger_hash:Ledger_hash.empty_hash ~target_ledger_hash in [%test_eq: string] message.subject Explorer_events.Subject.finality ; - assert_safe_json_equal (find_assoc_exn "status" message.payload) + assert_safe_json_equal + (find_assoc_exn "level" message.payload) (`String "proved") ; + assert_safe_json_equal + (find_assoc_exn "ledger_hash" message.payload) + (Ledger_hash.to_yojson target_ledger_hash) ; assert_safe_json_equal (find_assoc_exn "source_ledger_hash" message.payload) (Ledger_hash.to_yojson Ledger_hash.empty_hash) ; assert_safe_json_equal (find_assoc_exn "target_ledger_hash" message.payload) - (Ledger_hash.to_yojson Ledger_hash.empty_hash) + (Ledger_hash.to_yojson target_ledger_hash) ; + [%test_eq: string] + (find_header_exn message.headers "Nats-Msg-Id") + (sprintf "finality-proved-%s" + (Ledger_hash.to_decimal_string target_ledger_hash) ) -let%test_unit "Health events include the service identity and publishing state" = +let%test_unit "Health events include the service identity and publishing state" + = Feature_parser.assert_scenario "event-contract.feature" "Health events include the service identity and publishing state" ; let message = Explorer_events.build_health_message ~logger:(Logger.create ()) - ~service:"sequencer-nats-publisher" ~instance_id:"instance-1" - ~status:"ok" ~last_published_hash:Ledger_hash.empty_hash + ~service:"sequencer-nats-publisher" ~instance_id:"instance-1" ~status:"ok" + ~last_published_hash:Ledger_hash.empty_hash ~unproved_hash:Ledger_hash.empty_hash () in [%test_eq: string] message.subject Explorer_events.Subject.health ; - assert_safe_json_equal (find_assoc_exn "service" message.payload) + assert_safe_json_equal + (find_assoc_exn "service" message.payload) (`String "sequencer-nats-publisher") ; - assert_safe_json_equal (find_assoc_exn "instance_id" message.payload) + assert_safe_json_equal + (find_assoc_exn "instance_id" message.payload) (`String "instance-1") ; - assert_safe_json_equal (find_assoc_exn "status" message.payload) - (`String "ok") ; + assert_safe_json_equal (find_assoc_exn "status" message.payload) (`String "ok") ; assert_safe_json_equal (find_assoc_exn "last_published_hash" message.payload) (Ledger_hash.to_yojson Ledger_hash.empty_hash) ; - assert_safe_json_equal (find_assoc_exn "unproved_hash" message.payload) + assert_safe_json_equal + (find_assoc_exn "unproved_hash" message.payload) (Ledger_hash.to_yojson Ledger_hash.empty_hash) -let%test_unit "Sync replay from genesis marks the very first diff as genesis" = - Feature_parser.assert_scenario "sequencer-hooks.feature" - "Sync replay from genesis marks the very first diff as genesis" ; - [%test_eq: bool] - (Zeko_sequencer.Sequencer.replay_genesis_flag ~source:`Genesis - ~current_chunk:0 ~current_diff:0 ) - true +let%test_unit "Disabled NATS publishing is a no-op" = + Feature_parser.assert_scenario "event-contract.feature" + "Disabled NATS publishing is a no-op" ; + let client = + Thread_safe.block_on_async_exn (fun () -> Nats_client_async.connect None) + in + let sink = Explorer_events.create_nats_sink client in + Explorer_events.publish_transaction sink + ~kind:Explorer_events.Transaction_kind.User_command + ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false + ~diff:(sample_diff ()) -let%test_unit "Sync replay from a checkpoint never re-labels diffs as genesis" = - Feature_parser.assert_scenario "sequencer-hooks.feature" - "Sync replay from a checkpoint never re-labels diffs as genesis" ; - [%test_eq: bool] - (Zeko_sequencer.Sequencer.replay_genesis_flag - ~source:(`Specific Ledger_hash.empty_hash) - ~current_chunk:0 ~current_diff:0 ) - false +let%test_unit "Backfill dropped publishes are fatal" = + Feature_parser.assert_scenario "event-contract.feature" + "Backfill dropped publishes fail the job" ; + let service = test_service () in + let job = + add_job service ~status:Explorer_backfill_service.Running + ~diffs_published:0 () + in + match + Explorer_backfill_service.handle_backfill_publish_result service job + ~target_ledger_hash:Ledger_hash.empty_hash `Dropped + with + | Ok _ -> + failwith "expected dropped backfill publish to be fatal" + | Error error -> + if + not + (String.is_substring (Error.to_string_hum error) + ~substring:"NATS publish dropped" ) + then + failwithf "unexpected dropped-publish error: %s" + (Error.to_string_hum error) + () ; + [%test_eq: int] job.diffs_published 0 diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml index c8349d3fcc..f3f041411a 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml @@ -64,6 +64,66 @@ let assert_safe_json_equal actual expected = failwithf "Expected %s but got %s" (Yojson.Safe.to_string expected) (Yojson.Safe.to_string actual) () +let json_int_exn = function + | `Int value -> + value + | `Intlit value -> + Int.of_string value + | json -> + failwithf "expected JSON integer, got %s" (Yojson.Safe.to_string json) () + +let json_bool_exn = function + | `Bool value -> + value + | json -> + failwithf "expected JSON boolean, got %s" (Yojson.Safe.to_string json) () + +let require_no_jetstream_error label json = + match json with + | `Assoc fields -> ( + match List.Assoc.find fields "error" ~equal:String.equal with + | None -> + () + | Some error -> + failwithf "%s returned JetStream error: %s" label + (Yojson.Safe.to_string error) + () ) + | _ -> + failwithf "%s returned non-object JSON: %s" label + (Yojson.Safe.to_string json) + () + +let jetstream_request_json label client ~operation payload = + let subject = Explorer_events.Jetstream.api_subject operation in + Nats_client_async.request client ~subject ~timeout:(Time_ns.Span.of_sec 5.) + (Yojson.Safe.to_string payload) + >>| fun response -> + let response = expect_ok label response in + let json = Yojson.Safe.from_string response in + require_no_jetstream_error label json ; + json + +let purge_jetstream_stream client = + jetstream_request_json "purge stream" client ~operation:"PURGE" (`Assoc []) + >>| ignore + +let jetstream_message_count client = + jetstream_request_json "stream info" client ~operation:"INFO" (`Assoc []) + >>| fun info -> + json_assoc_exn "messages" (json_assoc_exn "state" info) |> json_int_exn + +let publish_jetstream_message label client + ({ Explorer_events.subject; headers; payload } : Explorer_events.message) = + let headers = Nats_client.Headers.of_list headers in + Nats_client_async.request client ~subject ~headers + ~timeout:(Time_ns.Span.of_sec 5.) + (Yojson.Safe.to_string payload) + >>| fun response -> + let response = expect_ok label response in + let json = Yojson.Safe.from_string response in + require_no_jetstream_error label json ; + json + let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = Explorer_events.build_live_diff ~logger:(Logger.create ()) ~diff: @@ -161,9 +221,41 @@ let run_backfill_scenario () = (`String "genesis_replay") ; assert_safe_json_equal (json_assoc_exn "genesis" payload) (`Bool true) ) +let run_jetstream_dedupe_scenario () = + Feature_parser.assert_scenario "nats-integration.feature" + "JetStream deduplicates transaction publishes by Nats-Msg-Id" ; + with_clients (fun ~subscriber:_ ~actor -> + let logger = Logger.create () in + let%bind () = Explorer_events.ensure_jetstream_stream ~logger actor in + let%bind () = purge_jetstream_stream actor in + let target_ledger_hash = Ledger_hash.empty_hash in + let message = + Explorer_events.build_transaction_message + ~kind:Explorer_events.Transaction_kind.User_command + ~target_ledger_hash ~genesis:false ~diff:(sample_diff ()) + in + let%bind first_ack = + publish_jetstream_message "first duplicate publish" actor message + in + let%bind second_ack = + publish_jetstream_message "second duplicate publish" actor message + in + assert_safe_json_equal + (json_assoc_exn "stream" first_ack) + (`String Explorer_events.Jetstream.stream_name) ; + assert_safe_json_equal + (json_assoc_exn "stream" second_ack) + (`String Explorer_events.Jetstream.stream_name) ; + if not (json_bool_exn (json_assoc_exn "duplicate" second_ack)) then + failwith "expected the second JetStream publish to be a duplicate" ; + let%map messages = jetstream_message_count actor in + if not (Int.equal messages 1) then + failwithf "expected one stored JetStream message, got %d" messages () ) + let main () = let%bind () = run_live_transaction_scenario () in let%bind () = run_backfill_scenario () in + let%bind () = run_jetstream_dedupe_scenario () in printf "explorer NATS Gherkin integration scenarios passed against %s\n" (Uri.to_string (url ())) ; return () diff --git a/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature b/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature index f82be35611..88afa1a1b2 100644 --- a/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature +++ b/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature @@ -1,21 +1,24 @@ Feature: Explorer Event Contract - Scenario: Transaction events include the replay kind, diff payload, and NATS dedup header + Scenario: Transaction events expose diff fields and NATS dedup header Given a transaction diff prepared for explorer publishing When the transaction event message is built Then the subject is "zeko.l2.transactions" And the payload includes "kind" + And the payload includes "source_ledger_hash" And the payload includes "target_ledger_hash" - And the payload includes "diff" + And the payload includes "changed_accounts" + And the payload includes "command" And the headers include "Nats-Msg-Id" - Scenario: Finality events include status and ledger hashes + Scenario: Finality events include level, ledger hashes, and NATS dedup header Given a finality transition prepared for explorer publishing When the finality event message is built Then the subject is "zeko.l2.finality" - And the payload includes "status" + And the payload includes "level" + And the payload includes "ledger_hash" And the payload includes "source_ledger_hash" - And the payload includes "target_ledger_hash" + And the headers include "Nats-Msg-Id" Scenario: Health events include the service identity and publishing state Given sequencer health data prepared for explorer publishing @@ -25,3 +28,13 @@ Feature: Explorer Event Contract And the payload includes "instance_id" And the payload includes "last_published_hash" And the payload includes "unproved_hash" + + Scenario: Disabled NATS publishing is a no-op + Given explorer publishing has no NATS client + When a transaction event is published + Then publishing does not raise + + Scenario: Backfill dropped publishes fail the job + Given a backfill job is publishing historical diffs + When NATS drops the publish + Then the backfill publish is treated as an error diff --git a/src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature b/src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature index 9bc1d3a318..952aa3c712 100644 --- a/src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature +++ b/src/app/zeko/sequencer/explorer/tests/features/nats-integration.feature @@ -13,3 +13,8 @@ Feature: Explorer NATS Integration Then a subscriber receives it on "zeko.l2.transactions" And the payload kind is "genesis_replay" And the payload marks genesis as true + + Scenario: JetStream deduplicates transaction publishes by Nats-Msg-Id + Given a running NATS server with JetStream enabled + When the same transaction event is published twice with the same "Nats-Msg-Id" + Then the JetStream stream stores one transaction message diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index b075ce4984..79fb32265a 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -1211,8 +1211,7 @@ module Sequencer = struct | None -> return None | Some uri -> - let%map client = Nats_client_async.connect (Some uri) in - Some client + Explorer_events.connect_and_ensure_jetstream ~logger uri in let nats_sink = match nats_client with diff --git a/src/app/zeko/sequencer/tests/run-sequencer-test.sh b/src/app/zeko/sequencer/tests/run-sequencer-test.sh index 7ad764e9b5..3d2e91e577 100755 --- a/src/app/zeko/sequencer/tests/run-sequencer-test.sh +++ b/src/app/zeko/sequencer/tests/run-sequencer-test.sh @@ -67,8 +67,10 @@ fi opam exec -- env -u DUNE_RPC dune build \ src/app/zeko/sequencer/tests/testing_ledger/run.exe \ src/app/zeko/da_layer/cli.exe \ + src/app/zeko/sequencer/cli.exe \ src/app/zeko/sequencer/prover/cli.exe \ src/app/zeko/sequencer/prover/cli_fake.exe \ + src/app/zeko/signer/cli.exe \ "$TEST_DUNE_TARGET" export ZEKO_SIGNATURE_KIND=testnet @@ -91,6 +93,34 @@ wait_for_port() { echo "Port $port is now open" } +wait_for_queue_consumers() { + local queue=$1 + local expected_consumers=$2 + local timeout_seconds=${3:-300} + + for _ in $(seq 1 "$timeout_seconds"); do + if docker exec rabbitmq-sequencer rabbitmqctl -q list_queues name consumers \ + | awk -v queue="$queue" -v expected="$expected_consumers" \ + '$1 == queue && $2 >= expected { found = 1 } END { exit found ? 0 : 1 }'; then + echo "Queue $queue has at least $expected_consumers consumers" + return + fi + + for pid in "${PROVER_PIDS[@]}"; do + if ! kill -0 "$pid" 2>/dev/null; then + echo "Prover process $pid exited before registering as a queue consumer" + exit 1 + fi + done + + sleep 1 + done + + echo "Timed out waiting for $expected_consumers consumers on queue $queue" + docker logs rabbitmq-sequencer || true + exit 1 +} + KEYGEN_BIN="$SEQUENCER_BUILD_ROOT/cli.exe" generate_even_key() { @@ -106,7 +136,7 @@ docker run --rm --name pg-sequencer \ docker run -d --name rabbitmq-sequencer \ -p 5672:5672 \ - rabbitmq:latest + rabbitmq:3.13.7 wait_for_port 5433 $$ wait_for_port 5672 $$ @@ -182,6 +212,7 @@ wait_for_port 8080 $l1_pid wait_for_port 8555 $da1_pid wait_for_port 8556 $da2_pid wait_for_port 8557 $da3_pid +wait_for_queue_consumers sequencer.jobs "$NUM_PROVERS" echo "All services started successfully" From efe115b8c59c0b4c517f956e61e54b99f55ce3ad Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Thu, 21 May 2026 22:36:20 +0700 Subject: [PATCH 39/51] Narrow sequencer test harness builds --- .../zeko/sequencer/explorer/tests/README.md | 5 +++ .../sequencer/tests/run-sequencer-test.sh | 45 +++++++++++-------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/tests/README.md b/src/app/zeko/sequencer/explorer/tests/README.md index 94be470242..c848622830 100644 --- a/src/app/zeko/sequencer/explorer/tests/README.md +++ b/src/app/zeko/sequencer/explorer/tests/README.md @@ -98,6 +98,11 @@ existing sequencer integration tests. A later follow-up can move this to a fully external `run.exe` process once the bootstrap/deploy path is ready for that shape. +Unlike the shared sequencer harness path, the `explorer-e2e` mode in +`run-sequencer-test.sh` still builds its own dedicated test executable and +service binaries because the standalone `Explorer E2E` workflow does not run a +separate prebuild step first. + Run it with: ```bash diff --git a/src/app/zeko/sequencer/tests/run-sequencer-test.sh b/src/app/zeko/sequencer/tests/run-sequencer-test.sh index 8379e93c25..d63223a0b7 100755 --- a/src/app/zeko/sequencer/tests/run-sequencer-test.sh +++ b/src/app/zeko/sequencer/tests/run-sequencer-test.sh @@ -92,23 +92,32 @@ SEQUENCER_ROOT="$(git rev-parse --show-toplevel)/src/app/zeko/sequencer" SEQUENCER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/sequencer" SIGNER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/signer" -if [ "$TEST_TARGET" = "explorer-e2e" ]; then - TEST_DUNE_TARGET="src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.exe" -else - TEST_DUNE_TARGET="src/app/zeko/sequencer/tests/sequencer_test.exe" - if [ "$MODE" = "fake" ]; then - TEST_DUNE_TARGET="src/app/zeko/sequencer/tests/sequencer_test_fake.exe" +test_binary() { + if [ "$TEST_TARGET" = "explorer-e2e" ]; then + echo "$SEQUENCER_BUILD_ROOT/explorer/tests/explorer_e2e_gherkin_tests.exe" + elif [ "$MODE" = "fake" ]; then + echo "$SEQUENCER_BUILD_ROOT/tests/sequencer_test_fake.exe" + else + echo "$SEQUENCER_BUILD_ROOT/tests/sequencer_test.exe" fi -fi +} + +ensure_explorer_e2e_build() { + if [ "$TEST_TARGET" != "explorer-e2e" ]; then + return + fi + + opam exec -- env -u DUNE_RPC dune build \ + src/app/zeko/sequencer/tests/testing_ledger/run.exe \ + src/app/zeko/da_layer/cli.exe \ + src/app/zeko/sequencer/cli.exe \ + src/app/zeko/sequencer/prover/cli.exe \ + src/app/zeko/sequencer/prover/cli_fake.exe \ + src/app/zeko/signer/cli.exe \ + src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.exe +} -opam exec -- env -u DUNE_RPC dune build \ - src/app/zeko/sequencer/tests/testing_ledger/run.exe \ - src/app/zeko/da_layer/cli.exe \ - src/app/zeko/sequencer/cli.exe \ - src/app/zeko/sequencer/prover/cli.exe \ - src/app/zeko/sequencer/prover/cli_fake.exe \ - src/app/zeko/signer/cli.exe \ - "$TEST_DUNE_TARGET" +ensure_explorer_e2e_build export ZEKO_SIGNATURE_KIND=zeko-testnet export ZEKO_CIRCUITS_CONFIG=test @@ -298,13 +307,13 @@ echo "All services started successfully" if [ "$TEST_TARGET" = "explorer-e2e" ]; then run "explorer-e2e" \ env ZEKO_CIRCUITS_MODE=$MODE \ - "$SEQUENCER_BUILD_ROOT/explorer/tests/explorer_e2e_gherkin_tests.exe" + "$(test_binary)" elif [ "$MODE" = "fake" ]; then run "sequencer-test" \ env ZEKO_CIRCUITS_MODE=$MODE \ - $SEQUENCER_BUILD_ROOT/tests/sequencer_test_fake.exe "${PROVERS[@]}" + "$(test_binary)" "${PROVERS[@]}" else run "sequencer-test" \ env ZEKO_CIRCUITS_MODE=$MODE \ - $SEQUENCER_BUILD_ROOT/tests/sequencer_test.exe "${PROVERS[@]}" + "$(test_binary)" "${PROVERS[@]}" fi From f4ea56cc7e54fcefe280e4056173856aefc81fc9 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 16:57:16 +0700 Subject: [PATCH 40/51] Fix explorer diff compatibility after merge --- .../sequencer/explorer/MESSAGE_CONTRACT.md | 12 ++++--- .../explorer/explorer-indexing-plan.md | 6 ++-- .../explorer/explorer_backfill_service.ml | 2 +- .../sequencer/explorer/explorer_events.ml | 32 ++++++++++++------- .../explorer/tests/explorer_gherkin_tests.ml | 6 ++-- .../tests/explorer_nats_gherkin_tests.ml | 4 +-- .../tests/features/event-contract.feature | 1 + 7 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md b/src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md index 7fce545acf..4a9770e423 100644 --- a/src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md +++ b/src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md @@ -60,10 +60,11 @@ Payload: "account": "" } ], - "command_with_action_step_flags": "", + "command_with_action_step_flags": "", + "actions": "", "genesis": false, "diff": { - "...": "Da_layer.Diff.Stable.V3.to_yojson diff" + "...": "Da_layer.Diff.Stable.V4.to_yojson diff" } } ``` @@ -81,10 +82,11 @@ Fields: without a command. - `changed_accounts`: post-state accounts affected by the diff, normalized as `{index, account}` objects. -- `command_with_action_step_flags`: raw DA-layer command field, retained for - consumers that need the exact OCaml-derived encoding. +- `command_with_action_step_flags`: legacy command tuple projection, retained for + consumers that still read the previous command field. +- `actions`: raw DA-layer actions field from the current diff format. - `genesis`: `true` when replaying the first diff from the genesis source hash. -- `diff`: DA-layer diff encoded with `Da_layer.Diff.Stable.V3.to_yojson`. +- `diff`: DA-layer diff encoded with `Da_layer.Diff.Stable.V4.to_yojson`. ## Finality diff --git a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md index 2821a7631f..d2f828f549 100644 --- a/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md +++ b/src/app/zeko/sequencer/explorer/explorer-indexing-plan.md @@ -89,13 +89,13 @@ Fields: Encoding rules: - `source_ledger_hash`, `timestamp`, `acc_set`, and - `command_with_action_step_flags` come from the JSON shape derived from - `Da_layer.Diff.Stable.V3`. + `actions` come from the JSON shape derived from + `Da_layer.Diff.Stable.V4`. - `command` is a normalized envelope with `type`, `raw`, and `action_step_flags`, or `null` when the diff has no command. - `changed_accounts` is normalized to `{index, account}` objects. - `diff` retains the existing JSON shape derived from - `Da_layer.Diff.Stable.V3`. + `Da_layer.Diff.Stable.V4`. - `target_ledger_hash` is the post-diff ledger hash passed to `Da_layer.Client.enqueue_diff`. - `genesis` matches the flag passed to `Da_layer.Client.enqueue_diff`. diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index 883af4f3bb..1dabb9cc23 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -357,7 +357,7 @@ let publish_target t job ~current_source ~index ~target_ledger_hash = if not (Ledger_hash.equal - (Da_layer.Diff.Stable.V3.source_ledger_hash diff) + (Da_layer.Diff.Stable.V4.source_ledger_hash diff) current_source ) then Deferred.return diff --git a/src/app/zeko/sequencer/explorer/explorer_events.ml b/src/app/zeko/sequencer/explorer/explorer_events.ml index 3bc6ced579..2d5a9870e3 100644 --- a/src/app/zeko/sequencer/explorer/explorer_events.ml +++ b/src/app/zeko/sequencer/explorer/explorer_events.ml @@ -354,11 +354,11 @@ let connect_and_ensure_jetstream_strict ?timeout ~logger uri = let assoc_field_exn fields name = List.Assoc.find_exn fields name ~equal:String.equal -let transaction_command_payload command_with_action_step_flags = - match command_with_action_step_flags with - | None -> +let transaction_command_payload actions = + match actions with + | `Actions _ -> `Null - | Some (command, action_step_flags) -> + | `Command_with_action_step_flags (command, action_step_flags) -> let command_type, command_json = match command with | User_command.Signed_command command -> @@ -382,8 +382,18 @@ let changed_accounts_payload changed_accounts = ; ("account", Account.Stable.V2.to_yojson account) ] ) ) +let legacy_command_with_action_step_flags_payload actions = + match actions with + | `Actions _ -> + `Null + | `Command_with_action_step_flags (command, action_step_flags) -> + `List + [ User_command.Stable.V2.to_yojson command + ; `List (List.map action_step_flags ~f:(fun flag -> `Bool flag)) + ] + let build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff = - let diff_json = Da_layer.Diff.Stable.V3.to_yojson diff in + let diff_json = Da_layer.Diff.Stable.V4.to_yojson diff in let diff_fields = match diff_json with | `Assoc fields -> @@ -391,9 +401,7 @@ let build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff = | _ -> [] in - let command_with_action_step_flags = - Da_layer.Diff.Stable.V3.command_with_action_step_flags diff - in + let actions = Da_layer.Diff.Stable.V4.actions diff in { subject = Subject.transactions ; headers = nats_msg_id_headers target_ledger_hash ; payload = @@ -403,13 +411,13 @@ let build_transaction_message ~kind ~target_ledger_hash ~genesis ~diff = ; ("target_ledger_hash", Ledger_hash.to_yojson target_ledger_hash) ; ("timestamp", assoc_field_exn diff_fields "timestamp") ; ("acc_set", assoc_field_exn diff_fields "acc_set") - ; ( "command" - , transaction_command_payload command_with_action_step_flags ) + ; ("command", transaction_command_payload actions) ; ( "changed_accounts" , changed_accounts_payload - (Da_layer.Diff.Stable.V3.changed_accounts diff) ) + (Da_layer.Diff.Stable.V4.changed_accounts diff) ) ; ( "command_with_action_step_flags" - , assoc_field_exn diff_fields "command_with_action_step_flags" ) + , legacy_command_with_action_step_flags_payload actions ) + ; ("actions", assoc_field_exn diff_fields "actions") ; ("genesis", `Bool genesis) ; ("diff", diff_json) ] diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml index 0904f99b04..7042dc0c0c 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -76,8 +76,8 @@ let add_job (service : Explorer_backfill_service.t) let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = Explorer_events.build_live_diff ~logger:(Logger.create ()) ~diff: - (Da_layer.Diff.create ~source_ledger_hash ~changed_accounts:[] - ~command_with_action_step_flags:None ) + (Da_layer.Diff.create_pending ~source_ledger_hash ~changed_accounts:[] + ~actions:(`Actions []) ) ~acc_set_root:Snark_params.Tick.Field.zero let sse_request body = @@ -276,7 +276,7 @@ let%test_unit "Transaction events expose diff fields and NATS dedup header" ; let target_ledger_hash = Ledger_hash.empty_hash in let diff = sample_diff () in - let diff_json = Da_layer.Diff.Stable.V3.to_yojson diff in + let diff_json = Da_layer.Diff.Stable.V4.to_yojson diff in let message = Explorer_events.build_transaction_message ~kind:Explorer_events.Transaction_kind.Sync_replay ~target_ledger_hash diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml index f3f041411a..6dd62d36e3 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_nats_gherkin_tests.ml @@ -127,8 +127,8 @@ let publish_jetstream_message label client let sample_diff ?(source_ledger_hash = Ledger_hash.empty_hash) () = Explorer_events.build_live_diff ~logger:(Logger.create ()) ~diff: - (Da_layer.Diff.create ~source_ledger_hash ~changed_accounts:[] - ~command_with_action_step_flags:None ) + (Da_layer.Diff.create_pending ~source_ledger_hash ~changed_accounts:[] + ~actions:(`Actions []) ) ~acc_set_root:Snark_params.Tick.Field.zero let test_service nats_client : Explorer_backfill_service.t = diff --git a/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature b/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature index 88afa1a1b2..3f353b20a3 100644 --- a/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature +++ b/src/app/zeko/sequencer/explorer/tests/features/event-contract.feature @@ -9,6 +9,7 @@ Feature: Explorer Event Contract And the payload includes "target_ledger_hash" And the payload includes "changed_accounts" And the payload includes "command" + And the payload includes "actions" And the headers include "Nats-Msg-Id" Scenario: Finality events include level, ledger hashes, and NATS dedup header From 6ce703ec92cdd7d484f5d8d14415355e5a11598a Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 18:01:14 +0700 Subject: [PATCH 41/51] Address automated explorer review findings --- .../explorer/explorer_backfill_service.ml | 28 +++++++++++++++---- .../sequencer/explorer/explorer_events.ml | 8 ++---- .../explorer/tests/explorer_gherkin_tests.ml | 20 +++++++++++++ .../tests/features/backfill-api.feature | 6 ++++ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml index 1dabb9cc23..5e3540980c 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_service.ml @@ -91,6 +91,8 @@ let terminal_job_retention = Time.Span.of_hr 1. let backfill_interval_size = 1000 +let max_active_backfill_jobs = 1 + let genesis_hash = Da_layer.Diff.empty_ledger_hash ~depth:constraint_constants.ledger_depth @@ -229,6 +231,9 @@ let find_active_job_by_range t ~from_hash ~to_hash = && Ledger_hash.equal job.from_hash from_hash && Ledger_hash.equal job.to_hash to_hash ) +let active_job_count t = + Hashtbl.count t.jobs ~f:(fun job -> not (is_terminal job.status)) + let subscribe_progress t ~id = match find_job t id with | None -> @@ -487,12 +492,23 @@ let start_backfill t ~from_hash ~to_hash = (Ledger_hash.to_decimal_string to_hash) ; job | None -> - let job = - create_job ~status:Queued ~from_hash ~to_hash () - in - let job = register_job t job in - run_job t job ; - job + if active_job_count t >= max_active_backfill_jobs then ( + let now = Time.now () in + [%log debug] + "Rejecting explorer backfill job because active job limit is reached: \ + from_hash=%s to_hash=%s max_active=%d" + (Ledger_hash.to_decimal_string from_hash) + (Ledger_hash.to_decimal_string to_hash) + max_active_backfill_jobs ; + create_job ~status:Failed ~from_hash ~to_hash + ~error:"Another backfill job is already active" ~started_at:now + ~finished_at:now () + |> register_job t ) + else + let job = create_job ~status:Queued ~from_hash ~to_hash () in + let job = register_job t job in + run_job t job ; + job let failed_job_snapshot_from_strings ~from_hash ~to_hash error = let now = Time.now () in diff --git a/src/app/zeko/sequencer/explorer/explorer_events.ml b/src/app/zeko/sequencer/explorer/explorer_events.ml index 2d5a9870e3..ccf771a102 100644 --- a/src/app/zeko/sequencer/explorer/explorer_events.ml +++ b/src/app/zeko/sequencer/explorer/explorer_events.ml @@ -263,7 +263,7 @@ let ensure_jetstream_stream ~logger client = warn_jetstream ~logger "JetStream stream setup failed" ~metadata:[ ("error", `String (Error.to_string_hum error)) ] -let nats_uri_available ?(timeout = Time_ns.Span.of_sec 3.) ~logger uri = +let nats_uri_available ?(timeout = Time.Span.of_sec 3.) ~logger uri = match Uri.host uri with | None -> warn_jetstream ~logger "NATS URL is missing a host" @@ -274,11 +274,9 @@ let nats_uri_available ?(timeout = Time_ns.Span.of_sec 3.) ~logger uri = let where = Tcp.Where_to_connect.of_host_and_port (Host_and_port.create ~host ~port) in - Clock_ns.with_timeout timeout + Clock.with_timeout timeout (Monitor.try_with_or_error (fun () -> - let%bind _socket, reader, writer = Tcp.connect where in - let%bind () = Writer.close writer in - Reader.close reader ) ) + Tcp.with_connection where ~timeout (fun _ _ _ -> Deferred.unit) ) ) >>| function | `Timeout -> warn_jetstream ~logger diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml index 7042dc0c0c..c120338be2 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -238,6 +238,26 @@ let%test_unit "The backfill job query returns the current job snapshot" = (`String "running") ; assert_basic_json_equal (find_assoc_exn "diffsPublished" backfill_job) (`Int 0) +let%test_unit "Concurrent backfill requests are bounded" = + Feature_parser.assert_scenario "backfill-api.feature" + "Concurrent backfill requests are bounded" ; + let service = test_service () in + let _active_job = + add_job service ~status:Explorer_backfill_service.Running () + in + let rejected_job = + Explorer_backfill_service.start_backfill service + ~from_hash:Ledger_hash.empty_hash + ~to_hash:Explorer_backfill_service.genesis_hash + in + [%test_eq: string] + (Explorer_backfill_service.string_of_job_status rejected_job.status) + "failed" ; + [%test_eq: bool] + (Option.value_map rejected_job.error ~default:false ~f:(fun error -> + String.is_substring error ~substring:"active" ) ) + true + let%test_unit "Backfill progress subscriptions stream GraphQL-SSE events" = Feature_parser.assert_scenario "backfill-api.feature" "Backfill progress subscriptions stream GraphQL-SSE events" ; diff --git a/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature b/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature index 95e322930b..6c5cb1a3e4 100644 --- a/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature +++ b/src/app/zeko/sequencer/explorer/tests/features/backfill-api.feature @@ -31,6 +31,12 @@ Feature: Explorer Backfill API Then the response includes the matching job id And the response includes the job status + Scenario: Concurrent backfill requests are bounded + Given a standalone backfill service with an active backfill job + When another backfill is requested for a different range + Then the second backfill request is rejected + And the snapshot error mentions an active job + Scenario: Backfill progress subscriptions stream GraphQL-SSE events Given a standalone backfill service with a queued backfill job When the GraphQL-SSE endpoint subscribes to that job From 37961bbeabf7c8d77cfb2ae18f47b7382538613c Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 19:15:02 +0700 Subject: [PATCH 42/51] Address Copilot review findings --- src/app/zeko/sequencer/README.md | 10 +++++----- .../explorer/tests/explorer_gherkin_tests.ml | 6 +----- src/lib/graphql_wrapper/graphql_wrapper.ml | 14 ++++++++++++++ src/lib/mina_graphql/types.ml | 4 ++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index 71f731bb97..76fa3079cc 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -77,7 +77,7 @@ When NATS is enabled, the sequencer emits: - `genesis` - `diff` -`diff` uses the existing `Da_layer.Diff.Stable.V3` JSON shape and every +`diff` uses the existing `Da_layer.Diff.Stable.V4` JSON shape and every transaction message includes `Nats-Msg-Id: `. `zeko.l2.finality` carries: @@ -101,10 +101,10 @@ Every finality message includes - `timestamp` When NATS is configured, the sequencer startup path best-effort configures the -Zeko-owned JetStream stream `ZEKO_L2` for `zeko.l2.transactions`, -`zeko.l2.finality`, and `zeko.health`. If NATS is down or JetStream setup fails, -startup logs a warning and explorer publishing stays disabled or drops messages -rather than crashing the sequencer. +Zeko-owned JetStream streams `zeko-l2` for `zeko.l2.transactions` and +`zeko.l2.finality`, and `zeko-health` for `zeko.health`. If NATS is down or +JetStream setup fails, startup logs a warning and explorer publishing stays +disabled or drops messages rather than crashing the sequencer. Run help to see the options: diff --git a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml index c120338be2..fdf0bba86a 100644 --- a/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml +++ b/src/app/zeko/sequencer/explorer/tests/explorer_gherkin_tests.ml @@ -380,11 +380,7 @@ let%test_unit "Health events include the service identity and publishing state" let%test_unit "Disabled NATS publishing is a no-op" = Feature_parser.assert_scenario "event-contract.feature" "Disabled NATS publishing is a no-op" ; - let client = - Thread_safe.block_on_async_exn (fun () -> Nats_client_async.connect None) - in - let sink = Explorer_events.create_nats_sink client in - Explorer_events.publish_transaction sink + Explorer_events.publish_transaction Explorer_events.noop_sink ~kind:Explorer_events.Transaction_kind.User_command ~target_ledger_hash:Ledger_hash.empty_hash ~genesis:false ~diff:(sample_diff ()) diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index ecfd4630e6..e7f3e446e8 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -46,6 +46,14 @@ module Make (Schema : Graphql_intf.Schema) = struct `Null | `Int i -> `Int i + | `Intlit s -> ( + match int_of_string s with + | i -> + `Int i + | exception Failure _ -> + failwith + (Printf.sprintf + "GraphQL default integer literal is out of range: %s" s ) ) | `Float f -> `Float f | `String s -> @@ -54,6 +62,12 @@ module Make (Schema : Graphql_intf.Schema) = struct `Bool b | `List xs -> `List (List.map const_value_of_json xs) + | `Tuple xs -> + `List (List.map const_value_of_json xs) + | `Variant (name, _) -> + failwith + (Printf.sprintf + "GraphQL default values do not support variant JSON: %s" name ) | `Assoc fields -> `Assoc (List.map (fun (name, value) -> (name, const_value_of_json value)) fields) diff --git a/src/lib/mina_graphql/types.ml b/src/lib/mina_graphql/types.ml index c058e1db34..b8f10f14b7 100644 --- a/src/lib/mina_graphql/types.ml +++ b/src/lib/mina_graphql/types.ml @@ -3078,7 +3078,7 @@ module Input = struct end module RosettaTransaction = struct - type input = Yojson.Basic.t + type input = Signed_command.t let to_default_json (command : Signed_command.t) = let public_key pk = `Pk (Public_key.Compressed.to_base58_check pk) in @@ -3134,7 +3134,7 @@ module Input = struct ~coerce:(fun graphql_json -> Rosetta_lib.Transaction.to_mina_signed (Utils.to_yojson graphql_json) |> Result.map_error ~f:Error.to_string_hum ) - ~to_json:(Fn.id : input -> input) + ~to_json:to_default_json ~to_default_json end From a02cafe0fc0fdcde618b666e1d919794481c59b1 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 19:24:59 +0700 Subject: [PATCH 43/51] Trim existing-file explorer changes --- .github/workflows/ci.yaml | 1 - src/app/zeko/da_layer/lib/core.ml | 2 +- src/app/zeko/sequencer/README.md | 109 ++---------------- src/app/zeko/sequencer/lib/dune | 2 - src/app/zeko/sequencer/lib/zeko_sequencer.ml | 4 - src/app/zeko/sequencer/run.ml | 3 - .../zeko/sequencer/tests/sequencer_test.ml | 11 +- src/app/zeko/sequencer/tests/test_spec.ml | 2 +- 8 files changed, 17 insertions(+), 117 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af64fe200e..0131b57654 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,3 @@ -# Builds and tests Zeko in CI using the exported opam toolchain. --- name: CI diff --git a/src/app/zeko/da_layer/lib/core.ml b/src/app/zeko/da_layer/lib/core.ml index d4a94ad543..e182166e11 100644 --- a/src/app/zeko/da_layer/lib/core.ml +++ b/src/app/zeko/da_layer/lib/core.ml @@ -12,7 +12,7 @@ module Field = Snark_params.Tick.Field 6. Check that after applying all the receipts of the command, the receipt chain hashes match the target ledger. 7. Check that new accounts in ledger openings are in same order as in acc set openings. 8. Attach timestamp and acc set root. - 9. Store the diff under the [target_ledger_hash]. + 9. Store the diff under the [target_ledger_hash]. 10. Sign [target_ledger_hash]. *) let post_diff ~logger ~proof_cache_db ~kvdb ~network_id ~(signer : Signer_service.Signer.t) ~ledger_openings ~acc_set_openings diff --git a/src/app/zeko/sequencer/README.md b/src/app/zeko/sequencer/README.md index 76fa3079cc..b3ff54a973 100644 --- a/src/app/zeko/sequencer/README.md +++ b/src/app/zeko/sequencer/README.md @@ -11,11 +11,6 @@ Think of the sequencer as the conductor of an orchestra in Zeko. It plays a vita ## Build -The repo-managed OCaml setup imports `opam.export`. The explorer NATS client -dependencies are the published opam packages `nats-client` and -`nats-client-async`; no `nats-ml` submodule checkout or GitHub opam pin is -required. - ```bash DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer ``` @@ -25,14 +20,8 @@ DUNE_PROFILE=devnet dune build ./src/app/zeko/sequencer ```bash dune build ./src/app/zeko/sequencer/tests/run-sequencer-test.sh {fake | real} -NATS_URL="nats://127.0.0.1:4222" opam exec -- env -u DUNE_RPC dune runtest --profile=devnet src/app/zeko/sequencer/explorer/tests ``` -The explorer Gherkin suite includes NATS-backed scenarios. `NATS_URL` must be -set and point at a running NATS server with JetStream enabled when running -those tests outside CI. The message contract is documented in -`src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md`. - ## Run Running the sequencer exposes the Graphql API on the port `-p`. The Graphql schema is a subset of the L1 Graphql API joined with the L1 Graphql API for fetching of actions/events. @@ -64,47 +53,14 @@ dune exec ./run.exe -- \ `--nats-url` enables explorer event publishing. Without it, the sequencer keeps the existing behavior and all NATS publish paths become no-ops. -When NATS is enabled, the sequencer emits: +When NATS is enabled, the sequencer emits these NATS subjects: - `zeko.l2.transactions` - `zeko.l2.finality` - `zeko.health` -`zeko.l2.transactions` carries: - -- `kind`: `user_command`, `fee_transfer`, `sync_replay`, or `genesis_replay` -- `target_ledger_hash` -- `genesis` -- `diff` - -`diff` uses the existing `Da_layer.Diff.Stable.V4` JSON shape and every -transaction message includes `Nats-Msg-Id: `. - -`zeko.l2.finality` carries: - -- `status`: `proved` or `committed` -- `source_ledger_hash` -- `target_ledger_hash` -- `timestamp` - -Every finality message includes -`Nats-Msg-Id: finality--`, for example -`finality-proved-...` or `finality-committed-...`. - -`zeko.health` carries: - -- `service` -- `instance_id` -- `status` -- `last_published_hash` -- `unproved_hash` -- `timestamp` - -When NATS is configured, the sequencer startup path best-effort configures the -Zeko-owned JetStream streams `zeko-l2` for `zeko.l2.transactions` and -`zeko.l2.finality`, and `zeko-health` for `zeko.health`. If NATS is down or -JetStream setup fails, startup logs a warning and explorer publishing stays -disabled or drops messages rather than crashing the sequencer. +The exact subjects, JetStream streams, payload fields, and dedup headers are +documented in `src/app/zeko/sequencer/explorer/MESSAGE_CONTRACT.md`. Run help to see the options: @@ -114,9 +70,6 @@ dune exec ./run.exe -- --help ## Explorer backfill service -The backfill service republishes historical DA diffs into the same -`zeko.l2.transactions` NATS subject used by the live sequencer path. - Build it with: ```bash @@ -133,16 +86,9 @@ dune exec ./explorer/explorer_backfill_server.exe -- \ --nats-url ``` -The backfill API is a separate process from the sequencer GraphQL API. By -default the sequencer listens on `8080` and the standalone backfill server -listens on `8090`, so they do not conflict unless you explicitly bind both to -the same port. - -The backfill GraphQL HTTP endpoint stays on `/graphql`. -The service requires `--nats-url` because each backfill republishes historical -diffs into NATS. Unlike the live sequencer publisher, the standalone backfill -service fails startup when it cannot connect to JetStream, and a dropped publish -marks the backfill job as failed. +The backfill API is a separate process from the sequencer GraphQL API and +republishes historical DA diffs into `zeko.l2.transactions`. It requires +`--nats-url` because every backfill job publishes to JetStream. Available GraphQL operations: @@ -150,49 +96,16 @@ Available GraphQL operations: - `backfillJob(id) -> BackfillJob | null` - `health -> Health` -The GraphQL-SSE endpoint is `/graphql/stream`. - -Supported subscription: +GraphQL-SSE endpoint: - `backfillProgress(id) -> BackfillProgress` -Use the same GraphQL subscription document against `/graphql/stream`; the -service executes the schema subscription and streams `backfillProgress` -responses as GraphQL-SSE events. - -`BackfillJob` fields: - -- `id` -- `fromHash` -- `toHash` -- `status` -- `diffsPublished` -- `error` -- `createdAt` -- `startedAt` -- `finishedAt` - -`BackfillProgress` fields: - -- `id` -- `status` -- `diffsPublished` -- `error` - -`Health` fields: - -- `ok` -- `instanceId` -- `startedAt` - ## Explorer acceptance tests -The explorer-specific tests now live under -`src/app/zeko/sequencer/explorer/tests/` and use copied `.feature` files from -the explorer spike as the main test inventory. CI runs them in the dedicated -Explorer Gherkin workflow, separate from the longer sequencer integration flow. -The real-NATS scenarios expect `NATS_URL` to point at a running NATS server -with JetStream enabled. +Explorer-specific Gherkin tests live under +`src/app/zeko/sequencer/explorer/tests/`. See +`src/app/zeko/sequencer/explorer/tests/README.md` for the fast integration, +NATS-backed, and E2E commands. ## Deploy rollup contract to L1 diff --git a/src/app/zeko/sequencer/lib/dune b/src/app/zeko/sequencer/lib/dune index 57ba51e48f..8540ae8239 100644 --- a/src/app/zeko/sequencer/lib/dune +++ b/src/app/zeko/sequencer/lib/dune @@ -1,5 +1,3 @@ -; Core sequencer library plus dedicated inline test modules. - (library (name sequencer_lib) (inline_tests) diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index ec04edbaf3..610156b509 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -1,7 +1,3 @@ -(* Implements the Zeko sequencer runtime, including transaction application, - DA-layer synchronization, proof/commit orchestration, and explorer event - publishing hooks. *) - open Core_kernel open Async_kernel open Mina_base diff --git a/src/app/zeko/sequencer/run.ml b/src/app/zeko/sequencer/run.ml index b3d483e439..23e13510d2 100644 --- a/src/app/zeko/sequencer/run.ml +++ b/src/app/zeko/sequencer/run.ml @@ -1,6 +1,3 @@ -(* Boots the main Zeko sequencer HTTP service and threads runtime CLI options, - including optional explorer NATS publishing, into the sequencer runtime. *) - open Core open Async open Sequencer_lib diff --git a/src/app/zeko/sequencer/tests/sequencer_test.ml b/src/app/zeko/sequencer/tests/sequencer_test.ml index 2648e33f91..db4b4f1ba0 100644 --- a/src/app/zeko/sequencer/tests/sequencer_test.ml +++ b/src/app/zeko/sequencer/tests/sequencer_test.ml @@ -228,7 +228,7 @@ let () = let new_sequencer = run (fun () -> let%map new_sequencer = - Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 + Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. ~da_config:da_config_with2 ~da_keys ~da_quorum ~db_dir:None ~checkpoints_dir:None ~postgres_uri:postgres_uri2 ~l1_uri:gql_uri ~archive_uri:gql_uri @@ -333,8 +333,7 @@ let () = print_endline "(* Restart sequencer *)" ; let new_sequencer = run (fun () -> - Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 - ~commitment_period_sec:0. + Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. ~da_config:da_config_with3 ~da_quorum ~db_dir:(Some db_dir) ~checkpoints_dir:None ~postgres_uri ~l1_uri:gql_uri ~archive_uri:gql_uri ~signer:(get_test_signer ()) @@ -466,8 +465,7 @@ let () = print_endline "(* Restart sequencer from checkpoint *)" ; let new_sequencer = run (fun () -> - Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 - ~commitment_period_sec:0. + Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. ~da_config:da_config_with3 ~da_quorum ~db_dir:(Some db_dir2) ~checkpoints_dir:(Some checkpoints_dir) ~postgres_uri:postgres_uri2 ~l1_uri:gql_uri ~archive_uri:gql_uri @@ -575,8 +573,7 @@ let () = print_endline "(* Restart sequencer *)" ; let new_sequencer = run (fun () -> - Sequencer.create ?nats_url:None ~logger ~max_pool_size:10 - ~commitment_period_sec:0. + Sequencer.create ~logger ~max_pool_size:10 ~commitment_period_sec:0. ~da_config:da_config_with2 ~da_quorum ~db_dir:(Some db_dir) ~checkpoints_dir:None ~postgres_uri ~l1_uri:gql_uri ~archive_uri:gql_uri ~signer:(get_test_signer ()) diff --git a/src/app/zeko/sequencer/tests/test_spec.ml b/src/app/zeko/sequencer/tests/test_spec.ml index 3d1fc688cb..9cd1f32932 100644 --- a/src/app/zeko/sequencer/tests/test_spec.ml +++ b/src/app/zeko/sequencer/tests/test_spec.ml @@ -555,7 +555,7 @@ module Sequencer_spec = struct print_endline "(* Init sequencer *)" ; let sequencer = - run (fun () -> + run (fun () -> Sequencer.create ?nats_url ~logger ~max_pool_size:10 ~commitment_period_sec:0. ~da_config ~da_keys ~da_quorum ~db_dir ~postgres_uri ~l1_uri:gql_uri From 8fd3ac774371ada87f3099c31bf50f4c5b5205bf Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 19:26:46 +0700 Subject: [PATCH 44/51] Fix CI fallout after review cleanup --- src/app/zeko/sequencer/tests/run-sequencer-test.sh | 2 +- src/lib/graphql_wrapper/graphql_wrapper.ml | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/app/zeko/sequencer/tests/run-sequencer-test.sh b/src/app/zeko/sequencer/tests/run-sequencer-test.sh index d63223a0b7..6abba0ada3 100755 --- a/src/app/zeko/sequencer/tests/run-sequencer-test.sh +++ b/src/app/zeko/sequencer/tests/run-sequencer-test.sh @@ -79,7 +79,7 @@ cleanup() { kill_process_tree KILL "$pid" done - rm -rf "$TMP_DIR" + [ -z "${TMP_DIR:-}" ] || rm -rf "$TMP_DIR" docker rm -f pg-sequencer 2>/dev/null docker rm -f rabbitmq-sequencer 2>/dev/null exit ${exit_status:-0} diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index e7f3e446e8..ecfd4630e6 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -46,14 +46,6 @@ module Make (Schema : Graphql_intf.Schema) = struct `Null | `Int i -> `Int i - | `Intlit s -> ( - match int_of_string s with - | i -> - `Int i - | exception Failure _ -> - failwith - (Printf.sprintf - "GraphQL default integer literal is out of range: %s" s ) ) | `Float f -> `Float f | `String s -> @@ -62,12 +54,6 @@ module Make (Schema : Graphql_intf.Schema) = struct `Bool b | `List xs -> `List (List.map const_value_of_json xs) - | `Tuple xs -> - `List (List.map const_value_of_json xs) - | `Variant (name, _) -> - failwith - (Printf.sprintf - "GraphQL default values do not support variant JSON: %s" name ) | `Assoc fields -> `Assoc (List.map (fun (name, value) -> (name, const_value_of_json value)) fields) From badc21c553875f6fc61dfd5ed8589e844e71453b Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 19:55:39 +0700 Subject: [PATCH 45/51] Reduce explorer PR diff noise --- .github/workflows/explorer-e2e.yaml | 2 +- .../zeko/sequencer/explorer/tests/README.md | 23 +- .../explorer/tests/run-explorer-e2e-test.sh | 268 ++++++++++++++++++ src/app/zeko/sequencer/lib/gql.ml | 50 ++-- .../sequencer/tests/run-sequencer-test.sh | 68 +---- .../sequencer/tests/testing_ledger/gql.ml | 50 ++-- 6 files changed, 339 insertions(+), 122 deletions(-) create mode 100755 src/app/zeko/sequencer/explorer/tests/run-explorer-e2e-test.sh diff --git a/.github/workflows/explorer-e2e.yaml b/.github/workflows/explorer-e2e.yaml index 5e608bf4cc..7fb569edac 100644 --- a/.github/workflows/explorer-e2e.yaml +++ b/.github/workflows/explorer-e2e.yaml @@ -42,4 +42,4 @@ jobs: sleep 1 done NATS_URL="nats://127.0.0.1:4222" \ - ./src/app/zeko/sequencer/tests/run-sequencer-test.sh real 1 explorer-e2e + ./src/app/zeko/sequencer/explorer/tests/run-explorer-e2e-test.sh real 1 diff --git a/src/app/zeko/sequencer/explorer/tests/README.md b/src/app/zeko/sequencer/explorer/tests/README.md index c848622830..7744d36c60 100644 --- a/src/app/zeko/sequencer/explorer/tests/README.md +++ b/src/app/zeko/sequencer/explorer/tests/README.md @@ -91,23 +91,22 @@ and assert behavior from the outside where practical: GraphQL `backfill` mutation over HTTP, and assert both GraphQL-SSE progress and replayed NATS messages -The current E2E entry point reuses the existing sequencer integration service -setup: real L1 test ledger, DA nodes, signers, RabbitMQ, Postgres, and NATS. -The sequencer runtime is still driven through `Sequencer.create`, matching the -existing sequencer integration tests. A later follow-up can move this to a -fully external `run.exe` process once the bootstrap/deploy path is ready for -that shape. - -Unlike the shared sequencer harness path, the `explorer-e2e` mode in -`run-sequencer-test.sh` still builds its own dedicated test executable and -service binaries because the standalone `Explorer E2E` workflow does not run a -separate prebuild step first. +The current E2E script starts the same service shape as the sequencer +integration test: real L1 test ledger, DA nodes, signers, RabbitMQ, Postgres, +and NATS. The sequencer runtime is still driven through `Sequencer.create`, +matching the existing sequencer integration tests. A later follow-up can move +this to a fully external `run.exe` process once the bootstrap/deploy path is +ready for that shape. + +The explorer E2E script is separate from `run-sequencer-test.sh` so the +existing sequencer harness keeps its original interface and the explorer suite +can build and run its dedicated Gherkin executable explicitly. Run it with: ```bash NATS_URL="nats://127.0.0.1:4222" \ - ./src/app/zeko/sequencer/tests/run-sequencer-test.sh real 1 explorer-e2e + ./src/app/zeko/sequencer/explorer/tests/run-explorer-e2e-test.sh real 1 ``` The E2E harness should remain separate from the faster integration suite so diff --git a/src/app/zeko/sequencer/explorer/tests/run-explorer-e2e-test.sh b/src/app/zeko/sequencer/explorer/tests/run-explorer-e2e-test.sh new file mode 100755 index 0000000000..d84fa7d8c1 --- /dev/null +++ b/src/app/zeko/sequencer/explorer/tests/run-explorer-e2e-test.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 [wait_for_port?]" >&2 + exit 1 +} + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + usage +fi + +MODE="$1" +NUM_PROVERS="$2" +WAIT_FOR_PORT="${3:-true}" +PROVER_PIDS=() +SIGNER_PIDS=() + +case "$MODE" in +fake | real) ;; +*) + echo "Error: first argument must be 'fake' or 'real'" >&2 + usage + ;; +esac + +if ! [[ "$NUM_PROVERS" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: second argument must be a positive integer" >&2 + usage +fi + +if [ -z "${NATS_URL:-}" ]; then + echo "Error: NATS_URL must point at a running NATS server" >&2 + exit 1 +fi + +cleanup() { + local exit_status=$1 + echo "Cleaning up..." + local -a service_pids=( + "${l1_pid:-}" + "${da1_pid:-}" + "${da2_pid:-}" + "${da3_pid:-}" + "${PROVER_PIDS[@]}" + "${SIGNER_PIDS[@]}" + ) + + trap - SIGINT SIGTERM EXIT + + for pid in "${service_pids[@]}"; do + [ -n "$pid" ] || continue + kill_process_tree TERM "$pid" + done + + sleep 1 + + for pid in "${service_pids[@]}"; do + [ -n "$pid" ] || continue + kill_process_tree KILL "$pid" + done + + [ -z "${TMP_DIR:-}" ] || rm -rf "$TMP_DIR" + docker rm -f pg-sequencer 2>/dev/null + docker rm -f rabbitmq-sequencer 2>/dev/null + exit "${exit_status:-0}" +} + +REPO_ROOT="$(git rev-parse --show-toplevel)" +SEQUENCER_BUILD_ROOT="$REPO_ROOT/_build/default/src/app/zeko/sequencer" +SIGNER_BUILD_ROOT="$REPO_ROOT/_build/default/src/app/zeko/signer" + +export ZEKO_SIGNATURE_KIND=zeko-testnet +export ZEKO_CIRCUITS_CONFIG=test + +TMP_DIR=$(mktemp -d) + +list_child_pids() { + local parent_pid="$1" + ps -axo pid=,ppid= | awk -v parent="$parent_pid" '$2 == parent { print $1 }' +} + +kill_process_tree() { + local signal="$1" + local pid="$2" + local child_pid + + while read -r child_pid; do + [ -n "$child_pid" ] || continue + kill_process_tree "$signal" "$child_pid" + done < <(list_child_pids "$pid") + + kill "-$signal" "$pid" 2>/dev/null || true +} + +trap 'cleanup 1' SIGINT SIGTERM +trap 'cleanup $?' EXIT + +wait_for_port() { + if [ "$WAIT_FOR_PORT" = "true" ]; then + local port=$1 + local pid=$2 + while ! nc -z localhost "$port"; do + sleep 1 + + if ! kill -0 "$pid" 2>/dev/null; then + echo "Process for port $port failed to start" + exit 1 + fi + done + + echo "Port $port is now open" + else + echo "Waiting for 2 seconds..." + sleep 2 + fi +} + +wait_for_queue_consumers() { + local queue=$1 + local expected_consumers=$2 + local timeout_seconds=${3:-300} + + for _ in $(seq 1 "$timeout_seconds"); do + if docker exec rabbitmq-sequencer rabbitmqctl -q list_queues name consumers \ + | awk -v queue="$queue" -v expected="$expected_consumers" \ + '$1 == queue && $2 >= expected { found = 1 } END { exit found ? 0 : 1 }'; then + echo "Queue $queue has at least $expected_consumers consumers" + return + fi + + for pid in "${PROVER_PIDS[@]}"; do + if ! kill -0 "$pid" 2>/dev/null; then + echo "Prover process $pid exited before registering as a queue consumer" + exit 1 + fi + done + + sleep 1 + done + + echo "Timed out waiting for $expected_consumers consumers on queue $queue" + docker logs rabbitmq-sequencer || true + exit 1 +} + +run() { + local name="$1" + shift + + bash -c ' + name="$1" + shift + + exec stdbuf -oL -eL "$@" \ + > >(awk -v n="$name" '"'"'{ print "[" n "] " $0; fflush(); }'"'"') \ + 2> >(awk -v n="$name" '"'"'{ print "[" n "][ERR] " $0; fflush(); }'"'"' >&2) + ' bash "$name" "$@" +} + +KEYGEN_BIN="$SEQUENCER_BUILD_ROOT/cli.exe" + +generate_even_key() { + "$KEYGEN_BIN" generate-even-key | awk -F': ' '/Private key:/ {print $2}' +} + +opam exec -- env -u DUNE_RPC dune build \ + src/app/zeko/sequencer/tests/testing_ledger/run.exe \ + src/app/zeko/da_layer/cli.exe \ + src/app/zeko/sequencer/cli.exe \ + src/app/zeko/sequencer/prover/cli.exe \ + src/app/zeko/sequencer/prover/cli_fake.exe \ + src/app/zeko/signer/cli.exe \ + src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.exe + +docker run --rm --name pg-sequencer \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + --tmpfs /var/lib/postgresql/data:rw,noexec,nosuid \ + -p 5433:5432 \ + -d postgres:16-alpine + +docker run -d --name rabbitmq-sequencer \ + -p 5672:5672 \ + rabbitmq:3.13.7 + +wait_for_port 5433 $$ +wait_for_port 5672 $$ + +SEQUENCER_SIGNER_BIN="$SIGNER_BUILD_ROOT/cli.exe" +DA_SIGNER_BIN="$SIGNER_BUILD_ROOT/cli.exe" + +ZEKO_TEST_SEQUENCER_SIGNER_PRIVATE_KEY="$(generate_even_key)" +DA1_SIGNER_PRIVATE_KEY="$(generate_even_key)" +DA2_SIGNER_PRIVATE_KEY="$(generate_even_key)" +DA3_SIGNER_PRIVATE_KEY="$(generate_even_key)" + +export ZEKO_TEST_SEQUENCER_SIGNER="127.0.0.1:8600" +export ZEKO_TEST_SEQUENCER_SIGNER_PRIVATE_KEY + +run "sequencer-signer" \ + env MINA_PRIVATE_KEY="$ZEKO_TEST_SEQUENCER_SIGNER_PRIVATE_KEY" \ + "$SEQUENCER_SIGNER_BIN" run --port 8600 --allow-zkapp-signing --max-fee 10 --max-balance-change 1000000 & +signer_seq_pid=$! +SIGNER_PIDS+=("$signer_seq_pid") + +run "da1-signer" \ + env MINA_PRIVATE_KEY="$DA1_SIGNER_PRIVATE_KEY" \ + "$DA_SIGNER_BIN" run --port 8601 --allow-field-signing & +signer_da1_pid=$! +SIGNER_PIDS+=("$signer_da1_pid") + +run "da2-signer" \ + env MINA_PRIVATE_KEY="$DA2_SIGNER_PRIVATE_KEY" \ + "$DA_SIGNER_BIN" run --port 8602 --allow-field-signing & +signer_da2_pid=$! +SIGNER_PIDS+=("$signer_da2_pid") + +run "da3-signer" \ + env MINA_PRIVATE_KEY="$DA3_SIGNER_PRIVATE_KEY" \ + "$DA_SIGNER_BIN" run --port 8603 --allow-field-signing & +signer_da3_pid=$! +SIGNER_PIDS+=("$signer_da3_pid") + +wait_for_port 8600 "$signer_seq_pid" +wait_for_port 8601 "$signer_da1_pid" +wait_for_port 8602 "$signer_da2_pid" +wait_for_port 8603 "$signer_da3_pid" + +run "l1" "$SEQUENCER_BUILD_ROOT/tests/testing_ledger/run.exe" -p 8080 --db-dir "$TMP_DIR/l1_db" --network-id mainnet --block-period 9999999 & +l1_pid=$! + +run "da1" "$SEQUENCER_BUILD_ROOT/../da_layer/cli.exe" run-node --port 8555 --healthcheck-port 8558 --network-id zeko-testnet --db-dir "$TMP_DIR/da1_db" --signer 127.0.0.1:8601 & +da1_pid=$! + +run "da2" "$SEQUENCER_BUILD_ROOT/../da_layer/cli.exe" run-node --port 8556 --healthcheck-port 8559 --network-id zeko-testnet --db-dir "$TMP_DIR/da2_db" --signer 127.0.0.1:8602 & +da2_pid=$! + +run "da3" "$SEQUENCER_BUILD_ROOT/../da_layer/cli.exe" run-node --port 8557 --healthcheck-port 8560 --network-id zeko-testnet --db-dir "$TMP_DIR/da3_db" --signer 127.0.0.1:8603 & +da3_pid=$! + +if [ "$MODE" = "fake" ]; then + sleep 5 + PROVER_BIN="$SEQUENCER_BUILD_ROOT/prover/cli_fake.exe" +else + PROVER_BIN="$SEQUENCER_BUILD_ROOT/prover/cli.exe" +fi + +for ((i = 0; i < NUM_PROVERS; i++)); do + run "prover-$i" \ + env ZEKO_CIRCUITS_MODE="$MODE" \ + "$PROVER_BIN" run-server --mq-host "localhost:5672" & + PROVER_PIDS+=("$!") +done + +echo "Waiting for services to start..." + +wait_for_port 8080 "$l1_pid" +wait_for_port 8555 "$da1_pid" +wait_for_port 8556 "$da2_pid" +wait_for_port 8557 "$da3_pid" +wait_for_queue_consumers sequencer.jobs "$NUM_PROVERS" + +echo "All services started successfully" + +run "explorer-e2e" \ + env ZEKO_CIRCUITS_MODE="$MODE" \ + "$SEQUENCER_BUILD_ROOT/explorer/tests/explorer_e2e_gherkin_tests.exe" diff --git a/src/app/zeko/sequencer/lib/gql.ml b/src/app/zeko/sequencer/lib/gql.ml index c9bd50f347..898d98fa5f 100644 --- a/src/app/zeko/sequencer/lib/gql.ml +++ b/src/app/zeko/sequencer/lib/gql.ml @@ -79,7 +79,7 @@ module Types = struct ; field "slotDuration" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.slot_duration) - ]) + ] ) in obj "DaemonStatus" ~fields:(fun _ -> [ field "chainId" ~typ:(non_null string) @@ -89,7 +89,7 @@ module Types = struct ~typ:(non_null consensus_configuration) ~args:Arg.[] ~resolve:(fun _ v -> v.consensus_configuration) - ]) + ] ) end let merkle_path_element : @@ -104,7 +104,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ x -> match x with `Left _ -> None | `Right h -> Some h ) - ]) + ] ) let account_timing : (Context.t, Account_timing.t option) typ = obj "AccountTiming" ~fields:(fun _ -> @@ -153,7 +153,7 @@ module Types = struct None | Timed timing_info -> Some timing_info.vesting_increment ) - ]) + ] ) let genesis_constants = obj "GenesisConstants" ~fields:(fun _ -> @@ -170,7 +170,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ () -> Time.now () |> Time.to_string_iso8601_basic ~zone:Time.Zone.utc ) - ]) + ] ) module AccountObj = struct module AnnotatedBalance = struct @@ -266,7 +266,7 @@ module Types = struct ~resolve:(fun _ (b : t) -> Option.map b.breadcrumb ~f:(fun crumb -> Transition_frontier.Breadcrumb.state_hash crumb ) ) - ]) + ] ) end module Partial_account = struct @@ -417,7 +417,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ (_, version) -> Mina_numbers.Txn_version.to_string version ) - ]) + ] ) let account_permissions = obj "AccountPermissions" ~fields:(fun _ -> @@ -490,7 +490,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ permission -> permission.Permissions.Poly.set_timing ) - ]) + ] ) let account_vk = obj "AccountVerificationKeyWithHash" ~doc:"Verification key with hash" @@ -507,7 +507,7 @@ module Types = struct @@ Pickles_graphql.Graphql_scalars.VerificationKeyHash.typ () ) ~args:Arg.[] ~resolve:(fun _ (vk : _ With_hash.t) -> vk.hash) - ]) + ] ) let rec account = lazy @@ -700,7 +700,7 @@ module Types = struct ~typ:(list (non_null merkle_path_element)) ~args:Arg.[] ~resolve:(fun _ _ -> None) - ] )) + ] ) ) let account = Lazy.force account end @@ -727,7 +727,7 @@ module Types = struct "Failure reason for the account update or any nested zkapp \ command" ~resolve:(fun _ (_, failures) -> failures) - ]) + ] ) end module User_command = struct @@ -813,7 +813,7 @@ module Types = struct (Mina_base_graphql.Graphql_scalars.TransactionStatusFailure.typ () ) ~args:[] ~doc:"null is no failure, reason for failure otherwise." - ]) + ] ) module With_status = struct type 'a t = { data : 'a; status : Command_status.t } @@ -1007,7 +1007,7 @@ module Types = struct (List.map (Transaction_status.Failure.Collection.to_display failures ) ~f:(fun f -> Some f) ) ) - ]) + ] ) end module State_hashes = struct @@ -1032,7 +1032,7 @@ module Types = struct ~resolve:(fun _ t -> Field.to_string Zeko_sequencer.State_hashes.(t.committed_ledger_hash) ) - ]) + ] ) end module Circuits_config = struct @@ -1113,7 +1113,7 @@ module Types = struct ~doc:"Payment that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ]) + ] ) let send_zkapp = obj "SendZkappPayload" ~fields:(fun _ -> @@ -1122,14 +1122,14 @@ module Types = struct ~doc:"zkApp transaction that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ]) + ] ) let proof_key = obj "ProofKeyPayload" ~fields:(fun _ -> [ field "key" ~typ:(non_null string) ~doc:"Key for querying the proof" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ]) + ] ) end module Input = struct @@ -2078,7 +2078,7 @@ module Types = struct ; field "distanceFromMaxBlockHeight" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.distance_from_max_block_height) - ]) + ] ) end module TransactionInfo = struct @@ -2112,7 +2112,7 @@ module Types = struct ~typ:(non_null @@ list @@ non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.zkapp_account_update_ids) - ]) + ] ) end module StupidActionState = struct @@ -2151,7 +2151,7 @@ module Types = struct Option.map (Pickles_types.Vector.nth action_state 4) ~f:Snark_params.Tick.Field.to_string ) - ]) + ] ) end module ActionData = struct @@ -2172,7 +2172,7 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, _, transaction_info) -> transaction_info) - ]) + ] ) end module EventData = struct @@ -2189,7 +2189,7 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, transaction_info) -> transaction_info) - ]) + ] ) end module ActionOutput = struct @@ -2197,7 +2197,6 @@ module Types = struct let t : ('context, t option) typ = obj "ActionOutput" ~fields:(fun _ -> - ( let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -2215,7 +2214,7 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] )) + ] ) end module EventOutput = struct @@ -2223,7 +2222,6 @@ module Types = struct let t : ('context, t option) typ = obj "EventOutput" ~fields:(fun _ -> - ( let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -2233,7 +2231,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] )) + ] ) end end end diff --git a/src/app/zeko/sequencer/tests/run-sequencer-test.sh b/src/app/zeko/sequencer/tests/run-sequencer-test.sh index 6abba0ada3..6ee2ea8956 100755 --- a/src/app/zeko/sequencer/tests/run-sequencer-test.sh +++ b/src/app/zeko/sequencer/tests/run-sequencer-test.sh @@ -3,11 +3,10 @@ set -euo pipefail usage() { echo "Usage: $0 " >&2 - echo " or: $0 explorer-e2e" >&2 exit 1 } -if [ "$#" -ne 3 ] && [ "$#" -ne 4 ]; then +if [ "$#" -ne 4 ]; then usage fi @@ -17,16 +16,14 @@ PROVER_PIDS=() SIGNER_PIDS=() PROVERS=() -if [ "$#" -eq 3 ]; then - TEST_TARGET="$3" - AGENT="false" - WAIT_FOR_PORT="true" -else - TEST_TARGET="sequencer" - AGENT="$3" - WAIT_FOR_PORT="$4" +AGENT="$3" +if [ "$AGENT" = "true" ]; then + echo "Redirecting output to /tmp/sequencer_test_output.log" + exec > /tmp/sequencer_test_output.log 2>&1 fi +WAIT_FOR_PORT="$4" + case "$MODE" in fake | real) ;; *) @@ -40,19 +37,6 @@ if ! [[ "$NUM_PROVERS" =~ ^[1-9][0-9]*$ ]]; then usage fi -case "$TEST_TARGET" in -sequencer | explorer-e2e) ;; -*) - echo "Error: third argument must be 'explorer-e2e' when using 3-arg mode" >&2 - usage - ;; -esac - -if [ "$AGENT" = "true" ]; then - echo "Redirecting output to /tmp/sequencer_test_output.log" - exec > /tmp/sequencer_test_output.log 2>&1 -fi - cleanup() { local exit_status=$1 echo "Cleaning up..." @@ -92,33 +76,6 @@ SEQUENCER_ROOT="$(git rev-parse --show-toplevel)/src/app/zeko/sequencer" SEQUENCER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/sequencer" SIGNER_BUILD_ROOT="$(git rev-parse --show-toplevel)/_build/default/src/app/zeko/signer" -test_binary() { - if [ "$TEST_TARGET" = "explorer-e2e" ]; then - echo "$SEQUENCER_BUILD_ROOT/explorer/tests/explorer_e2e_gherkin_tests.exe" - elif [ "$MODE" = "fake" ]; then - echo "$SEQUENCER_BUILD_ROOT/tests/sequencer_test_fake.exe" - else - echo "$SEQUENCER_BUILD_ROOT/tests/sequencer_test.exe" - fi -} - -ensure_explorer_e2e_build() { - if [ "$TEST_TARGET" != "explorer-e2e" ]; then - return - fi - - opam exec -- env -u DUNE_RPC dune build \ - src/app/zeko/sequencer/tests/testing_ledger/run.exe \ - src/app/zeko/da_layer/cli.exe \ - src/app/zeko/sequencer/cli.exe \ - src/app/zeko/sequencer/prover/cli.exe \ - src/app/zeko/sequencer/prover/cli_fake.exe \ - src/app/zeko/signer/cli.exe \ - src/app/zeko/sequencer/explorer/tests/explorer_e2e_gherkin_tests.exe -} - -ensure_explorer_e2e_build - export ZEKO_SIGNATURE_KIND=zeko-testnet export ZEKO_CIRCUITS_CONFIG=test @@ -294,6 +251,7 @@ for ((i = 0; i < NUM_PROVERS; i++)); do PROVERS+=("localhost:$PORT") done +# Wait for ports to be open echo "Waiting for services to start..." wait_for_port 8080 $l1_pid @@ -304,16 +262,12 @@ wait_for_queue_consumers sequencer.jobs "$NUM_PROVERS" echo "All services started successfully" -if [ "$TEST_TARGET" = "explorer-e2e" ]; then - run "explorer-e2e" \ - env ZEKO_CIRCUITS_MODE=$MODE \ - "$(test_binary)" -elif [ "$MODE" = "fake" ]; then +if [ "$MODE" = "fake" ]; then run "sequencer-test" \ env ZEKO_CIRCUITS_MODE=$MODE \ - "$(test_binary)" "${PROVERS[@]}" + $SEQUENCER_BUILD_ROOT/tests/sequencer_test_fake.exe "${PROVERS[@]}" else run "sequencer-test" \ env ZEKO_CIRCUITS_MODE=$MODE \ - "$(test_binary)" "${PROVERS[@]}" + $SEQUENCER_BUILD_ROOT/tests/sequencer_test.exe "${PROVERS[@]}" fi diff --git a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml index b55acf3112..0f1e71bdf1 100644 --- a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml +++ b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml @@ -60,7 +60,7 @@ module Types = struct [ field "chainId" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ v -> v.chain_id) - ]) + ] ) end let merkle_path_element : @@ -75,7 +75,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ x -> match x with `Left _ -> None | `Right h -> Some h ) - ]) + ] ) let account_timing : (State.t, Account_timing.t option) typ = obj "AccountTiming" ~fields:(fun _ -> @@ -124,7 +124,7 @@ module Types = struct None | Timed timing_info -> Some timing_info.vesting_increment ) - ]) + ] ) let genesis_constants = obj "GenesisConstants" ~fields:(fun _ -> @@ -142,7 +142,7 @@ module Types = struct ~resolve:(fun _ () -> State.Constants.genesis_timestamp |> Genesis_constants.of_time |> Genesis_constants.genesis_timestamp_to_string ) - ]) + ] ) module AccountObj = struct module AnnotatedBalance = struct @@ -238,7 +238,7 @@ module Types = struct ~resolve:(fun _ (b : t) -> Option.map b.breadcrumb ~f:(fun crumb -> Transition_frontier.Breadcrumb.state_hash crumb ) ) - ]) + ] ) end module Partial_account = struct @@ -389,7 +389,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ (_, version) -> Mina_numbers.Txn_version.to_string version ) - ]) + ] ) let account_permissions = obj "AccountPermissions" ~fields:(fun _ -> @@ -462,7 +462,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ permission -> permission.Permissions.Poly.set_timing ) - ]) + ] ) let account_vk = obj "AccountVerificationKeyWithHash" ~doc:"Verification key with hash" @@ -479,7 +479,7 @@ module Types = struct @@ Pickles_graphql.Graphql_scalars.VerificationKeyHash.typ () ) ~args:Arg.[] ~resolve:(fun _ (vk : _ With_hash.t) -> vk.hash) - ]) + ] ) let rec account = lazy @@ -672,7 +672,7 @@ module Types = struct ~typ:(list (non_null merkle_path_element)) ~args:Arg.[] ~resolve:(fun _ _ -> None) - ] )) + ] ) ) let account = Lazy.force account end @@ -699,7 +699,7 @@ module Types = struct "Failure reason for the account update or any nested zkapp \ command" ~resolve:(fun _ (_, failures) -> failures) - ]) + ] ) end module User_command = struct @@ -785,7 +785,7 @@ module Types = struct (Mina_base_graphql.Graphql_scalars.TransactionStatusFailure.typ () ) ~args:[] ~doc:"null is no failure, reason for failure otherwise." - ]) + ] ) module With_status = struct type 'a t = { data : 'a; status : Command_status.t } @@ -973,7 +973,7 @@ module Types = struct (List.map (Transaction_status.Failure.Collection.to_display failures ) ~f:(fun f -> Some f) ) ) - ]) + ] ) end let block : (State.t, Unsigned.uint32 option) typ = @@ -983,7 +983,7 @@ module Types = struct ~doc:"Height of the blockchain at this block" ~args:Arg.[] ~resolve:(fun _ asd -> asd) - ]) + ] ) in let protocol_state = obj "ProtocolState" ~fields:(fun _ -> @@ -994,13 +994,13 @@ module Types = struct ~typ:(non_null @@ consensus_state) ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ]) + ] ) in obj "Block" ~fields:(fun _ -> [ field "protocolState" ~typ:(non_null protocol_state) ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ]) + ] ) module Payload = struct let send_payment = @@ -1010,7 +1010,7 @@ module Types = struct ~doc:"Payment that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ]) + ] ) let send_zkapp = obj "SendZkappPayload" ~fields:(fun _ -> @@ -1019,7 +1019,7 @@ module Types = struct ~doc:"zkApp transaction that was sent" ~args:Arg.[] ~resolve:(fun _ -> Fn.id) - ]) + ] ) end module Input = struct @@ -1351,7 +1351,7 @@ module Types = struct ; field "distanceFromMaxBlockHeight" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.distance_from_max_block_height) - ]) + ] ) end module TransactionInfo = struct @@ -1385,7 +1385,7 @@ module Types = struct ~typ:(non_null @@ list @@ non_null int) ~args:Arg.[] ~resolve:(fun _ v -> v.zkapp_account_update_ids) - ]) + ] ) end module StupidActionState = struct @@ -1424,7 +1424,7 @@ module Types = struct Option.map (Pickles_types.Vector.nth action_state 4) ~f:Snark_params.Tick.Field.to_string ) - ]) + ] ) end module ActionData = struct @@ -1445,7 +1445,7 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, _, transaction_info) -> transaction_info) - ]) + ] ) end module EventData = struct @@ -1462,7 +1462,7 @@ module Types = struct ; field "transactionInfo" ~typ:TransactionInfo.t ~args:Arg.[] ~resolve:(fun _ (_, transaction_info) -> transaction_info) - ]) + ] ) end module ActionOutput = struct @@ -1470,7 +1470,6 @@ module Types = struct let t : ('context, t option) typ = obj "ActionOutput" ~fields:(fun _ -> - ( let open Archive.Account_update_actions in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -1488,7 +1487,7 @@ module Types = struct ~resolve:(fun _ v -> List.map v.actions ~f:(fun x -> (x, v.account_update_id, v.transaction_info) ) ) - ] )) + ] ) end module EventOutput = struct @@ -1496,7 +1495,6 @@ module Types = struct let t : ('context, t option) typ = obj "EventOutput" ~fields:(fun _ -> - ( let open Archive.Account_update_events in [ field "blockInfo" ~typ:BlockInfo.t ~args:Arg.[] @@ -1506,7 +1504,7 @@ module Types = struct ~args:Arg.[] ~resolve:(fun _ v -> List.map v.events ~f:(fun x -> (x, v.transaction_info)) ) - ] )) + ] ) end end end From ed69c187c04e79d8ba17206600186cd2cac521e8 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 20:06:20 +0700 Subject: [PATCH 46/51] Fix Rosetta GraphQL client variable type --- src/lib/mina_graphql/types.ml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/mina_graphql/types.ml b/src/lib/mina_graphql/types.ml index b8f10f14b7..444b38e476 100644 --- a/src/lib/mina_graphql/types.ml +++ b/src/lib/mina_graphql/types.ml @@ -3078,7 +3078,7 @@ module Input = struct end module RosettaTransaction = struct - type input = Signed_command.t + type input = Yojson.Basic.t let to_default_json (command : Signed_command.t) = let public_key pk = `Pk (Public_key.Compressed.to_base58_check pk) in @@ -3134,7 +3134,7 @@ module Input = struct ~coerce:(fun graphql_json -> Rosetta_lib.Transaction.to_mina_signed (Utils.to_yojson graphql_json) |> Result.map_error ~f:Error.to_string_hum ) - ~to_json:to_default_json + ~to_json:Fn.id ~to_default_json end From 497e1574e02b3d824caebe624d2bb53ae93da8c2 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 22 May 2026 20:15:54 +0700 Subject: [PATCH 47/51] Finalize sequencer create application --- src/app/zeko/sequencer/lib/zeko_sequencer.ml | 2 +- src/app/zeko/sequencer/run.ml | 2 +- src/app/zeko/sequencer/tests/sequencer_test.ml | 10 +++++++--- src/app/zeko/sequencer/tests/test_spec.ml | 3 ++- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index 610156b509..711cb98bfc 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -1167,7 +1167,7 @@ module Sequencer = struct ~da_quorum ~db_dir ~checkpoints_dir ~postgres_uri ~l1_uri ~archive_uri ~(signer : Signer_service.Signer.t) ~deposit_delay_blocks ~mq_host ~fee_modifier ~minimum_fee ~slot_acceptance ~proof_cache_db ~l1_config - ~commit_validity_period ~commit_fee ~bridge_txn_fee = + ~commit_validity_period ~commit_fee ~bridge_txn_fee () = [%log info] "Precomputing srs" ; Pickles.Side_loaded.srs_precomputation () ; let db_dir = diff --git a/src/app/zeko/sequencer/run.ml b/src/app/zeko/sequencer/run.ml index 23e13510d2..9987d346af 100644 --- a/src/app/zeko/sequencer/run.ml +++ b/src/app/zeko/sequencer/run.ml @@ -40,7 +40,7 @@ let run ~logger ~port ~max_pool_size ~commitment_period ~da_config ~da_keys ~commitment_period_sec:commitment_period ~deposit_delay_blocks ?nats_url ~signer ~mq_host ~fee_modifier ~minimum_fee ~slot_acceptance ~proof_cache_db - ~l1_config ~commit_validity_period ~commit_fee ~bridge_txn_fee ) + ~l1_config ~commit_validity_period ~commit_fee ~bridge_txn_fee () ) in Sequencer.run_committer sequencer ; diff --git a/src/app/zeko/sequencer/tests/sequencer_test.ml b/src/app/zeko/sequencer/tests/sequencer_test.ml index db4b4f1ba0..5301cf8a40 100644 --- a/src/app/zeko/sequencer/tests/sequencer_test.ml +++ b/src/app/zeko/sequencer/tests/sequencer_test.ml @@ -240,6 +240,7 @@ let () = (Mina_numbers.Global_slot_span.of_int 10) ~commit_fee:(Currency.Fee.of_mina_int_exn 1) ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") + () in [%test_eq: Frozen_ledger_hash.t] (get_root new_sequencer) final_ledger_hash ; @@ -343,7 +344,8 @@ let () = ~l1_config ~commit_validity_period:(Mina_numbers.Global_slot_span.of_int 10) ~commit_fee:(Currency.Fee.of_mina_int_exn 1) - ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") ) + ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") + () ) in print_endline "(* Requeue witnesses and commit with quorum 3 *)" ; @@ -475,7 +477,8 @@ let () = ~l1_config ~commit_validity_period:(Mina_numbers.Global_slot_span.of_int 10) ~commit_fee:(Currency.Fee.of_mina_int_exn 1) - ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") ) + ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") + () ) in print_endline "(* Check that all da nodes are synced *)" ; @@ -583,7 +586,8 @@ let () = ~l1_config ~commit_validity_period:(Mina_numbers.Global_slot_span.of_int 10) ~commit_fee:(Currency.Fee.of_mina_int_exn 1) - ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") ) + ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") + () ) in print_endline "(* Check that after restart it recommited *)" ; diff --git a/src/app/zeko/sequencer/tests/test_spec.ml b/src/app/zeko/sequencer/tests/test_spec.ml index 9cd1f32932..70318464a8 100644 --- a/src/app/zeko/sequencer/tests/test_spec.ml +++ b/src/app/zeko/sequencer/tests/test_spec.ml @@ -564,7 +564,8 @@ module Sequencer_spec = struct ~proof_cache_db:(Proof_cache_tag.create_identity_db ()) ~l1_config ~commit_validity_period ~checkpoints_dir ~commit_fee:(Currency.Fee.of_mina_int_exn 1) - ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") ) + ~bridge_txn_fee:(Currency.Fee.of_mina_string_exn "0.1") + () ) in let l1_executor = Executor.create ~kind:(`L1 gql_uri) From 29b8a2e7c6b1eb91306a224034fe79a9e4c92838 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 1 Jun 2026 16:03:17 +0700 Subject: [PATCH 48/51] Remove unnecessary GraphQL dependency churn --- opam.export | 25 +++--- src/app/cli/src/init/graphql_internal.ml | 2 +- src/app/zeko/sequencer/lib/gql.ml | 11 ++- .../sequencer/tests/testing_ledger/gql.ml | 11 ++- .../fields_derivers_graphql.ml | 30 +++---- .../graphql_lib/consensus/graphql_scalars.ml | 12 +-- src/lib/graphql_wrapper/graphql_wrapper.ml | 78 +++---------------- .../abstract_test.ml | 12 +-- .../ocaml_graphql_server_tests/error_test.ml | 8 +- .../ocaml_graphql_server_tests/test_schema.ml | 4 +- src/lib/mina_graphql/types.ml | 64 ++------------- 11 files changed, 72 insertions(+), 185 deletions(-) diff --git a/opam.export b/opam.export index 80929b2f49..5bdc186446 100644 --- a/opam.export +++ b/opam.export @@ -19,9 +19,9 @@ roots: [ "dispatch.0.5.0" "extlib.1.7.8" "fileutils.0.6.4" - "graphql-async.0.14.0" - "graphql-cohttp.0.14.0" - "graphql-lwt.0.14.0" + "graphql-async.0.13.0" + "graphql-cohttp.0.13.0" + "graphql-lwt.0.13.0" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" "js_of_ocaml.4.0.0" @@ -29,7 +29,7 @@ roots: [ "js_of_ocaml-toplevel.4.0.0" "lmdb.1.0" "menhir.20210419" - "merlin.4.18-414" + "merlin.4.5-414" "nats-client.0.0.10" "nats-client-async.0.0.10" "ocaml-base-compiler.4.14.0" @@ -54,7 +54,7 @@ roots: [ "utop.2.9.1" "websocket.2.14" "websocket-async.2.14" - "yojson.2.0.0" + "yojson.1.7.0" ] installed: [ "alcotest.1.1.0" @@ -129,7 +129,7 @@ installed: [ "digestif.0.9.0" "dispatch.0.5.0" "domain-name.0.3.0" - "dot-merlin-reader.4.18-414" + "dot-merlin-reader.4.2" "dune.3.19.0" "dune-build-info.3.1.1" "dune-configurator.2.8.2" @@ -147,10 +147,10 @@ installed: [ "fix.20201120" "fmt.0.8.6" "fpath.0.7.3" - "graphql.0.14.0" - "graphql-async.0.14.0" - "graphql-cohttp.0.14.0" - "graphql-lwt.0.14.0" + "graphql.0.13.0" + "graphql-async.0.13.0" + "graphql-cohttp.0.13.0" + "graphql-lwt.0.13.0" "graphql_parser.0.12.2" "graphql_ppx.1.2.2" "http.6.0.0~alpha2" @@ -179,9 +179,8 @@ installed: [ "menhir.20210419" "menhirLib.20210419" "menhirSdk.20210419" - "merlin.4.18-414" + "merlin.4.5-414" "merlin-extend.0.6.1" - "merlin-lib.4.18-414" "mew.0.1.0" "mew_vi.0.5.0" "minicli.5.0.2" @@ -307,7 +306,7 @@ installed: [ "websocket.2.14" "websocket-async.2.14" "xdg.3.5.0" - "yojson.2.0.0" + "yojson.1.7.0" "zarith.1.7" "zarith_stubs_js.v0.14.1" "zed.3.1.0" diff --git a/src/app/cli/src/init/graphql_internal.ml b/src/app/cli/src/init/graphql_internal.ml index 46f2fe4479..dc0b08a8f4 100644 --- a/src/app/cli/src/init/graphql_internal.ml +++ b/src/app/cli/src/init/graphql_internal.ml @@ -138,7 +138,7 @@ module Make (Io : Cohttp.S.IO with type 'a t = 'a Schema.Io.t) (Body : HttpBody with type +'a io := 'a Schema.Io.t) = struct - module Ws = Graphql_websocket.Connection.Make (Io) + module Ws = Websocket.Connection.Make (Io) module Websocket_transport = Websocket_handler.Make (Schema.Io) (Ws) let ( >>= ) = Io.( >>= ) diff --git a/src/app/zeko/sequencer/lib/gql.ml b/src/app/zeko/sequencer/lib/gql.ml index 898d98fa5f..83b30e8428 100644 --- a/src/app/zeko/sequencer/lib/gql.ml +++ b/src/app/zeko/sequencer/lib/gql.ml @@ -1362,13 +1362,12 @@ module Types = struct Obj.magic x in let arg_typ = - let to_json x = - Yojson.Safe.to_basic - (Mina_base.Zkapp_command.zkapp_command_to_json x) - in { arg_typ = Mina_base.Zkapp_command.arg_typ () |> conv - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = + (function + | x -> + Yojson.Safe.to_basic + (Mina_base.Zkapp_command.zkapp_command_to_json x) ) } in obj "SendZkappInput" ~coerce:Fn.id diff --git a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml index 0f1e71bdf1..598e6e1c5b 100644 --- a/src/app/zeko/sequencer/tests/testing_ledger/gql.ml +++ b/src/app/zeko/sequencer/tests/testing_ledger/gql.ml @@ -1252,13 +1252,12 @@ module Types = struct Obj.magic x in let arg_typ = - let to_json x = - Yojson.Safe.to_basic - (Mina_base.Zkapp_command.zkapp_command_to_json x) - in { arg_typ = Mina_base.Zkapp_command.arg_typ () |> conv - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = + (function + | x -> + Yojson.Safe.to_basic + (Mina_base.Zkapp_command.zkapp_command_to_json x) ) } in obj "SendZkappInput" ~coerce:Fn.id diff --git a/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml b/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml index 159e62c77d..608890ebe0 100644 --- a/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml +++ b/src/lib/fields_derivers_graphql/fields_derivers_graphql.ml @@ -285,26 +285,22 @@ module Graphql_raw = struct let graphql_fields = { Input.T.run = (fun () -> - let fields = - List.rev - @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> - g.Accumulator.T.run () ) - in Schema.obj annotations.name ?doc:annotations.doc - ~fields + ~fields:(fun _ -> + List.rev + @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> + g.Accumulator.T.run () ) ) |> Schema.non_null ) } in let nullable_graphql_fields = { Input.T.run = (fun () -> - let fields = - List.rev - @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> - g.Accumulator.T.run () ) - in Schema.obj annotations.name ?doc:annotations.doc - ~fields ) + ~fields:(fun _ -> + List.rev + @@ List.filter_map graphql_fields_accumulator ~f:(fun g -> + g.Accumulator.T.run () ) ) ) } in obj#graphql_fields := graphql_fields ; @@ -657,8 +653,7 @@ let%test_module "Test" = let manual_typ = Schema.( - obj "T1" ~doc - ~fields: + obj "T1" ~doc ~fields:(fun _ -> [ field "fooHello" ~args:Arg.[] ~typ:int @@ -667,7 +662,7 @@ let%test_module "Test" = ~args:Arg.[] ~typ:(non_null (list (non_null string))) ~resolve:(fun _ t -> t.bar) - ] ) + ] )) let derived init = let open Graphql_fields in @@ -753,13 +748,12 @@ let%test_module "Test" = let manual_typ = Schema.( - obj "T2" ?doc:None - ~fields: + obj "T2" ?doc:None ~fields:(fun _ -> [ field "foo" ~args:Arg.[] ~typ:T1.manual_typ ~resolve:(fun _ t -> Or_ignore_test.to_option t.foo) - ] ) + ] )) let derived init = let open Graphql_fields in diff --git a/src/lib/graphql_lib/consensus/graphql_scalars.ml b/src/lib/graphql_lib/consensus/graphql_scalars.ml index fc98411714..6feddfd882 100644 --- a/src/lib/graphql_lib/consensus/graphql_scalars.ml +++ b/src/lib/graphql_lib/consensus/graphql_scalars.ml @@ -75,7 +75,7 @@ module Epoch_ledger = struct let typ () : ('ctx, Value.t option) Graphql_async.Schema.typ = let open Graphql_async in let open Schema in - obj "epochLedger" ~fields: + obj "epochLedger" ~fields:(fun _ -> [ field "hash" ~typ: (non_null @@ Mina_base_graphql.Graphql_scalars.LedgerHash.typ ()) @@ -85,7 +85,7 @@ module Epoch_ledger = struct ~typ:(non_null @@ Currency_graphql.Graphql_scalars.Amount.typ ()) ~args:Arg.[] ~resolve:(fun _ { Poly.total_currency; _ } -> total_currency) - ] + ] ) end module Epoch_data = struct @@ -94,7 +94,7 @@ module Epoch_data = struct let typ name = let open Graphql_async in let open Schema in - obj name ~fields: + obj name ~fields:(fun _ -> [ field "ledger" ~typ:(non_null @@ Epoch_ledger.typ ()) ~args:Arg.[] @@ -115,7 +115,7 @@ module Epoch_data = struct ~typ:(non_null @@ Mina_numbers_graphql.Graphql_scalars.Length.typ ()) ~args:Arg.[] ~resolve:(fun _ { Poly.epoch_length; _ } -> epoch_length) - ] + ] ) end module Consensus_state = struct @@ -128,7 +128,7 @@ module Consensus_state = struct let open Schema in let length = Mina_numbers_graphql.Graphql_scalars.Length.typ () in let amount = Currency_graphql.Graphql_scalars.Amount.typ () in - obj "ConsensusState" ~fields: + obj "ConsensusState" ~fields:(fun _ -> [ field "blockchainLength" ~typ:(non_null length) ~doc:"Length of the blockchain at this block" ~deprecated:(Deprecated (Some "use blockHeight instead")) @@ -200,7 +200,7 @@ module Consensus_state = struct ; field "coinbaseReceiever" ~typ:(non_null public_key) ~args:Arg.[] ~resolve:(const coinbase_receiver) - ] + ] ) end let%test_module "Roundtrip tests" = diff --git a/src/lib/graphql_wrapper/graphql_wrapper.ml b/src/lib/graphql_wrapper/graphql_wrapper.ml index ecfd4630e6..d73213d6f8 100644 --- a/src/lib/graphql_wrapper/graphql_wrapper.ml +++ b/src/lib/graphql_wrapper/graphql_wrapper.ml @@ -40,30 +40,9 @@ module Make (Schema : Graphql_intf.Schema) = struct } module Arg = struct - let rec const_value_of_json : Yojson.Basic.t -> Graphql_parser.const_value = - function - | `Null -> - `Null - | `Int i -> - `Int i - | `Float f -> - `Float f - | `String s -> - `String s - | `Bool b -> - `Bool b - | `List xs -> - `List (List.map const_value_of_json xs) - | `Assoc fields -> - `Assoc - (List.map (fun (name, value) -> (name, const_value_of_json value)) fields) - (** wrapper around the [Arg.arg_typ] type *) type ('obj_arg, 'a) arg_typ = - { arg_typ : 'obj_arg Schema.Arg.arg_typ - ; to_json : 'a -> Yojson.Basic.t - ; to_graphql_const : 'obj_arg -> Graphql_parser.const_value - } + { arg_typ : 'obj_arg Schema.Arg.arg_typ; to_json : 'a -> Yojson.Basic.t } (** wrapper around the [Arg.arg] type *) type ('obj_arg, 'a) arg = @@ -145,56 +124,38 @@ module Make (Schema : Graphql_intf.Schema) = struct Schema.Arg.(graphql_arg :: to_ocaml_graphql_server_args t) | DefaultArg { name; doc; typ; default } :: t -> let graphql_arg = - Schema.Arg.arg' ?doc name ~typ:typ.arg_typ - ~default:(typ.to_graphql_const (Some default)) + Schema.Arg.arg' ?doc name ~typ:typ.arg_typ ~default in Schema.Arg.(graphql_arg :: to_ocaml_graphql_server_args t) let int = - let to_json = Json.json_of_option (fun i -> `Int i) in { arg_typ = Schema.Arg.int - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = Json.json_of_option (fun i -> `Int i) } - let scalar_with_default_to_json ?doc name ~coerce ~to_json ~to_default_json = - let to_json = Json.json_of_option to_json in - let to_default_json = Json.json_of_option to_default_json in + let scalar ?doc name ~coerce ~to_json = { arg_typ = Schema.Arg.scalar ?doc name ~coerce - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_default_json x)) + ; to_json = Json.json_of_option to_json } - let scalar ?doc name ~coerce ~to_json = - scalar_with_default_to_json ?doc name ~coerce ~to_json - ~to_default_json:to_json - let string = - let to_json = Json.json_of_option (function s -> `String s) in { arg_typ = Schema.Arg.string - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = Json.json_of_option (function s -> `String s) } let float = - let to_json = Json.json_of_option (function f -> `Float f) in { arg_typ = Schema.Arg.float - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = Json.json_of_option (function f -> `Float f) } let bool = - let to_json = Json.json_of_option (function f -> `Bool f) in { arg_typ = Schema.Arg.bool - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = Json.json_of_option (function f -> `Bool f) } let guid = - let to_json = Json.json_of_option (function s -> `String s) in { arg_typ = Schema.Arg.guid - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = Json.json_of_option (function s -> `String s) } let obj ?doc name ~fields ~coerce ~split = @@ -203,29 +164,17 @@ module Make (Schema : Graphql_intf.Schema) = struct let arg_typ = Schema.Arg.obj name ?doc ~fields:gql_server_fields ~coerce in - { arg_typ - ; to_json = Json.json_of_option @@ split build_obj_json - ; to_graphql_const = - (fun _ -> - failwith "GraphQL input object default values are not supported" ) - } + { arg_typ; to_json = Json.json_of_option @@ split build_obj_json } let non_null (arg_typ : _ arg_typ) = { arg_typ = Schema.Arg.non_null arg_typ.arg_typ ; to_json = (function x -> arg_typ.to_json (Some x)) - ; to_graphql_const = (function x -> arg_typ.to_graphql_const (Some x)) } let list (arg_typ : _ arg_typ) = { arg_typ = Schema.Arg.list arg_typ.arg_typ ; to_json = Json.json_of_option (function l -> `List (List.map arg_typ.to_json l)) - ; to_graphql_const = - (function - | None -> - `Null - | Some l -> - `List (List.map arg_typ.to_graphql_const l) ) } (** wrapper around the enum arg_typ. @@ -247,10 +196,8 @@ module Make (Schema : Graphql_intf.Schema) = struct let ocaml_graphql_server_values = List.map (function { enum_value; _ } -> enum_value) values in - let to_json = Json.json_of_option (fun v -> `String (to_string values v)) in { arg_typ = Schema.Arg.enum ?doc name ~values:ocaml_graphql_server_values - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = Json.json_of_option (fun v -> `String (to_string values v)) } let arg ?doc name ~typ = Arg { name; typ; doc } @@ -340,8 +287,7 @@ module Make (Schema : Graphql_intf.Schema) = struct (** The [Propagated] module contains the parts of the Schema we do not modify *) module Propagated = struct - let obj ?doc name ~fields = - Schema.fix (fun r -> r.obj ?doc name ~fields) + let obj = Schema.obj let schema = Schema.schema diff --git a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml index 0dda856da3..a6e9fe4fd6 100644 --- a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml +++ b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/abstract_test.ml @@ -11,25 +11,25 @@ let fido : dog = { name = "Fido"; puppies = 2 } let cat = Schema.( - obj "Cat" ~fields: + obj "Cat" ~fields:(fun _ -> [ field "name" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (cat : cat) -> cat.name) ; field "kittens" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ (cat : cat) -> cat.kittens) - ] ) + ] )) let dog = Schema.( - obj "Dog" ~fields: + obj "Dog" ~fields:(fun _ -> [ field "name" ~typ:(non_null string) ~args:Arg.[] ~resolve:(fun _ (dog : dog) -> dog.name) ; field "puppies" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ (dog : dog) -> dog.puppies) - ] ) + ] )) let pet : (unit, [ `pet ]) Schema.abstract_typ = Schema.union "Pet" @@ -39,8 +39,8 @@ let dog_as_pet = Schema.add_type pet dog let named : (unit, [ `named ]) Schema.abstract_typ = Schema.( - interface "Named" ~fields: - [ abstract_field "name" ~typ:(non_null string) ~args:Arg.[] ] ) + interface "Named" ~fields:(fun _ -> + [ abstract_field "name" ~typ:(non_null string) ~args:Arg.[] ] )) let cat_as_named = Schema.add_type named cat diff --git a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml index ba4a5b4d8a..a92647e1ee 100644 --- a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml +++ b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/error_test.ml @@ -49,11 +49,11 @@ let non_nullable_error_test () = let nested_nullable_error_test () = let obj_with_non_nullable_field = Schema.( - obj "obj" ~fields: + obj "obj" ~fields:(fun _ -> [ io_field "non_nullable" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun _ () -> Error "boom") - ] ) + ] )) in let schema = Schema.( @@ -79,12 +79,12 @@ let nested_nullable_error_test () = let error_in_list_test () = let foo = Schema.( - obj "Foo" ~fields: + obj "Foo" ~fields:(fun _ -> [ io_field "id" ~typ:int ~args:Arg.[] ~resolve:(fun _ (id, should_fail) -> if should_fail then Error "boom" else Ok (Some id) ) - ] ) + ] )) in let schema = Schema.( diff --git a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml index 0ca264ec6f..546e70fc36 100644 --- a/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml +++ b/src/lib/graphql_wrapper/ocaml_graphql_server_tests/test_schema.ml @@ -20,7 +20,7 @@ let input_role = Schema.Arg.(enum "role" ~values:role_values) let user = Schema.( - obj "user" ~fields: + obj "user" ~fields:(fun _ -> [ field "id" ~typ:(non_null int) ~args:Arg.[] ~resolve:(fun { ctx = (); _ } p -> p.id) @@ -30,7 +30,7 @@ let user = ; field "role" ~typ:(non_null role) ~args:Arg.[] ~resolve:(fun _ p -> p.role) - ] ) + ] )) (* Not available in List before OCaml 4.07 *) let list_to_seq n l = diff --git a/src/lib/mina_graphql/types.ml b/src/lib/mina_graphql/types.ml index 444b38e476..ac021894a7 100644 --- a/src/lib/mina_graphql/types.ml +++ b/src/lib/mina_graphql/types.ml @@ -3029,13 +3029,12 @@ module Input = struct Obj.magic x in let arg_typ = - let to_json x = - Yojson.Safe.to_basic - (Mina_base.Zkapp_command.zkapp_command_to_json x) - in { arg_typ = Mina_base.Zkapp_command.arg_typ () |> conv - ; to_json - ; to_graphql_const = (fun x -> const_value_of_json (to_json x)) + ; to_json = + (function + | x -> + Yojson.Safe.to_basic + (Mina_base.Zkapp_command.zkapp_command_to_json x) ) } in obj "SendZkappInput" ~coerce:Fn.id @@ -3080,62 +3079,13 @@ module Input = struct module RosettaTransaction = struct type input = Yojson.Basic.t - let to_default_json (command : Signed_command.t) = - let public_key pk = `Pk (Public_key.Compressed.to_base58_check pk) in - let token_id token = `Token_id (Token_id.to_string token) in - let valid_until = - let slot = Signed_command.valid_until command in - if Mina_numbers.Global_slot_since_genesis.equal slot - Mina_numbers.Global_slot_since_genesis.max_value - then None - else Some (Mina_numbers.Global_slot_since_genesis.to_uint32 slot) - in - let command_kind = - match Signed_command.payload command |> Signed_command_payload.body with - | Payment _ -> - `Payment - | Stake_delegation _ -> - `Delegation - in - let command' : Rosetta_lib.User_command_info.Partial.t = - { kind = command_kind - ; fee_payer = public_key (Signed_command.fee_payer_pk command) - ; source = public_key (Signed_command.fee_payer_pk command) - ; receiver = public_key (Signed_command.receiver_pk command) - ; fee_token = token_id (Signed_command.fee_token command) - ; token = token_id (Signed_command.token command) - ; fee = Currency.Fee.to_uint64 (Signed_command.fee command) - ; amount = - Option.map (Signed_command.amount command) - ~f:Currency.Amount.to_uint64 - ; valid_until - ; memo = - (let memo = Signed_command.memo command in - if Signed_command_memo.equal memo Signed_command_memo.empty then - None - else Some (Signed_command_memo.to_string_hum memo) ) - } - in - let transaction : Rosetta_lib.Transaction.Signed.t = - { command = command' - ; nonce = Mina_numbers.Account_nonce.to_uint32 (Signed_command.nonce command) - ; signature = Signed_command.signature command - } - in - Rosetta_lib.Transaction.Signed.render transaction - |> Result.map ~f:Rosetta_lib.Transaction.Signed.Rendered.to_yojson - |> Result.map_error ~f:(fun err -> - Error.of_string (Rosetta_lib.Errors.show err) ) - |> Or_error.ok_exn |> Yojson.Safe.to_basic - let arg_typ = - Schema.Arg.scalar_with_default_to_json "RosettaTransaction" + Schema.Arg.scalar "RosettaTransaction" ~doc:"A transaction encoded in the Rosetta format" ~coerce:(fun graphql_json -> Rosetta_lib.Transaction.to_mina_signed (Utils.to_yojson graphql_json) |> Result.map_error ~f:Error.to_string_hum ) - ~to_json:Fn.id - ~to_default_json + ~to_json:(Fn.id : input -> input) end module AddAccountInput = struct From a9b7d2ff798cbb4787e3d72facf5c611e58e71e8 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 1 Jun 2026 16:18:29 +0700 Subject: [PATCH 49/51] Restore Yojson 1 compatibility --- src/lib/cli_lib/commands.ml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/cli_lib/commands.ml b/src/lib/cli_lib/commands.ml index 87129c9336..7c5ccab808 100644 --- a/src/lib/cli_lib/commands.ml +++ b/src/lib/cli_lib/commands.ml @@ -1,4 +1,8 @@ open Signature_lib + +(* Alias ocamlp-streams before it's hidden by open + Core_kernel *) +module Streams = Stream open Core_kernel open Async @@ -146,11 +150,11 @@ let validate_transaction = @@ fun () -> let num_fails = ref 0 in let num_transactions = ref 0 in - let jsons = Yojson.Safe.seq_from_channel In_channel.stdin in + let jsons = Yojson.Safe.stream_from_channel In_channel.stdin in let signature_kind = Mina_signature_kind.t_DEPRECATED in ( match Or_error.try_with (fun () -> - Stdlib.Seq.iter + Streams.iter (fun transaction_json -> incr num_transactions ; match From 9ca569471c71a685ff15d05bf92feb3a14093367 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 1 Jun 2026 16:30:39 +0700 Subject: [PATCH 50/51] Use GraphQL 13 object field thunks --- .../sequencer/explorer/explorer_backfill_graphql.ml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml index bbff695ce6..d43fa3eaea 100644 --- a/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml +++ b/src/app/zeko/sequencer/explorer/explorer_backfill_graphql.ml @@ -10,7 +10,7 @@ let backfill_job_typ : (Explorer_backfill_service.t, Explorer_backfill_service.job_snapshot option) typ = obj "BackfillJob" - ~fields: + ~fields:(fun _ -> [ field "id" ~typ:(non_null string) ~args:[] ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> job.id) ; field "fromHash" ~typ:(non_null string) ~args:[] @@ -37,13 +37,13 @@ let backfill_job_typ : ; field "finishedAt" ~typ:string ~args:[] ~resolve:(fun _ (job : Explorer_backfill_service.job_snapshot) -> job.finished_at) - ] + ] ) let progress_typ : (Explorer_backfill_service.t, Explorer_backfill_service.progress_snapshot option) typ = obj "BackfillProgress" - ~fields: + ~fields:(fun _ -> [ field "id" ~typ:(non_null string) ~args:[] ~resolve: (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> @@ -60,13 +60,13 @@ let progress_typ : ~resolve: (fun _ (progress : Explorer_backfill_service.progress_snapshot) -> progress.error) - ] + ] ) let health_typ : (Explorer_backfill_service.t, Explorer_backfill_service.health_snapshot option) typ = obj "Health" - ~fields: + ~fields:(fun _ -> [ field "ok" ~typ:(non_null bool) ~args:[] ~resolve:(fun _ (value : Explorer_backfill_service.health_snapshot) -> value.ok) @@ -76,7 +76,7 @@ let health_typ : ; field "startedAt" ~typ:(non_null string) ~args:[] ~resolve:(fun _ (value : Explorer_backfill_service.health_snapshot) -> value.started_at) - ] + ] ) let query_fields = [ io_field "backfillJob" ~typ:backfill_job_typ From f1f22ab93d7d7c869b72992573879e9c3b2f2b86 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 1 Jun 2026 17:13:45 +0700 Subject: [PATCH 51/51] Trim explorer PR noise --- src/app/zeko/sequencer/explorer/dune | 2 +- src/app/zeko/sequencer/lib/zeko_sequencer.ml | 8 ++++++++ src/lib/cli_lib/commands.ml | 21 +++++++++++--------- src/lib/logproc_lib/interpolator.ml | 2 +- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/app/zeko/sequencer/explorer/dune b/src/app/zeko/sequencer/explorer/dune index 0efb676f91..037a9d4e35 100644 --- a/src/app/zeko/sequencer/explorer/dune +++ b/src/app/zeko/sequencer/explorer/dune @@ -46,7 +46,7 @@ uri core core_kernel - async + async async_unix ppx_inline_test.config) (preprocess diff --git a/src/app/zeko/sequencer/lib/zeko_sequencer.ml b/src/app/zeko/sequencer/lib/zeko_sequencer.ml index 711cb98bfc..813c98673f 100644 --- a/src/app/zeko/sequencer/lib/zeko_sequencer.ml +++ b/src/app/zeko/sequencer/lib/zeko_sequencer.ml @@ -193,6 +193,14 @@ module Sequencer = struct State.Last_committed_ledger.set sequencer_state ~data:new_inner_ledger ; let () = + [%log debug] + "Publishing explorer finality event: subject=%s status=%s \ + source_ledger_hash=%s target_ledger_hash=%s" + Explorer_events.Subject.finality + (Explorer_events.Finality_status.to_string + Explorer_events.Finality_status.Committed ) + (Ledger_hash.to_decimal_string source_ledger_hash) + (Ledger_hash.to_decimal_string target_ledger_hash) ; Explorer_events.publish_finality nats_sink ~logger ~status:Explorer_events.Finality_status.Committed ~source_ledger_hash ~target_ledger_hash diff --git a/src/lib/cli_lib/commands.ml b/src/lib/cli_lib/commands.ml index 7c5ccab808..c8a701a6c5 100644 --- a/src/lib/cli_lib/commands.ml +++ b/src/lib/cli_lib/commands.ml @@ -149,14 +149,14 @@ let validate_transaction = ( Command.Param.return @@ fun () -> let num_fails = ref 0 in - let num_transactions = ref 0 in + (* TODO upgrade to yojson 2.0.0 when possible to use seq_from_channel + * instead of the deprecated stream interface *) let jsons = Yojson.Safe.stream_from_channel In_channel.stdin in let signature_kind = Mina_signature_kind.t_DEPRECATED in ( match Or_error.try_with (fun () -> Streams.iter (fun transaction_json -> - incr num_transactions ; match Rosetta_lib.Transaction.to_mina_signed transaction_json with @@ -186,15 +186,18 @@ let validate_transaction = (Yojson.Safe.pretty_to_string (Error_json.error_to_yojson err)) ; Format.printf "Invalid transaction.@." ; Core_kernel.exit 1 ) ; - if !num_transactions = 0 then ( - Format.printf "Could not parse any transactions@." ; - exit 1 ) - else if !num_fails > 0 then ( + if !num_fails > 0 then ( Format.printf "Some transactions failed to verify@." ; exit 1 ) - else ( - Format.printf "All transactions were valid@." ; - exit 0 ) ) + else + let first = Streams.peek jsons in + match first with + | None -> + Format.printf "Could not parse any transactions@." ; + exit 1 + | _ -> + Format.printf "All transactions were valid@." ; + exit 0 ) module Vrf = struct let generate_witness = diff --git a/src/lib/logproc_lib/interpolator.ml b/src/lib/logproc_lib/interpolator.ml index 8712ca62b7..528fd86441 100644 --- a/src/lib/logproc_lib/interpolator.ml +++ b/src/lib/logproc_lib/interpolator.ml @@ -68,7 +68,7 @@ let interpolate { mode; max_interpolation_length; pretty_print } msg metadata = let open Result.Let_syntax in let format_json = if pretty_print then Yojson.Safe.pretty_to_string - else fun ?std json -> Yojson.Safe.to_string ?std json + else Yojson.Safe.to_string ?buf:None ?len:None in match mode with | Hidden ->