Skip to content

Commit 1b36f7b

Browse files
committed
fix: validate complete watchdog state on idempotent init; add permissive CORS
Idempotent init now requires readable config.json and a non-empty checkpoint snapshot before exit 0, so supervisors are not told success on unusable state. Document LONG_BLOCK_RANGE_ERROR_CODES as init-only. Enable CorsLayer::permissive for browser POST /tx.
1 parent be510ed commit 1b36f7b

9 files changed

Lines changed: 149 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ Notes:
146146
- payload size is bounded at ingress; oversized requests are rejected before entering the hot path.
147147
- overload is enforced at queue admission: if the inclusion-lane queue is full, `POST /tx` returns HTTP `429` with code `OVERLOADED` and message `queue full`.
148148
- queue capacity is an internal runtime constant tuned alongside inclusion-lane chunking to absorb short bursts; if this starts triggering persistently, it is a signal to revisit runtime sizing or throughput rather than add another admission layer.
149+
- CORS is currently **permissive** (`Access-Control-Allow-Origin: *`, all methods/headers) so browser wallets can call `POST /tx`. Tighten once the planned ingress/egress port split lands.
149150

150151
### `GET /ws/subscribe?from_offset=<u64>`
151152

docs/watchdog/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ the chain id explicitly).
182182
The watchdog has two subcommands:
183183

184184
```bash
185-
sequencer-watchdog init # setup: writes config.json + head.json (idempotent)
185+
sequencer-watchdog init # setup: writes config.json + head.json (idempotent if complete)
186186
sequencer-watchdog tick # one compare cycle; schedule this
187187
```
188188

docs/watchdog/operator-deployment.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ Today `WalletApp::default()` / `WalletConfig::sepolia()` align with Sepolia stag
160160
| `CARTESI_WATCHDOG_BLOCKCHAIN_ID` | Chain id label for `status.prom` metrics (prefer set at `init`; optional auto-detect via `eth_chainId` when L1 endpoint is present at `init`) |
161161
| `CARTESI_WATCHDOG_METRICS_FILE` | Override path for the Prometheus textfile written by each `tick` |
162162
| `CARTESI_WATCHDOG_LUA_DEPS` | `.deps/lua` |
163+
| `CARTESI_WATCHDOG_LONG_BLOCK_RANGE_ERROR_CODES` | Optional CSV of RPC error codes that trigger `eth_getLogs` partition retry. **Evaluated only at `init` and persisted in `config.json`** — not a tick-time override; re-running idempotent `init` does not refresh it. Wipe state and re-init (or edit `config.json`) to change. Default matches the sequencer: `-32005,-32012,-32600,-32602,-32616`. |
163164

164165
The sequencer discovers and pins `input_box_address` at startup; use the same values as `CARTESI_SEQUENCER_BLOCKCHAIN_HTTP_ENDPOINT` / `CARTESI_SEQUENCER_APP_ADDRESS` configuration.
165166

@@ -174,13 +175,15 @@ Pick one:
174175
3. **Replay from genesis** (only for new rollups / low block height — slow).
175176

176177
Run `init` once to store the bootstrap CM snapshot into the watchdog state
177-
layout. Re-running `init` on an already-initialized state directory is a no-op
178-
success (exit `0`), matching `sequencer setup` — safe for process supervisors
179-
that always invoke init before tick. The L1 RPC URL is not persisted — each
180-
`tick` reads `CARTESI_WATCHDOG_BLOCKCHAIN_HTTP_ENDPOINT` so it can rotate
181-
without editing state. If `CARTESI_WATCHDOG_BLOCKCHAIN_ID` is unset at `init`,
182-
auto-detect also needs that endpoint present then (prefer setting the chain id
183-
explicitly):
178+
layout. Re-running `init` on a **complete** already-initialized state directory
179+
is a no-op success (exit `0`), matching `sequencer setup` — safe for process
180+
supervisors that always invoke init before tick. If `head.json` exists but
181+
`config.json` or the selected snapshot is missing/corrupt, `init` fails (exit
182+
`1`) and asks you to wipe `state_dir` and re-run — it will not certify an
183+
unusable state. The L1 RPC URL is not persisted — each `tick` reads
184+
`CARTESI_WATCHDOG_BLOCKCHAIN_HTTP_ENDPOINT` so it can rotate without editing
185+
state. If `CARTESI_WATCHDOG_BLOCKCHAIN_ID` is unset at `init`, auto-detect also
186+
needs that endpoint present then (prefer setting the chain id explicitly):
184187

