Skip to content

Commit 8eb2fb5

Browse files
authored
Merge pull request #101 from BaseIntelligence/fix/master-no-validator-submitter
fix(deploy): keep on-chain validator off master hosts
2 parents 031a048 + 58773e6 commit 8eb2fb5

8 files changed

Lines changed: 92 additions & 15 deletions

File tree

bins/design-challenge/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ fn build_app_state(
546546
staging_root: cli.staging_root.clone(),
547547
stage_delay,
548548
auto_retry_max: cli.auto_retry_max,
549+
emit_poll: Duration::from_secs(15),
549550
},
550551
Arc::clone(&store),
551552
sandbox,
@@ -673,6 +674,7 @@ async fn cmd_serve(cli: Cli) -> Result<(), String> {
673674
}
674675
tokio::spawn(Arc::clone(&orch).run_round_loop());
675676
tokio::spawn(Arc::clone(&orch).run_sweeper());
677+
tokio::spawn(Arc::clone(&orch).run_emitter());
676678

677679
let app = design_router(state);
678680
let listener = TcpListener::bind(cli.bind)

crates/design-challenge/src/orchestrator.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ pub struct OrchestratorConfig {
149149
/// Auto-retry budget for infra-class failures (initial attempt + this
150150
/// many retries). Default 3; `cheat` / `rejected` are always terminal.
151151
pub auto_retry_max: u32,
152+
/// Poll interval for the late-tempo D24 filler.
153+
pub emit_poll: Duration,
152154
}
153155

154156
impl Default for OrchestratorConfig {
@@ -161,6 +163,7 @@ impl Default for OrchestratorConfig {
161163
staging_root: PathBuf::from("/var/lib/design/staging"),
162164
stage_delay: Duration::ZERO,
163165
auto_retry_max: 3,
166+
emit_poll: Duration::from_secs(15),
164167
}
165168
}
166169
}
@@ -177,6 +180,8 @@ pub struct Orchestrator<C: ChainClient + Send + Sync> {
177180
gating: Option<Arc<dyn GatingStore>>,
178181
/// Shared chain-epoch cache (HTTP `AppState.epoch` + sweeper clock).
179182
epoch_cache: Option<Arc<AtomicU64>>,
183+
/// Last epoch successfully covered by [`Self::emit_leaves`].
184+
emitted_epoch: AtomicU64,
180185
}
181186

182187
impl<C: ChainClient + Send + Sync> std::fmt::Debug for Orchestrator<C> {
@@ -209,6 +214,7 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
209214
sk,
210215
gating: None,
211216
epoch_cache: None,
217+
emitted_epoch: AtomicU64::new(0),
212218
}
213219
}
214220

@@ -279,6 +285,42 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
279285
}
280286
}
281287

288+
/// Late-tempo D24 filler: `NotAttempted` coverage when no admin award fired
289+
/// (waits ~last 48 blocks so `award_round` can land Score leaves first).
290+
pub async fn run_emitter(self: Arc<Self>)
291+
where
292+
C: Sync,
293+
{
294+
loop {
295+
if let Err(e) = self.emitter_tick().await {
296+
warn!(error = %e, "design emitter tick error");
297+
}
298+
sleep(self.cfg.emit_poll).await;
299+
}
300+
}
301+
302+
/// One emitter tick. `Ok(true)` when a leaf set was submitted this tick.
303+
///
304+
/// # Errors
305+
/// Chain / sign / submit failures (retried next tick).
306+
pub async fn emitter_tick(&self) -> Result<bool, String>
307+
where
308+
C: Sync,
309+
{
310+
let state = chain::gather_schedule_state(self.chain.as_ref(), self.cfg.netuid)
311+
.map_err(|e| format!("schedule: {e}"))?;
312+
let epoch = state.subnet_epoch_index;
313+
if epoch == 0 || self.emitted_epoch.load(Ordering::Relaxed) >= epoch {
314+
return Ok(false);
315+
}
316+
let tempo = u64::from(state.tempo.max(1));
317+
if state.blocks_since_last_step.saturating_add(48) < tempo {
318+
return Ok(false);
319+
}
320+
self.emit_leaves().await?;
321+
Ok(true)
322+
}
323+
282324
async fn sweep_unscored_timeouts(&self) -> Result<(), String> {
283325
let current = self.current_epoch();
284326
if current == 0 {
@@ -1089,6 +1131,13 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
10891131
submit_signed_leaf_set(self.gateway.as_ref(), &signed)
10901132
.await
10911133
.map_err(|e| e.to_string())?;
1134+
self.emitted_epoch.store(epoch, Ordering::Relaxed);
1135+
info!(
1136+
epoch,
1137+
participants = expected_set.len(),
1138+
last_epoch_block = state.last_epoch_block,
1139+
"design leaf set submitted"
1140+
);
10921141
Ok(())
10931142
}
10941143
}

deploy/AGENTS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ Terraform: [`terraform/`](terraform/). Firewall: SSH from operator IP; CI uses e
1919

2020
| File | Purpose |
2121
|------|---------|
22-
| `compose/role-master.yml` | gateway profile, VPC publish |
23-
| `compose/role-validator.yml` | no gateway; external gateway endpoint |
22+
| `compose/role-master.yml` | gateway profile, VPC publish; **no validator** (avoids dual CRV4 submit) |
23+
| `compose/role-validator.yml` | no gateway; external gateway endpoint; sole on-chain submitter |
2424
| `compose/env-staging.yml` | testnet 541, faster coordination |
2525
| `compose/env-prod.yml` | mainnet, conservative intervals |
2626
| `compose/env-local.yml` | **local only** — ports/smoke knobs/tunnel env; always on top of `env-staging` |
@@ -120,7 +120,9 @@ Prod (`env-prod.yml` + `base-burn-seal.service`): onfinality `public-ws` primary
120120

121121
Validator logs should show `Match epoch=` then `Match → submit_intent` / `submit_timelocked ok`. Keep legacy Python weight submit **stopped** to avoid double-commit.
122122

123-
**Legacy Python agents (mainnet):** `validator-5gzi` (`95.133.252.120`) may point `master_url` / `weights_url` / `registry_url` at `https://chain.joinbase.ai` with **`submit_on_chain_enabled: false`**. Coordination shims live in `gateway-compat` (`/v1/validators/*`, `/v1/registry`, empty assignments). `GET /v1/weights/latest` refreshes `computed_at` / `expires_at` at serve time so Python pydantic clients accept sealed vectors older than 720s. Sole on-chain submitter for hotkey `5Gzi…` is the Rust validator on `192.81.218.11` — do **not** start `base-weight-submitter-5gzi` on `validator-root` unless CR ownership is moved off Rust.
123+
**Sole on-chain submitter (mainnet hotkey `5Gzi…`):** Rust `base-validator-1` on **`base-prod-validator` (`192.81.218.11`) only**. `role-master.yml` profiles the validator under `never`; `remote-deploy.sh --role master` force-removes any leftover container. Do **not** run a second validator (or Python weight submitter) with the same wallet — dual submitters fight `WeightsSetRateLimit` and can leave CRV4 commits stuck while incentive still shows a prior monopoly UID.
124+
125+
**Legacy Python agents (mainnet):** `validator-5gzi` (`95.133.252.120`) may point `master_url` / `weights_url` / `registry_url` at `https://chain.joinbase.ai` with **`submit_on_chain_enabled: false`**. Coordination shims live in `gateway-compat` (`/v1/validators/*`, `/v1/registry`, empty assignments). `GET /v1/weights/latest` refreshes `computed_at` / `expires_at` at serve time so Python pydantic clients accept sealed vectors older than 720s. Do **not** start `base-weight-submitter-5gzi` on `validator-root` unless CR ownership is moved off Rust.
124126

125127
**Challenge verification:** on **master** only (validator has **no challenge exec**). Simulate submissions end-to-end — submit **baseline** + submit **cheat**, poll `/v1/runs/{id}` + `/events` + `/logs`, probe edges (bad harness, sanitize, quota, routes), then **admin winners** (`GET/POST /v1/admin/rounds/{id}/…` with bearer from `deploy/secrets/design/annotator_tokens`) and confirm leaf → seal → `GET /v1/weights/latest` **`sealed: true`**. **Never host Sim in staging/prod** (`BASE_ALLOW_HOST_SIM` / host `SimSandbox` are CI/local only). Healthz alone is insufficient.
126128

deploy/compose/env-local.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ services:
3939
- ./.local/trust-root:/etc/base/config:ro
4040

4141
validator:
42+
# role-master disables on-chain submit on droplet masters; local-e2e needs
43+
# a co-located validator for Match/CRV4 smoke against the local gateway.
44+
profiles: !override []
4245
# Avoid role-master 18080 — often taken by staging SSH tunnels on laptops.
4346
ports: !override
4447
- "127.0.0.1:${LOCAL_VALIDATOR_HOST_PORT:-28080}:8080"

deploy/compose/role-master.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# Master role — gateway enabled, published on VPC for validator peers.
22
# Used with: docker compose -f docker-compose.yml -f deploy/compose/role-master.yml --profile master
3+
#
4+
# On-chain weight submit must NOT run here (sole submitter = validator host).
5+
# Co-located validator is local-e2e only — env-local.yml clears the profile.
36
services:
47
gateway:
58
profiles: ["master"]
69
ports:
710
- "8080:8080"
811
validator:
9-
ports:
10-
- "127.0.0.1:18080:8080"
12+
profiles: ["never"]