185188
```bash
186189
sequencer-watchdog init

sequencer/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ serde = { version = "1", features = ["derive"] }
1818
serde_json = "1"
1919
toml = "0.8"
2020
tracing = "0.1"
21-
tower-http = { version = "0.6.8", features = ["trace"] }
21+
tower-http = { version = "0.6.8", features = ["trace", "cors"] }
2222
rusqlite = { version = "0.38.0", features = ["bundled"] }
2323
rusqlite_migration = "2.3.0"
2424
alloy-primitives = { version = "1.4.1", features = ["serde", "k256"] }

sequencer/src/http.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use axum::response::{IntoResponse, Response};
2121
use serde::Serialize;
2222
use thiserror::Error;
2323
use tokio::sync::mpsc;
24+
use tower_http::cors::CorsLayer;
2425
use tower_http::trace::TraceLayer;
2526

2627
pub use crate::egress::api::SnapshotState;
@@ -228,7 +229,10 @@ pub fn start_on_listener(
228229
))
229230
// Enforces a raw request-body cap before JSON deserialization, including whitespace.
230231
.layer(DefaultBodyLimit::max(config.max_body_bytes))
231-
.layer(TraceLayer::new_for_http());
232+
.layer(TraceLayer::new_for_http())
233+
// Permissive CORS so browser wallets can POST /tx (and preflight OPTIONS).
234+
// Tighten when the ingress/egress port split lands and public exposure is narrower.
235+
.layer(CorsLayer::permissive());
232236

233237
tokio::spawn(async move {
234238
axum::serve(listener, app)

sequencer/tests/snapshot_endpoints.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,3 +522,33 @@ async fn latest_snapshot_falls_back_to_finalized_when_no_pending() {
522522

523523
assert!(wait_for_lease(db.path.as_str(), fin_id, 0).await);
524524
}
525+
526+
#[tokio::test]
527+
async fn cors_permits_browser_preflight_on_tx() {
528+
let db = temp_db("cors-preflight");
529+
let Some(server) = start_server(db.path.as_str()).await else {
530+
return;
531+
};
532+
533+
let resp = reqwest::Client::new()
534+
.request(reqwest::Method::OPTIONS, server.url("/tx"))
535+
.header("Origin", "https://wallet.example")
536+
.header("Access-Control-Request-Method", "POST")
537+
.header("Access-Control-Request-Headers", "content-type")
538+
.send()
539+
.await
540+
.expect("OPTIONS /tx");
541+
542+
assert!(
543+
resp.status().is_success(),
544+
"preflight status: {}",
545+
resp.status()
546+
);
547+
let allow_origin = resp
548+
.headers()
549+
.get("access-control-allow-origin")
550+
.expect("Access-Control-Allow-Origin")
551+
.to_str()
552+
.expect("header utf8");
553+
assert_eq!(allow_origin, "*");
554+
}

watchdog/config.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,21 @@ function config.persisted(cfg)
125125
}
126126
end
127127

128+
--- Validate a persisted config.json object (no tick-time env required).
129+
--- Raises on missing/invalid fields; used by idempotent init before exit 0.
130+
function config.validate_persisted(data)
131+
if type(data) ~= "table" then
132+
error("config.json is not an object")
133+
end
134+
if data.version ~= config.VERSION then
135+
error("unsupported config.json version: " .. tostring(data.version))
136+
end
137+
required_field(data, "sequencer_url")
138+
required_field(data, "input_box_address")
139+
required_field(data, "app_address")
140+
return true
141+
end
142+
128143
function config.from_persisted(state_dir, data, env)
129144
env = normalize_env(env)
130145
if data.version ~= config.VERSION then

watchdog/main.lua

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,56 @@ local function default_deps(cfg)
106106
return deps, json
107107
end
108108

109+
local function shell_quote(value)
110+
value = tostring(value)
111+
return "'" .. value:gsub("'", "'\\''") .. "'"
112+
end
113+
114+
--- True when `path` is a non-empty directory (CM snapshot must have content).
115+
local function directory_nonempty(path)
116+
local quoted = shell_quote(path)
117+
local ok = os.execute("test -d " .. quoted .. " && test -n \"$(ls -A " .. quoted .. " 2>/dev/null)\"")
118+
return ok == true or ok == 0
119+
end
120+
121+
--- Require a complete, tick-usable state before reporting idempotent init success.
122+
--- A valid head.json alone is not enough — config.json or the selected snapshot
123+
--- may be missing/corrupt after a partial wipe.
124+
local function validate_initialized_state(state_dir, existing, json)
125+
local persisted, cfg_err = state.read_json(state_dir, "config.json", json)
126+
if not persisted then
127+
return nil,
128+
"incomplete watchdog state: missing or unreadable config.json ("
129+
.. tostring(cfg_err)
130+
.. ")"
131+
end
132+
local ok, validate_err = pcall(config.validate_persisted, persisted)
133+
if not ok then
134+
return nil, "incomplete watchdog state: " .. tostring(validate_err)
135+
end
136+
if type(existing.snapshot_dir) ~= "string" or existing.snapshot_dir == "" then
137+
return nil, "incomplete watchdog state: head has no snapshot_dir"
138+
end
139+
if not directory_nonempty(existing.snapshot_dir) then
140+
return nil,
141+
"incomplete watchdog state: missing or empty snapshot at "
142+
.. tostring(existing.snapshot_dir)
143+
end
144+
return true
145+
end
146+
109147
local function run_init(cfg, deps)
110148
deps = deps or default_machine_deps(cfg)
111149
local json = json_mod.new()
112150

113151
local existing, load_err = checkpoint.load(cfg.state_dir)
114152
if existing then
115-
-- Idempotent like `sequencer setup`: re-init on an already-set-up
116-
-- state dir is a no-op success so process supervisors can run init
117-
-- unconditionally without wrapping exit codes.
153+
local usable, why = validate_initialized_state(cfg.state_dir, existing, json)
154+
if not usable then
155+
return nil, tostring(why) .. "; wipe state_dir and re-run init"
156+
end
157+
-- Idempotent like `sequencer setup`: complete state → no-op success so
158+
-- process supervisors can run init unconditionally.
118159
return {
119160
ok = true,
120161
already_initialized = true,

watchdog/tests/run.lua

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,13 @@ local function fake_machine(inspect_state)
521521
function machine:dump(_instance, snapshot_dir, reference_block)
522522
self.saved_snapshot_dir = snapshot_dir
523523
_instance.reference_block = reference_block
524+
-- Real CM dumps create a non-empty snapshot dir; mirror that so
525+
-- idempotent-init usability checks see a complete state.
526+
os.execute("mkdir -p " .. "'" .. tostring(snapshot_dir):gsub("'", "'\\''") .. "'")
527+
local marker = io.open(snapshot_dir .. "/.dump", "w")
528+
assert(marker, "write snapshot marker")
529+
marker:write("ok")
530+
marker:close()
524531
return true
525532
end
526533
function machine:feed_inputs(instance, inputs)
@@ -976,6 +983,41 @@ test("init is a no-op success when state is already initialized", function()
976983
assert_eq(second.safe_block, first.safe_block)
977984
end)
978985

986+
test("init fails when config.json is missing after head exists", function()
987+
local dir = os.tmpname()
988+
os.remove(dir)
989+
990+
local cfg = fake_cfg()
991+
cfg.state_dir = dir
992+
local first, first_err = main_mod.run_init(cfg, { machine = fake_machine("{}") })
993+
assert(first, first_err)
994+
995+
assert(os.remove(dir .. "/config.json"))
996+
997+
local second, second_err = main_mod.run_init(cfg, { machine = fake_machine("{}") })
998+
assert_eq(second, nil)
999+
assert(tostring(second_err):find("config.json", 1, true), tostring(second_err))
1000+
assert(tostring(second_err):find("wipe state_dir", 1, true), tostring(second_err))
1001+
end)
1002+
1003+
test("init fails when checkpoint snapshot is missing after head exists", function()
1004+
local dir = os.tmpname()
1005+
os.remove(dir)
1006+
1007+
local cfg = fake_cfg()
1008+
cfg.state_dir = dir
1009+
local first, first_err = main_mod.run_init(cfg, { machine = fake_machine("{}") })
1010+
assert(first, first_err)
1011+
1012+
local loaded = assert(checkpoint.load(dir))
1013+
assert(os.execute("rm -rf '" .. loaded.snapshot_dir:gsub("'", "'\\''") .. "'"))
1014+
1015+
local second, second_err = main_mod.run_init(cfg, { machine = fake_machine("{}") })
1016+
assert_eq(second, nil)
1017+
assert(tostring(second_err):find("snapshot", 1, true), tostring(second_err))
1018+
assert(tostring(second_err):find("wipe state_dir", 1, true), tostring(second_err))
1019+
end)
1020+
9791021
test("runner happy path replays inputs and writes checkpoint", function()
9801022
local checkpoint_writes = {}
9811023
local checkpoint_mod = {

0 commit comments

Comments
 (0)