deploy/scripts/assert-compose-matrix.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ for banned in design-challenge design-egress-proxy prism-challenge socket-proxy;
5858
done
5959
echo "OK: validator role does not render gateway or challenge services"
6060

61-
# --- master role: gateway + challenge services present ---
61+
# --- master role: gateway + challenge services present; no on-chain validator ---
6262
services=$(render \
6363
-f docker-compose.yml \
6464
-f deploy/compose/role-master.yml \
@@ -72,7 +72,10 @@ for required in design-challenge design-egress-proxy prism-challenge socket-prox
7272
fail "master role does not render $required (must)"
7373
fi
7474
done
75-
echo "OK: master role renders gateway and challenge services"
75+
if echo "$services" | grep -qx "validator"; then
76+
fail "master role renders validator (dual submitter; must not — use validator host)"
77+
fi
78+
echo "OK: master role renders gateway and challenge services (no validator)"
7679

7780
# --- evil-gateway not in default or master ---
7881
services=$(render \
@@ -206,6 +209,8 @@ local_services=$(render \
206209
config --services)
207210
echo "$local_services" | grep -qx "gateway" \
208211
|| fail "env-local master stack does not render gateway"
212+
echo "$local_services" | grep -qx "validator" \
213+
|| fail "env-local master stack does not render co-located validator"
209214
echo "$local_services" | grep -qx "prism-challenge" \
210215
|| fail "env-local master stack does not render prism-challenge"
211216
echo "$local_services" | grep -qx "design-challenge" \

deploy/scripts/remote-deploy.sh

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -416,23 +416,35 @@ fi
416416
docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} \$UP_PROFILE "\${UP_ARGS[@]}"
417417
# Profile-disabled services are not started, but an older compose project may
418418
# still be running them. On validator, force-remove master-only challenge
419-
# surfaces so smoke health does not see stale unhealthy containers.
419+
# surfaces so smoke health does not see stale unhealthy containers. On master,
420+
# force-remove the validator so a prior dual-submitter cannot fight the
421+
# validator-host wallet for WeightsSetRateLimit / CRV4 commits.
420422
if [[ '$ROLE' == 'validator' ]]; then
421423
docker compose ${COMPOSE_FILES[*]} rm -sf \
422424
prism-challenge design-challenge design-egress-proxy socket-proxy \
423425
>/dev/null 2>&1 || true
426+
elif [[ '$ROLE' == 'master' ]]; then
427+
docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} rm -sf validator \
428+
>/dev/null 2>&1 || true
424429
fi
425430
docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} \$UP_PROFILE ps
426431
# Local health probes via published tunnels if present, else container exec.
427432
sleep 5
428-
if curl -fsS -m 5 http://127.0.0.1:18080/healthz >/dev/null 2>&1; then
429-
echo "validator tunnel health: \$(curl -fsS -m 5 http://127.0.0.1:18080/healthz)"
430-
elif docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} exec -T validator curl -fsS -m 5 http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
431-
echo "validator health: ok (in-container)"
432-
else
433-
echo "validator health: probe deferred (container may still be starting)"
433+
if [[ '$ROLE' == 'validator' ]]; then
434+
if curl -fsS -m 5 http://127.0.0.1:18080/healthz >/dev/null 2>&1; then
435+
echo "validator tunnel health: \$(curl -fsS -m 5 http://127.0.0.1:18080/healthz)"
436+
elif docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} exec -T validator curl -fsS -m 5 http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
437+
echo "validator health: ok (in-container)"
438+
else
439+
echo "validator health: probe deferred (container may still be starting)"
440+
fi
434441
fi
435442
if [[ '$ROLE' == 'master' ]]; then
443+
if docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} ps --status running --services 2>/dev/null | grep -qx validator; then
444+
echo "remote-deploy: ERROR: validator still running on master (dual submitter)" >&2
445+
exit 1
446+
fi
447+
echo "master: validator absent (sole on-chain submitter is validator host)"
436448
if docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} exec -T gateway curl -fsS -m 5 http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
437449
echo "gateway health: ok"
438450
else

docs/DESIGN_CHALLENGE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,9 @@ Expected set `E` = all metagraph hotkeys for the pinned epoch (policy
479479

480480
- Exactly one signed leaf per `h ∈ E`
481481
- **Refuses subset and superset** — Silence is a bug
482-
- Emit at round close and at each epoch boundary via `POST /v1/weights/raw`
482+
- Emit at round close (scored) and near each epoch boundary via `POST /v1/weights/raw`
483+
(`Orchestrator::run_emitter` fills `NotAttempted` when no admin award fired, so
484+
D24 seals keep advancing under 50/50 emission shares)
483485

484486
Absence codes used on this path include `NotAttempted`, `Timeout`,
485487
`InvalidResponse`, `MinerError`, `RateLimited`, `ChallengeInternal` (bundle enum).

0 commit comments

Comments
 (0)