From 8dc4e9e18f2b8a410c964660e1fbd63d1e7d1845 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:11:40 +0000 Subject: [PATCH] feat(bounty): add video bug-report challenge with DeepSeek gate Ship miner multipart intake, ffmpeg compress, OpenRouter 24h reject, admin approve, and score_epoch TARGET=50 with trust-root weights design/prism/bounty 3000/4500/2500. --- .github/workflows/ghcr-public.yml | 1 + .github/workflows/images.yml | 11 +- AGENTS.md | 7 +- Cargo.lock | 119 +++ bins/bounty-challenge/Cargo.toml | 35 + bins/bounty-challenge/src/main.rs | 470 +++++++++++ config/CEREMONY.md | 18 +- config/challenges.staging.toml | 12 +- config/challenges.staging.toml.sig | 2 +- config/challenges.toml | 14 +- config/challenges.toml.sig | 2 +- crates/bounty-challenge-task/Cargo.toml | 12 + crates/bounty-challenge-task/src/lib.rs | 54 ++ crates/bounty-challenge-task/src/score.rs | 150 ++++ crates/bounty-challenge/Cargo.toml | 35 + crates/bounty-challenge/src/compress.rs | 108 +++ crates/bounty-challenge/src/emit.rs | 271 +++++++ crates/bounty-challenge/src/lib.rs | 57 ++ crates/bounty-challenge/src/orchestrator.rs | 302 +++++++ crates/bounty-challenge/src/similarity.rs | 240 ++++++ crates/bounty-http/Cargo.toml | 30 + crates/bounty-http/src/api.rs | 763 ++++++++++++++++++ crates/bounty-http/src/lib.rs | 18 + crates/bounty-store-pg/Cargo.toml | 23 + crates/bounty-store-pg/src/lib.rs | 475 +++++++++++ crates/bounty-store/Cargo.toml | 21 + crates/bounty-store/src/lib.rs | 15 + crates/bounty-store/src/store.rs | 514 ++++++++++++ crates/challenge-agentic/src/sim.rs | 50 +- .../db/migrations/0017_bounty_challenge.sql | 91 +++ deploy/AGENTS.md | 6 +- deploy/Dockerfile | 16 + deploy/compose/env-local.yml | 11 + deploy/compose/env-prod.yml | 5 + deploy/compose/env-staging.yml | 11 +- deploy/compose/role-validator.yml | 2 + deploy/env/bounty-challenge.env.example | 23 + deploy/scripts/age-encrypt-env.sh | 4 +- deploy/scripts/assert-compose-matrix.sh | 22 +- deploy/scripts/local-e2e.sh | 9 +- deploy/scripts/materialize-env.sh | 2 +- deploy/scripts/promote.sh | 2 +- deploy/scripts/record-image-digests.sh | 4 +- deploy/scripts/register-challenge-backends.sh | 8 +- deploy/scripts/remote-deploy.sh | 28 +- deploy/secrets/README.md | 31 +- docker-compose.yml | 53 +- docs/AGENTS.md | 4 +- docs/ARCHITECTURE.md | 15 +- docs/BOUNTY_CHALLENGE.md | 213 +++++ docs/COMPLETENESS.md | 18 +- docs/DESIGN_CHALLENGE.md | 6 +- docs/PRISM.md | 2 +- docs/external-miner/README.md | 6 + docs/external-miner/bounty.md | 83 ++ docs/runbooks/bounty-enable-and-emission.md | 125 +++ docs/runbooks/design-enable-and-emission.md | 15 +- docs/runbooks/local-testnet-e2e.md | 1 + .../prism-enable-lium-and-emission.md | 13 +- xtask/src/external_docs_check.rs | 14 +- 60 files changed, 4545 insertions(+), 127 deletions(-) create mode 100644 bins/bounty-challenge/Cargo.toml create mode 100644 bins/bounty-challenge/src/main.rs create mode 100644 crates/bounty-challenge-task/Cargo.toml create mode 100644 crates/bounty-challenge-task/src/lib.rs create mode 100644 crates/bounty-challenge-task/src/score.rs create mode 100644 crates/bounty-challenge/Cargo.toml create mode 100644 crates/bounty-challenge/src/compress.rs create mode 100644 crates/bounty-challenge/src/emit.rs create mode 100644 crates/bounty-challenge/src/lib.rs create mode 100644 crates/bounty-challenge/src/orchestrator.rs create mode 100644 crates/bounty-challenge/src/similarity.rs create mode 100644 crates/bounty-http/Cargo.toml create mode 100644 crates/bounty-http/src/api.rs create mode 100644 crates/bounty-http/src/lib.rs create mode 100644 crates/bounty-store-pg/Cargo.toml create mode 100644 crates/bounty-store-pg/src/lib.rs create mode 100644 crates/bounty-store/Cargo.toml create mode 100644 crates/bounty-store/src/lib.rs create mode 100644 crates/bounty-store/src/store.rs create mode 100644 crates/db/migrations/0017_bounty_challenge.sql create mode 100644 deploy/env/bounty-challenge.env.example create mode 100644 docs/BOUNTY_CHALLENGE.md create mode 100644 docs/external-miner/bounty.md create mode 100644 docs/runbooks/bounty-enable-and-emission.md diff --git a/.github/workflows/ghcr-public.yml b/.github/workflows/ghcr-public.yml index f81157763..165092070 100644 --- a/.github/workflows/ghcr-public.yml +++ b/.github/workflows/ghcr-public.yml @@ -29,6 +29,7 @@ jobs: "base/updater" "base/prism-challenge" "base/design-challenge" + "base/bounty-challenge" "base/design-egress-proxy" "base/design-runtime" ) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index d15257df8..b825c53de 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -57,6 +57,9 @@ jobs: - target: design-challenge image_suffix: design-challenge dockerfile: deploy/Dockerfile + - target: bounty-challenge + image_suffix: bounty-challenge + dockerfile: deploy/Dockerfile - target: design-egress-proxy image_suffix: design-egress-proxy dockerfile: deploy/Dockerfile @@ -169,6 +172,7 @@ jobs: "updater", "prism-challenge", "design-challenge", + "bounty-challenge", "design-egress-proxy", "design-runtime", "design-review", @@ -239,10 +243,10 @@ jobs: cp "$SRC" "deploy/digests/${SHA}.json" echo "Recorded digest manifest: deploy/digests/${SHA}.json" chmod +x deploy/scripts/promote.sh - # Pin services only (validator/gateway/updater/prism-challenge/design-challenge). + # Pin services only (validator/gateway/updater/prism/design/bounty). # --skip-backup: this job records CI digests; Spaces/PG backup runs at # prod promote (deploy-prod.yml, fail-closed). - for svc in validator gateway updater prism-challenge design-challenge; do + for svc in validator gateway updater prism-challenge design-challenge bounty-challenge; do IMAGE=$(python3 -c ' import json, sys d = json.load(open(sys.argv[1])) @@ -261,7 +265,7 @@ jobs: import json, sys p = json.load(open("deploy/pins/staging.json")) assert p["commit_sha"] == sys.argv[1], (p["commit_sha"], sys.argv[1]) - for s in ("validator", "gateway", "updater", "prism-challenge", "design-challenge"): + for s in ("validator", "gateway", "updater", "prism-challenge", "design-challenge", "bounty-challenge"): assert s in p["services"], s assert p["services"][s]["digest"].startswith("sha256:") print("staging pins ok", p["commit_sha"]) @@ -310,6 +314,7 @@ jobs: "base/updater" "base/prism-challenge" "base/design-challenge" + "base/bounty-challenge" "base/design-egress-proxy" "base/design-runtime" "base/design-review" diff --git a/AGENTS.md b/AGENTS.md index 2bf2814c3..b3d6ff303 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Working branch: **`main`**. Prod ships from annotated tags `v*.*.*` cut on `main | Key | Who | Needed for | |-----|-----|------------| | `gateway_sk` | Gateway | Bundle **seal** signatures (`POST /v1/admin/seal`) | -| `prism_sk` / `design_sk` | Challenge / smoke | Signed leaves (`POST /v1/weights/raw`); pubs must match trust root | +| `prism_sk` / `design_sk` / `bounty_sk` | Challenge / smoke | Signed leaves (`POST /v1/weights/raw`); pubs must match trust root | | Gateway owner wallet + `BASE_GATEWAY_REQUIRE_OWNER` | Gateway | Master-only **identity** check (live/prod). **Not** required to seal or serve `/v1/weights/latest` | | Validator wallet | Validator | On-chain weight **submit** only — validators *fetch* sealed weights; they do not need a gateway wallet | @@ -42,8 +42,9 @@ Each live challenge has a **separate public GitHub repo** for miners. Those repo |-----------|-------------|------| | Design | [`BaseIntelligence/design-challenge`](https://github.com/BaseIntelligence/design-challenge) | Miner docs + baseline harness | | Prism | [`BaseIntelligence/prism`](https://github.com/BaseIntelligence/prism) | Miner docs + recipe examples (publish / keep in sync; no control-plane code) | +| Bounty | *(public repo follow-up)* | Miner docs mirror in [`docs/external-miner/bounty.md`](docs/external-miner/bounty.md) until published | -Monorepo mirror for CI and operators: [`docs/external-miner/`](docs/external-miner/). Frozen contracts stay in this repo (`docs/DESIGN_CHALLENGE.md`, `docs/PRISM.md`, …). +Monorepo mirror for CI and operators: [`docs/external-miner/`](docs/external-miner/). Frozen contracts stay in this repo (`docs/DESIGN_CHALLENGE.md`, `docs/PRISM.md`, `docs/BOUNTY_CHALLENGE.md`, …). **When a challenge product or public API changes**, agents **must** update: @@ -90,7 +91,7 @@ Match CI (`.github/workflows/ci.yml`): | Doc authority vs evidence | [`docs/AGENTS.md`](docs/AGENTS.md) | | Component status | [`docs/COMPLETENESS.md`](docs/COMPLETENESS.md) | | Frozen contracts | [`docs/BUNDLE_SPEC.md`](docs/BUNDLE_SPEC.md), [`docs/DESIGN_CHALLENGE.md`](docs/DESIGN_CHALLENGE.md), [`docs/PRISM.md`](docs/PRISM.md) | -| Miner HTTP submit | [`docs/external-miner/`](docs/external-miner/) · public: [design-challenge](https://github.com/BaseIntelligence/design-challenge), [prism](https://github.com/BaseIntelligence/prism) | +| Miner HTTP submit | [`docs/external-miner/`](docs/external-miner/) · public: [design-challenge](https://github.com/BaseIntelligence/design-challenge), [prism](https://github.com/BaseIntelligence/prism) · bounty mirror: [`bounty.md`](docs/external-miner/bounty.md) | | Threat / operator checklist | [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md), [`docs/OPERATOR_SECURITY.md`](docs/OPERATOR_SECURITY.md) | ## Do not commit diff --git a/Cargo.lock b/Cargo.lock index e4e2edff5..945d9d9d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,6 +458,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -606,6 +607,98 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bounty-challenge" +version = "0.1.0" +dependencies = [ + "async-trait", + "bounty-challenge-task", + "bounty-http", + "bounty-store", + "bounty-store-pg", + "bundle", + "challenge-agentic", + "challenge-common", + "crypto", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tracing", + "trustroot", +] + +[[package]] +name = "bounty-challenge-bin" +version = "0.1.0" +dependencies = [ + "axum", + "bounty-challenge", + "chain", + "chain-live", + "challenge-agentic", + "challenge-common", + "challenge-keys", + "clap", + "crypto", + "db", + "hex", + "sha2 0.10.9", + "submission-gating", + "telemetry", + "tokio", + "tracing", + "trustroot", +] + +[[package]] +name = "bounty-challenge-task" +version = "0.1.0" + +[[package]] +name = "bounty-http" +version = "0.1.0" +dependencies = [ + "axum", + "bounty-challenge-task", + "bounty-store", + "hex", + "http-body-util", + "serde", + "serde_json", + "sha2 0.10.9", + "submission-gating", + "tempfile", + "tokio", + "tokio-util", + "tower", +] + +[[package]] +name = "bounty-store" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", +] + +[[package]] +name = "bounty-store-pg" +version = "0.1.0" +dependencies = [ + "async-trait", + "bounty-store", + "db", + "serde_json", + "sqlx", +] + [[package]] name = "bstr" version = "1.13.0" @@ -1841,6 +1934,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -3229,6 +3331,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "ndk-context" version = "0.1.1" diff --git a/bins/bounty-challenge/Cargo.toml b/bins/bounty-challenge/Cargo.toml new file mode 100644 index 000000000..1fca5029b --- /dev/null +++ b/bins/bounty-challenge/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "bounty-challenge-bin" +description = "bounty-challenge operator binary (port 8095)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[[bin]] +name = "bounty-challenge" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "multipart"] } +bounty-challenge = { path = "../../crates/bounty-challenge" } +chain = { path = "../../crates/chain" } +chain-live = { path = "../../crates/chain-live" } +challenge-agentic = { path = "../../crates/challenge-agentic" } +challenge-common = { path = "../../crates/challenge-common" } +challenge-keys = { path = "../../crates/challenge-keys" } +clap = { version = "4", features = ["derive", "env"] } +crypto = { path = "../../crates/crypto" } +db = { path = "../../crates/db" } +hex = "0.4" +sha2 = "0.10" +submission-gating = { path = "../../crates/submission-gating" } +telemetry = { path = "../../crates/telemetry" } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } +tracing = "0.1" +trustroot = { path = "../../crates/trustroot" } + +[lints] +workspace = true diff --git a/bins/bounty-challenge/src/main.rs b/bins/bounty-challenge/src/main.rs new file mode 100644 index 000000000..321ed9c66 --- /dev/null +++ b/bins/bounty-challenge/src/main.rs @@ -0,0 +1,470 @@ +//! `bounty-challenge` — operator-side bounty challenge service (:8095). +//! +//! Intake → ffmpeg compress → agentic similar-24h → admin approve → D24 emit +//! with `TARGET_BUGS` burn-sink on uid=0. + +#![forbid(unsafe_code)] +#![allow(clippy::doc_markdown, clippy::too_many_lines)] + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; +use std::time::Duration; + +use bounty_challenge::{ + bounty_router, emit_epoch, openrouter_model, AppState, BountySimAgent, DbBountyStore, + GatewayClient, GatewayClientConfig, MemoryBountyStore, Orchestrator, OrchestratorConfig, + CHALLENGE_ID, DEFAULT_OPENROUTER_MODEL, DRY_RUN_BASE_URL, SCORING_VERSION, +}; +use chain::ChainClient; +use challenge_agentic::{load_api_key_file, AgentConfig, AgenticBackend, OpenRouterAgent}; +use challenge_common::{expected_set_at_chain, PinnedBlockHash}; +use challenge_keys::load_challenge_secret; +use clap::Parser; +use crypto::KEY_LEN; +use sha2::{Digest, Sha256}; +use submission_gating::{ + watch_once, GatingStore, MemoryGatingStore, MetagraphCache, PgGatingStore, +}; +use tokio::net::TcpListener; +use tokio::sync::Semaphore; +use trustroot::ParticipantPolicy; + +/// Bounty challenge CLI. +#[derive(Debug, Parser)] +#[command( + name = "bounty-challenge", + about = "Bounty challenge service (port 8095)" +)] +struct Cli { + /// Bind address. + #[arg(long, env = "BASE_CHALLENGE_BIND", default_value = "0.0.0.0:8095")] + bind: SocketAddr, + /// Force in-memory store even when `BASE_DATABASE_URL` is set. + /// Env `BOUNTY_FORCE_MEMORY=1|true` is read via [`env_truthy`] (clap bool + /// env rejects `1`). + #[arg(long, default_value_t = false)] + force_memory: bool, + /// Challenge mini-secret file (optional — emitter disabled when missing). + #[arg(long, env = "BASE_CHALLENGE_SK_FILE")] + challenge_sk_file: Option, + /// Force Sim agentic + copy compress (CI / no ffmpeg / no OpenRouter). + /// Env `BOUNTY_FORCE_SIM=1|true` is read via [`env_truthy`]. + #[arg(long, default_value_t = false)] + force_sim: bool, + /// Netuid. + #[arg(long, env = "BASE_NETUID", default_value_t = 1)] + netuid: u16, + /// Max concurrent pipeline workers. + #[arg(long, env = "BOUNTY_MAX_CONCURRENT", default_value_t = 2)] + max_concurrent: u32, + /// Chain WS endpoint. + #[arg(long, env = "BASE_CHAIN_ENDPOINT")] + chain_endpoint: Option, + /// Gateway base URL for leaf submit. + #[arg( + long, + env = "BASE_CHALLENGE_GATEWAY_ENDPOINT", + default_value = "http://gateway:8080" + )] + gateway_endpoint: String, + /// Admin bearer tokens file (one token per line); hashed at boot. + #[arg(long, env = "BOUNTY_ADMIN_TOKENS_FILE")] + admin_tokens_file: Option, + /// OpenRouter key file for agentic review. + #[arg( + long, + env = "BOUNTY_OPENROUTER_KEY_FILE", + default_value = "/run/base/openrouter/api_key" + )] + openrouter_key_file: PathBuf, + /// Artifacts volume root. + #[arg( + long, + env = "BOUNTY_ARTIFACTS_ROOT", + default_value = "/var/lib/bounty/artifacts" + )] + artifacts_root: PathBuf, + /// Metagraph watcher cadence (seconds). + #[arg(long, env = "BOUNTY_GATING_WATCH_SECS", default_value_t = 60)] + gating_watch_secs: u64, + /// Max raw upload bytes. + #[arg(long, env = "BOUNTY_MAX_UPLOAD_BYTES", default_value_t = 104_857_600)] + max_upload_bytes: usize, + /// Emitter poll seconds. + #[arg(long, env = "BOUNTY_EMIT_POLL_SECS", default_value_t = 15)] + emit_poll_secs: u64, +} + +fn env_truthy(name: &str) -> bool { + matches!( + std::env::var(name).as_deref(), + Ok("1" | "true" | "TRUE" | "yes" | "YES") + ) +} + +fn load_token_hashes(path: Option<&PathBuf>) -> Vec { + let Some(p) = path else { + return Vec::new(); + }; + let Ok(text) = std::fs::read_to_string(p) else { + tracing::warn!(path = %p.display(), "admin tokens file unreadable"); + return Vec::new(); + }; + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|tok| { + let mut h = Sha256::new(); + h.update(tok.as_bytes()); + hex::encode(h.finalize()) + }) + .collect() +} + +fn build_agentic(force_sim: bool, key_file: &Path) -> (Arc, &'static str) { + if force_sim { + return (Arc::new(BountySimAgent::new()), "sim"); + } + match load_api_key_file(key_file) { + Ok(key) => { + let model = openrouter_model(); + match OpenRouterAgent::with_config( + key, + challenge_agentic::OPENROUTER_API_BASE, + model, + AgentConfig::default(), + ) { + Ok(agent) => (Arc::new(agent), "openrouter"), + Err(e) => { + tracing::warn!(error = %e, "OpenRouter agent build failed; using BountySimAgent"); + (Arc::new(BountySimAgent::new()), "sim") + } + } + } + Err(e) => { + tracing::warn!( + error = %e, + path = %key_file.display(), + "OpenRouter key missing; using BountySimAgent" + ); + (Arc::new(BountySimAgent::new()), "sim") + } + } +} + +fn spawn_gating_watcher( + chain_ep: &str, + netuid: u16, + cache: Arc, + gating: Arc, + poll: Duration, +) { + let ep = chain_ep.to_owned(); + tokio::spawn(async move { + let mut client: Option = None; + let stores = vec![(CHALLENGE_ID.to_owned(), gating)]; + loop { + if client.is_none() { + let ep2 = ep.clone(); + client = match tokio::task::spawn_blocking(move || { + let mut c = + chain_live::LiveChainClient::connect(&ep2).map_err(|e| e.to_string())?; + c.set_netuid(netuid); + Ok::<_, String>(c) + }) + .await + { + Ok(Ok(c)) => Some(c), + Ok(Err(e)) => { + tracing::warn!(error = %e, "gating watcher: chain connect failed"); + None + } + Err(e) => { + tracing::warn!(error = %e, "gating watcher: join failed"); + None + } + }; + } + if let Some(c) = client.as_mut() { + if let Err(e) = watch_once(c, netuid, &cache, &stores).await { + tracing::warn!(error = %e, "gating watcher tick failed (cache kept)"); + client = None; + } + } + tokio::time::sleep(poll).await; + } + }); +} + +fn spawn_emitter( + store: Arc, + gateway: GatewayClient, + sk: [u8; KEY_LEN], + chain_ep: String, + netuid: u16, + epoch: Arc, + poll: Duration, +) { + tokio::spawn(async move { + let mut client: Option = None; + loop { + if client.is_none() { + let ep2 = chain_ep.clone(); + client = match tokio::task::spawn_blocking(move || { + let mut c = + chain_live::LiveChainClient::connect(&ep2).map_err(|e| e.to_string())?; + c.set_netuid(netuid); + Ok::<_, String>(c) + }) + .await + { + Ok(Ok(c)) => Some(c), + Ok(Err(e)) => { + tracing::warn!(error = %e, "emitter: chain connect failed"); + None + } + Err(e) => { + tracing::warn!(error = %e, "emitter: join failed"); + None + } + }; + } + if let Some(c) = client.as_mut() { + match emitter_tick(&store, &gateway, &sk, c, netuid, &epoch).await { + Ok(Some(s)) => tracing::info!( + epoch = s.epoch, + leaves = s.leaves, + burn_units = s.burn_units, + "bounty epoch emitted" + ), + Ok(None) => {} + Err(e) => { + tracing::warn!(error = %e, "emitter tick error"); + client = None; + } + } + } + tokio::time::sleep(poll).await; + } + }); +} + +async fn emitter_tick( + store: &Arc, + gateway: &GatewayClient, + sk: &[u8; KEY_LEN], + chain: &chain_live::LiveChainClient, + netuid: u16, + epoch_atom: &AtomicU64, +) -> Result, String> { + let state = + chain::gather_schedule_state(chain, netuid).map_err(|e| format!("schedule: {e}"))?; + let epoch = state.subnet_epoch_index; + epoch_atom.store(epoch, std::sync::atomic::Ordering::Relaxed); + let block_hash = chain + .block_hash(state.last_epoch_block) + .map_err(|e| format!("block_hash: {e}"))?; + let expected = expected_set_at_chain( + &ParticipantPolicy::AllMetagraphHotkeys, + PinnedBlockHash::new(block_hash), + chain, + ) + .map_err(|e| format!("expected set: {e}"))?; + emit_epoch(Arc::clone(store), gateway, sk, epoch, &expected) + .await + .map_err(|e| e.to_string()) +} + +#[tokio::main] +async fn main() -> ExitCode { + let _ = telemetry::init_tracing(); + let cli = Cli::parse(); + // Clap bool env only accepts true/false; also honor 1/yes like design/prism. + let force_memory = cli.force_memory || env_truthy("BOUNTY_FORCE_MEMORY"); + let force_sim = cli.force_sim || env_truthy("BOUNTY_FORCE_SIM"); + + if let Err(e) = tokio::fs::create_dir_all(&cli.artifacts_root).await { + tracing::error!(error = %e, path = %cli.artifacts_root.display(), "artifacts root"); + return ExitCode::FAILURE; + } + + let (store, store_mode): (Arc, String) = if force_memory { + (Arc::new(MemoryBountyStore::new()), "memory".into()) + } else if let Ok(url) = std::env::var("BASE_DATABASE_URL") { + if url.trim().is_empty() { + (Arc::new(MemoryBountyStore::new()), "memory".into()) + } else { + match db::connect(&url).await { + Ok(pool) => { + if let Err(e) = db::migrate(&pool).await { + tracing::error!(error = %e, "db migrate failed"); + return ExitCode::FAILURE; + } + ( + Arc::new(DbBountyStore::new(pool)) + as Arc, + "postgres".into(), + ) + } + Err(e) => { + tracing::error!(error = %e, "db connect failed"); + return ExitCode::FAILURE; + } + } + } + } else { + (Arc::new(MemoryBountyStore::new()), "memory".into()) + }; + + let (agentic, agentic_mode) = build_agentic(force_sim, &cli.openrouter_key_file); + let admin_hashes = load_token_hashes(cli.admin_tokens_file.as_ref()); + + let gating_enabled = !matches!(std::env::var("BASE_SUBMISSION_GATING").as_deref(), Ok("0")); + if !gating_enabled { + tracing::warn!("BASE_SUBMISSION_GATING=0: intake metagraph checks disabled"); + } + + let metagraph = Arc::new(MetagraphCache::new()); + let gating: Arc = if store_mode == "postgres" { + if let Ok(url) = std::env::var("BASE_DATABASE_URL") { + match db::connect(&url).await { + Ok(pool) => Arc::new(PgGatingStore::new(pool)), + Err(_) => Arc::new(MemoryGatingStore::new()), + } + } else { + Arc::new(MemoryGatingStore::new()) + } + } else { + Arc::new(MemoryGatingStore::new()) + }; + + let epoch = Arc::new(AtomicU64::new(0)); + let backend_mode = format!("{store_mode}/{agentic_mode}"); + + let state = Arc::new(AppState { + store: Arc::clone(&store), + backend_mode: backend_mode.clone(), + artifacts_root: cli.artifacts_root.clone(), + admin_token_hashes: admin_hashes, + metagraph: gating_enabled.then(|| Arc::clone(&metagraph)), + epoch: Arc::clone(&epoch), + max_upload_bytes: cli.max_upload_bytes, + }); + + let gateway = match GatewayClient::new(GatewayClientConfig { + base_url: if force_sim { + DRY_RUN_BASE_URL.to_owned() + } else { + cli.gateway_endpoint.clone() + }, + ..Default::default() + }) { + Ok(g) => g, + Err(e) => { + tracing::error!(error = %e, "gateway client"); + return ExitCode::FAILURE; + } + }; + + if gating_enabled { + if let Some(ep) = cli.chain_endpoint.as_deref().filter(|s| !s.is_empty()) { + spawn_gating_watcher( + ep, + cli.netuid, + Arc::clone(&metagraph), + Arc::clone(&gating), + Duration::from_secs(cli.gating_watch_secs.max(15)), + ); + } + } + + let orch = Arc::new(Orchestrator::new( + Arc::clone(&store), + OrchestratorConfig { + max_concurrent: cli.max_concurrent.max(1), + force_sim, + artifacts_root: cli.artifacts_root.clone(), + ..OrchestratorConfig::default() + }, + agentic, + )); + let permits = Arc::new(Semaphore::new(cli.max_concurrent.max(1) as usize)); + for _ in 0..cli.max_concurrent.max(1) { + tokio::spawn(Arc::clone(&orch).run_worker(Arc::clone(&permits))); + } + + match cli.challenge_sk_file.as_ref() { + Some(p) if p.is_file() => match load_challenge_secret(p) { + Ok(sk) => { + if let Some(ep) = cli.chain_endpoint.clone().filter(|s| !s.is_empty()) { + spawn_emitter( + Arc::clone(&store), + gateway, + sk, + ep, + cli.netuid, + Arc::clone(&epoch), + Duration::from_secs(cli.emit_poll_secs.max(5)), + ); + } else { + tracing::warn!("challenge sk present but no BASE_CHAIN_ENDPOINT; emitter idle"); + } + } + Err(e) => { + tracing::error!(error = %e, "challenge sk load failed"); + return ExitCode::FAILURE; + } + }, + _ => tracing::warn!("no BASE_CHALLENGE_SK_FILE; leaf emitter disabled"), + } + + let app = bounty_router(state); + let listener = match TcpListener::bind(cli.bind).await { + Ok(l) => l, + Err(e) => { + tracing::error!(error = %e, bind = %cli.bind, "bind failed"); + return ExitCode::FAILURE; + } + }; + tracing::info!( + bind = %cli.bind, + challenge_id = CHALLENGE_ID, + scoring_version = SCORING_VERSION, + backend = %backend_mode, + default_model = DEFAULT_OPENROUTER_MODEL, + "bounty-challenge listening" + ); + + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + { + tracing::error!(error = %e, "server error"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut s) => { + s.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + () = ctrl_c => {}, + () = terminate => {}, + } +} diff --git a/config/CEREMONY.md b/config/CEREMONY.md index 21ecf2789..1eb89b5ed 100644 --- a/config/CEREMONY.md +++ b/config/CEREMONY.md @@ -12,18 +12,22 @@ Offline, operator-only. Never commit secrets. Prefer `/root/.base-secrets/` (mod | `config/measurements.toml` | Measurement allowlist; empty = fail-closed (base-agent CVM path removed). | | `config/measurements.toml.sig` | Detached owner signature. | -### Design challenge enablement (post agent/hypertraining removal) +### Challenge enablement (post agent/hypertraining removal) -Current committed `challenges.toml` has **two** rows: `design` @ 5000 bps and -`prism` @ 5000 bps (50/50; sum = 10000). The `design` public key was generated -with the **dev throwaway** `challenge-design.age` under `~/.base-secrets/`. +Current committed `challenges.toml` has **three** rows: `design` @ 3000 bps, +`prism` @ 4500 bps, and `bounty` @ 2500 bps (sum = 10000). Challenge public +keys were generated with **dev throwaway** age files under `~/.base-secrets/` +(`challenge-design.age`, `challenge-prism.age`, `challenge-bounty.age`). A future production owner/key ceremony may still: -1. Keygen a production `design_sk` (keep off-git; materialize as `deploy/secrets/design_sk`). -2. Replace the `design` `public_key` row in `config/challenges.toml`. -3. Optionally move bps between `prism` and `design` (sum must remain 10000). +1. Keygen production challenge mini-secrets (keep off-git; materialize under + `deploy/secrets/{design,prism,bounty}_sk`). +2. Replace the matching `public_key` rows in `config/challenges.toml`. +3. Optionally move bps among `design` / `prism` / `bounty` (sum must remain 10000). 4. Re-sign with the **production** owner key (`sign --kind challenges`). 5. Verify under `config/owner.pubkey` (or the production owner pubkey after rotation). +6. Mirror the same body + `.sig` into `config/challenges.staging.toml` (+ `.sig`) + when staging should track prod shares. ## Secret layout (never git) diff --git a/config/challenges.staging.toml b/config/challenges.staging.toml index 700edb322..11c90b67e 100644 --- a/config/challenges.staging.toml +++ b/config/challenges.staging.toml @@ -1,5 +1,5 @@ # STAGING-ONLY trust root override (testnet 541): mirrors prod emission shares -# so the staging e2e can prove design + prism weights end-to-end. +# so the staging e2e can prove design + prism + bounty weights end-to-end. # # Mounted over /etc/base/config/challenges.toml by deploy/compose/env-staging.yml # only. Signed with the same throwaway owner key as config/challenges.toml @@ -10,11 +10,17 @@ introduced_epoch = 0 [[challenges]] id = "design" public_key = "3e27f87d8330006a73174001120c3455f16b95fee098bb8c2bab9d5053840418" -emission_share_bps = 5000 +emission_share_bps = 3000 policy = "all_metagraph_hotkeys" [[challenges]] id = "prism" public_key = "bcd50bb830e050ed4b011dd8f1d2f126fdb42dc55b45ece30a7d5c8ceb3c5219" -emission_share_bps = 5000 +emission_share_bps = 4500 +policy = "all_metagraph_hotkeys" + +[[challenges]] +id = "bounty" +public_key = "bc1cbf7eab63e8c526b841ba02ce555dc73ea4175a51b09f91e79ffb359e5127" +emission_share_bps = 2500 policy = "all_metagraph_hotkeys" diff --git a/config/challenges.staging.toml.sig b/config/challenges.staging.toml.sig index 2574dfa85..d32b1181f 100644 --- a/config/challenges.staging.toml.sig +++ b/config/challenges.staging.toml.sig @@ -1 +1 @@ -fae237ccecf24f35cc65fb0d56906db25f0909d016f36bc48bc9788ab6d3324f3c305dab54f0de76fa9700ec6a4cef1dfa5be02dde1a63a7e8260b1a1a440789 +e2cc34991d6f316c411a2e9d6756ae9953bc3022b2800fa976d1ab1107fa762bd33c6024fbecbfbb2a6c64dfb85ab40311df9289e15517d02b15ca5ef3242282 diff --git a/config/challenges.toml b/config/challenges.toml index 5e33461b0..01a28fca4 100644 --- a/config/challenges.toml +++ b/config/challenges.toml @@ -1,8 +1,8 @@ # Owner-signed challenges trust root (D18/D23/D24). # Signed with throwaway owner key (see owner.pubkey). Production rotation: CEREMONY.md. # -# Emission: design = 5000 bps, prism = 5000 bps (50/50; rebalanced 2026-08-07 from -# design 2000 / prism 8000 activated 2026-08-06). Same owner key + challenge keys; +# Emission: design = 3000 bps, prism = 4500 bps, bounty = 2500 bps (sum 10000; +# rebalanced with bounty challenge enablement). Same owner key + challenge keys; # a future production owner/key ceremony per CEREMONY.md remains pending). version = 1 introduced_epoch = 0 @@ -10,11 +10,17 @@ introduced_epoch = 0 [[challenges]] id = "design" public_key = "3e27f87d8330006a73174001120c3455f16b95fee098bb8c2bab9d5053840418" -emission_share_bps = 5000 +emission_share_bps = 3000 policy = "all_metagraph_hotkeys" [[challenges]] id = "prism" public_key = "bcd50bb830e050ed4b011dd8f1d2f126fdb42dc55b45ece30a7d5c8ceb3c5219" -emission_share_bps = 5000 +emission_share_bps = 4500 +policy = "all_metagraph_hotkeys" + +[[challenges]] +id = "bounty" +public_key = "bc1cbf7eab63e8c526b841ba02ce555dc73ea4175a51b09f91e79ffb359e5127" +emission_share_bps = 2500 policy = "all_metagraph_hotkeys" diff --git a/config/challenges.toml.sig b/config/challenges.toml.sig index 8f8a4ee6d..2e60ad786 100644 --- a/config/challenges.toml.sig +++ b/config/challenges.toml.sig @@ -1 +1 @@ -c0c1be58a06daa6713c482ac3619649354ae3229fdc307f212f401dc5d2c17318f8ac9f8d3f9be6ab605a525907b77d9e083cb47c37f4df8231285a4fc104f80 +4208b2eb99ab81f1f691ae33cfa29a06ff3bffeb08febcd235218049c473be7622ccccd95e510012a7c1cda21c9954a85cf95aff53a0581e91afdfd9cae8bc84 diff --git a/crates/bounty-challenge-task/Cargo.toml b/crates/bounty-challenge-task/Cargo.toml new file mode 100644 index 000000000..bcdbefea2 --- /dev/null +++ b/crates/bounty-challenge-task/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bounty-challenge-task" +description = "Bounty challenge identity constants and epoch scoring" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/bounty-challenge-task/src/lib.rs b/crates/bounty-challenge-task/src/lib.rs new file mode 100644 index 000000000..98bfec3bd --- /dev/null +++ b/crates/bounty-challenge-task/src/lib.rs @@ -0,0 +1,54 @@ +//! Bounty challenge identity constants and pure epoch scoring. +//! +//! ```text +//! challenge_id = "bounty" +//! scoring_version = 1 +//! bug_id domain = b"base-bounty-bug-id-v1" +//! submission domain = b"base-bounty-submission-v1" +//! ``` +//! +//! Epoch scoring: target [`TARGET_BUGS`] approved bugs = full [`SCORE_MAX`] +//! miner pool; shortfall burns via UID-0 leaf mass (see [`score_epoch`]). + +#![forbid(unsafe_code)] +#![allow(clippy::missing_errors_doc, clippy::doc_markdown)] + +mod score; +pub use score::{score_epoch, EpochScoreInput, EpochScoreOutcome}; + +/// Normative challenge id (trust-root / leaf `challenge_id` string). +pub const CHALLENGE_ID: &str = "bounty"; + +/// UTF-8 bytes of [`CHALLENGE_ID`]. +pub const CHALLENGE_ID_BYTES: &[u8] = b"bounty"; + +/// Live `challenge_scoring_version` for bounty (TARGET_BUGS burn-sink). +pub const SCORING_VERSION: u16 = 1; + +/// Domain tag for bug id digests. +pub const BUG_ID_DOMAIN: &[u8] = b"base-bounty-bug-id-v1"; + +/// Domain tag for submission / intake digests. +pub const SUBMISSION_DOMAIN: &[u8] = b"base-bounty-submission-v1"; + +/// Integer score lattice max (same scale as other challenges). +pub const SCORE_MAX: u64 = 1_000_000; + +/// Approved bugs that fully claim the bounty emission share this epoch. +pub const TARGET_BUGS: u64 = 50; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity() { + assert_eq!(CHALLENGE_ID, "bounty"); + assert_eq!(CHALLENGE_ID_BYTES, b"bounty"); + assert_eq!(SCORING_VERSION, 1); + assert_eq!(SCORE_MAX, 1_000_000); + assert_eq!(TARGET_BUGS, 50); + assert_eq!(BUG_ID_DOMAIN, b"base-bounty-bug-id-v1"); + assert_eq!(SUBMISSION_DOMAIN, b"base-bounty-submission-v1"); + } +} diff --git a/crates/bounty-challenge-task/src/score.rs b/crates/bounty-challenge-task/src/score.rs new file mode 100644 index 000000000..0a02d595c --- /dev/null +++ b/crates/bounty-challenge-task/src/score.rs @@ -0,0 +1,150 @@ +//! Epoch scoring: TARGET_BUGS burn-sink formula. +//! +//! ```text +//! capped = min(total, TARGET) +//! miner_pool = SCORE_MAX * capped / TARGET +//! burn_units = SCORE_MAX - miner_pool +//! miner m: floor(miner_pool * points_m / total) when total > 0 +//! ``` + +use std::collections::BTreeMap; + +use crate::{SCORE_MAX, TARGET_BUGS}; + +/// Inputs for one epoch's bounty leaf projection. +#[derive(Debug, Clone, Default)] +pub struct EpochScoreInput { + /// Miner hotkey → approved bug count this epoch. + pub approved_points: BTreeMap, +} + +/// Pure scoring outcome (emit maps `burn_units` onto metagraph uid=0). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EpochScoreOutcome { + /// Per-miner lattice scores (`Score(n)` values; absent miners omitted). + pub miner_scores: BTreeMap, + /// Units for the UID-0 burn sink leaf (`0` when `total >= TARGET_BUGS`). + pub burn_units: u64, + /// Pool allocated to miners before per-miner floor split. + pub miner_pool: u64, + /// Sum of approved points (uncapped). + pub total_approved: u64, + /// `min(total_approved, TARGET_BUGS)`. + pub capped: u64, +} + +/// Compute miner scores + burn sink for one epoch. +/// +/// Rules: +/// - `capped = min(total, TARGET_BUGS)` +/// - `miner_pool = SCORE_MAX * capped / TARGET_BUGS` (integer) +/// - `burn_units = SCORE_MAX - miner_pool` +/// - When `total > 0`, each miner with points gets +/// `floor(miner_pool * points_m / total)` +/// - When `total == 0`, `miner_scores` is empty and `burn_units == SCORE_MAX` +/// - Above target: `burn_units == 0`, full pool diluted across all points +#[must_use] +pub fn score_epoch(input: &EpochScoreInput) -> EpochScoreOutcome { + let total_approved: u64 = input.approved_points.values().map(|p| u64::from(*p)).sum(); + let capped = total_approved.min(TARGET_BUGS); + let miner_pool = if TARGET_BUGS == 0 { + 0 + } else { + u64::try_from( + u128::from(SCORE_MAX).saturating_mul(u128::from(capped)) / u128::from(TARGET_BUGS), + ) + .unwrap_or(0) + }; + let burn_units = SCORE_MAX.saturating_sub(miner_pool); + + let mut miner_scores = BTreeMap::new(); + if total_approved > 0 { + for (hk, pts) in &input.approved_points { + if *pts == 0 { + miner_scores.insert(hk.clone(), 0); + continue; + } + let share = u64::try_from( + u128::from(miner_pool).saturating_mul(u128::from(*pts)) + / u128::from(total_approved), + ) + .unwrap_or(0); + miner_scores.insert(hk.clone(), share); + } + } + + EpochScoreOutcome { + miner_scores, + burn_units, + miner_pool, + total_approved, + capped, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn input(points: &[(&str, u32)]) -> EpochScoreInput { + EpochScoreInput { + approved_points: points + .iter() + .map(|(hk, n)| ((*hk).to_owned(), *n)) + .collect(), + } + } + + #[test] + fn zero_bugs_full_burn() { + let out = score_epoch(&input(&[])); + assert_eq!(out.total_approved, 0); + assert_eq!(out.capped, 0); + assert_eq!(out.miner_pool, 0); + assert_eq!(out.burn_units, SCORE_MAX); + assert!(out.miner_scores.is_empty()); + } + + #[test] + fn twenty_five_bugs_half_pool_half_burn() { + // 25 / 50 → miner_pool = SCORE_MAX / 2, burn = SCORE_MAX / 2. + let out = score_epoch(&input(&[("aa", 25)])); + assert_eq!(out.total_approved, 25); + assert_eq!(out.capped, 25); + assert_eq!(out.miner_pool, SCORE_MAX / 2); + assert_eq!(out.burn_units, SCORE_MAX / 2); + assert_eq!(out.miner_scores.get("aa"), Some(&(SCORE_MAX / 2))); + } + + #[test] + fn fifty_bugs_full_pool_no_burn() { + let out = score_epoch(&input(&[("aa", 30), ("bb", 20)])); + assert_eq!(out.total_approved, 50); + assert_eq!(out.capped, 50); + assert_eq!(out.miner_pool, SCORE_MAX); + assert_eq!(out.burn_units, 0); + // 30/50 and 20/50 of SCORE_MAX. + assert_eq!(out.miner_scores.get("aa"), Some(&(SCORE_MAX / 5 * 3))); + assert_eq!(out.miner_scores.get("bb"), Some(&(SCORE_MAX / 5 * 2))); + } + + #[test] + fn seventy_five_bugs_dilution_no_burn() { + // Above target: capped = 50, full pool, burn 0, share by uncapped total. + let out = score_epoch(&input(&[("aa", 50), ("bb", 25)])); + assert_eq!(out.total_approved, 75); + assert_eq!(out.capped, TARGET_BUGS); + assert_eq!(out.miner_pool, SCORE_MAX); + assert_eq!(out.burn_units, 0); + assert_eq!(out.miner_scores.get("aa"), Some(&(SCORE_MAX * 50 / 75))); + assert_eq!(out.miner_scores.get("bb"), Some(&(SCORE_MAX * 25 / 75))); + } + + #[test] + fn single_miner_at_target_takes_all() { + let out = score_epoch(&input(&[("aa", 50)])); + assert_eq!(out.burn_units, 0); + assert_eq!(out.miner_scores.get("aa"), Some(&SCORE_MAX)); + } +} diff --git a/crates/bounty-challenge/Cargo.toml b/crates/bounty-challenge/Cargo.toml new file mode 100644 index 000000000..ed4bc3d2b --- /dev/null +++ b/crates/bounty-challenge/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "bounty-challenge" +description = "Bounty challenge orchestrator, compress, similarity, leaf emit" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +async-trait = "0.1" +bounty-challenge-task = { path = "../bounty-challenge-task" } +bounty-http = { path = "../bounty-http" } +bounty-store = { path = "../bounty-store" } +bounty-store-pg = { path = "../bounty-store-pg" } +bundle = { path = "../bundle" } +challenge-agentic = { path = "../challenge-agentic" } +challenge-common = { path = "../challenge-common" } +crypto = { path = "../crypto" } +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "process", "fs", "io-util"] } +tracing = "0.1" +trustroot = { path = "../trustroot" } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } + +[lints] +workspace = true diff --git a/crates/bounty-challenge/src/compress.rs b/crates/bounty-challenge/src/compress.rs new file mode 100644 index 000000000..c6614c72b --- /dev/null +++ b/crates/bounty-challenge/src/compress.rs @@ -0,0 +1,108 @@ +//! ffmpeg video compress (720p, CRF ~28). + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use sha2::{Digest, Sha256}; +use thiserror::Error; +use tokio::process::Command; + +/// Compress outcome. +#[derive(Debug, Clone)] +pub struct CompressResult { + /// Output path under artifacts volume. + pub video_path: PathBuf, + /// sha256 hex of compressed bytes. + pub sha256: String, + /// Byte length. + pub bytes: u64, +} + +/// Compress failures. +#[derive(Debug, Error)] +pub enum CompressError { + /// Missing input / IO. + #[error("io: {0}")] + Io(#[from] std::io::Error), + /// ffmpeg non-zero exit. + #[error("ffmpeg: {0}")] + Ffmpeg(String), + /// Empty output. + #[error("empty compressed output")] + EmptyOutput, +} + +/// Compress `input` → `output` with libx264 720p CRF 28 + aac. +/// +/// When `force_sim` is set, copies the input bytes (CI / no ffmpeg) and hashes +/// the copy — never invents success without writing a file. +/// +/// # Errors +/// IO / ffmpeg failure / empty output. +pub async fn compress_video( + input: &Path, + output: &Path, + force_sim: bool, +) -> Result { + if let Some(parent) = output.parent() { + tokio::fs::create_dir_all(parent).await?; + } + if force_sim { + tokio::fs::copy(input, output).await?; + } else { + let status = Command::new("ffmpeg") + .args([ + "-y", + "-i", + input.to_str().unwrap_or(""), + "-vf", + "scale=-2:720", + "-c:v", + "libx264", + "-crf", + "28", + "-c:a", + "aac", + "-movflags", + "+faststart", + output.to_str().unwrap_or(""), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .await?; + if !status.success() { + return Err(CompressError::Ffmpeg(format!("exit {status}"))); + } + } + let bytes = tokio::fs::read(output).await?; + if bytes.is_empty() { + return Err(CompressError::EmptyOutput); + } + let mut h = Sha256::new(); + h.update(&bytes); + Ok(CompressResult { + video_path: output.to_path_buf(), + sha256: hex::encode(h.finalize()), + bytes: bytes.len() as u64, + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[tokio::test] + async fn sim_compress_copies_and_hashes() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("raw.bin"); + let output = dir.path().join("out.mp4"); + tokio::fs::write(&input, b"fake-video-bytes").await.unwrap(); + let r = compress_video(&input, &output, true).await.unwrap(); + assert_eq!(r.bytes, 16); + assert_eq!(r.sha256.len(), 64); + assert_eq!(tokio::fs::read(&output).await.unwrap(), b"fake-video-bytes"); + } +} diff --git a/crates/bounty-challenge/src/emit.rs b/crates/bounty-challenge/src/emit.rs new file mode 100644 index 000000000..f1e45d63b --- /dev/null +++ b/crates/bounty-challenge/src/emit.rs @@ -0,0 +1,271 @@ +//! D24 exact-E leaf emission for `challenge_id = "bounty"`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use bounty_challenge_task::{score_epoch, EpochScoreInput, CHALLENGE_ID_BYTES, SCORE_MAX}; +use bounty_store::{BountyStore, EpochScoreRow, FinalScore}; +use bundle::{LeafV1, NoScoreReasonCode, ScoreOrAbsence}; +use challenge_common::{ + emit_signed_leaf_set, hotkey_hex, submit_signed_leaf_set, ExpectedSet, GatewayClient, Hotkey, + LeafEmitError, +}; +use crypto::KEY_LEN; +use thiserror::Error; +use tracing::info; +use trustroot::decode_hex_array; + +/// Emission failures. +#[derive(Debug, Error)] +pub enum EmitError { + /// Store fault. + #[error("store: {0}")] + Store(String), + /// Leaf signing / D24 shape. + #[error("emit: {0}")] + Sign(#[from] LeafEmitError), + /// Gateway submit. + #[error("submit: {0}")] + Submit(String), + /// Missing uid=0 participant in expected set. + #[error("expected set has no uid=0 hotkey")] + NoUidZero, +} + +/// One emitted leaf set summary. +#[derive(Debug, Clone)] +pub struct EmitSummary { + /// Leaf epoch. + pub epoch: u64, + /// Full D24 set size. + pub leaves: usize, + /// Burn units assigned to uid=0. + pub burn_units: u64, + /// Miner pool before burn. + pub miner_pool: u64, + /// Signed leaves (test introspection). + pub signed: BTreeMap, +} + +/// Sign exactly one leaf per `h ∈ expected` under `bounty`. +/// +/// # Errors +/// Missing/unknown hotkeys or signing failure. +pub fn emit_signed_bounty_leaf_set( + secret: &[u8; KEY_LEN], + epoch: u64, + expected: &BTreeSet, + scores: &BTreeMap, +) -> Result, LeafEmitError> { + emit_signed_leaf_set(secret, CHALLENGE_ID_BYTES, epoch, expected, scores) +} + +/// Build D24 scores from epoch approved points + burn sink on uid=0. +/// +/// # Errors +/// [`EmitError::NoUidZero`] when the expected set has no uid 0. +pub fn build_epoch_scores( + expected: &ExpectedSet, + approved_points: &BTreeMap, +) -> Result<(BTreeMap, u64, u64), EmitError> { + let uid0 = expected + .participants + .iter() + .find(|p| p.uid == 0) + .map(|p| p.hotkey) + .ok_or(EmitError::NoUidZero)?; + + let outcome = score_epoch(&EpochScoreInput { + approved_points: approved_points.clone(), + }); + + let mut scores: BTreeMap = BTreeMap::new(); + for p in &expected.participants { + let hex = hotkey_hex(&p.hotkey); + let miner_val = outcome.miner_scores.get(&hex).copied().unwrap_or(0); + if p.hotkey == uid0 { + // UID0 receives burn mass (+ any approved points if they also mine). + let total = miner_val.saturating_add(outcome.burn_units); + scores.insert( + p.hotkey, + if total > 0 { + ScoreOrAbsence::Score { + value: total.min(SCORE_MAX), + } + } else { + ScoreOrAbsence::NoScore { + reason: NoScoreReasonCode::NotAttempted, + } + }, + ); + } else if miner_val > 0 { + scores.insert(p.hotkey, ScoreOrAbsence::Score { value: miner_val }); + } else { + scores.insert( + p.hotkey, + ScoreOrAbsence::NoScore { + reason: NoScoreReasonCode::NotAttempted, + }, + ); + } + } + Ok((scores, outcome.burn_units, outcome.miner_pool)) +} + +/// Persist score rows for audit after a successful emit. +async fn persist_scores( + store: &dyn BountyStore, + epoch: u64, + approved_points: &BTreeMap, + scores: &BTreeMap, +) -> Result<(), EmitError> { + for (hk, soa) in scores { + let hex = hotkey_hex(hk); + let points = approved_points.get(&hex).copied().unwrap_or(0); + let final_score = match soa { + ScoreOrAbsence::Score { value } => Some(FinalScore::Score(*value)), + ScoreOrAbsence::NoScore { reason } => Some(FinalScore::NoScore(*reason as u8)), + }; + store + .upsert_epoch_score(&EpochScoreRow { + epoch, + miner_hotkey: hex, + points, + final_score, + }) + .await + .map_err(|e| EmitError::Store(e.to_string()))?; + } + Ok(()) +} + +/// Emit one epoch's bounty leaf set (idempotent when scores already exist). +/// +/// # Errors +/// Store / sign / submit failures. +pub async fn emit_epoch( + store: Arc, + gateway: &GatewayClient, + secret: &[u8; KEY_LEN], + epoch: u64, + expected: &ExpectedSet, +) -> Result, EmitError> { + let existing = store + .list_epoch_scores(epoch) + .await + .map_err(|e| EmitError::Store(e.to_string()))?; + if existing.iter().any(|r| r.final_score.is_some()) { + return Ok(None); + } + + let approved = store + .approved_points_for_epoch(epoch) + .await + .map_err(|e| EmitError::Store(e.to_string()))?; + let (scores, burn_units, miner_pool) = build_epoch_scores(expected, &approved)?; + let hotkeys = expected.hotkeys(); + let signed = emit_signed_bounty_leaf_set(secret, epoch, &hotkeys, &scores)?; + submit_signed_leaf_set(gateway, &signed) + .await + .map_err(|e| EmitError::Submit(e.to_string()))?; + persist_scores(store.as_ref(), epoch, &approved, &scores).await?; + info!( + epoch, + leaves = signed.len(), + burn_units, + miner_pool, + "bounty epoch leaf set emitted" + ); + Ok(Some(EmitSummary { + epoch, + leaves: signed.len(), + burn_units, + miner_pool, + signed, + })) +} + +/// Decode lowercase 64-hex hotkey. +/// +/// # Errors +/// Bad hex. +pub fn hotkey_from_hex(s: &str) -> Result { + decode_hex_array::(s).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use bounty_store::MemoryBountyStore; + use challenge_common::{ + public_key_from_secret, verify_leaf_sig, ExpectedParticipant, GatewayClientConfig, + DRY_RUN_BASE_URL, + }; + + fn sk() -> [u8; KEY_LEN] { + let mut s = [7u8; KEY_LEN]; + s[0] = 0x42; + s + } + + fn expected_two() -> ExpectedSet { + let uid0 = [1u8; KEY_LEN]; + let miner = [2u8; KEY_LEN]; + ExpectedSet { + block_hash: [9u8; 32], + participants: vec![ + ExpectedParticipant { + hotkey: uid0, + uid: 0, + }, + ExpectedParticipant { + hotkey: miner, + uid: 1, + }, + ], + } + } + + #[test] + fn burn_sink_on_uid0_when_under_target() { + let exp = expected_two(); + let miner_hex = hotkey_hex(&[2u8; KEY_LEN]); + let mut pts = BTreeMap::new(); + pts.insert(miner_hex, 25); + let (scores, burn, pool) = build_epoch_scores(&exp, &pts).unwrap(); + assert_eq!(pool, SCORE_MAX / 2); + assert_eq!(burn, SCORE_MAX / 2); + match scores.get(&[1u8; KEY_LEN]).unwrap() { + ScoreOrAbsence::Score { value } => assert_eq!(*value, SCORE_MAX / 2), + ScoreOrAbsence::NoScore { .. } => panic!("expected burn score"), + } + match scores.get(&[2u8; KEY_LEN]).unwrap() { + ScoreOrAbsence::Score { value } => assert_eq!(*value, SCORE_MAX / 2), + ScoreOrAbsence::NoScore { .. } => panic!("expected miner score"), + } + } + + #[tokio::test] + async fn emit_epoch_dry_run_and_idempotent() { + let store: Arc = Arc::new(MemoryBountyStore::new()); + let gw = GatewayClient::new(GatewayClientConfig { + base_url: DRY_RUN_BASE_URL.into(), + ..Default::default() + }) + .unwrap(); + let exp = expected_two(); + let first = emit_epoch(Arc::clone(&store), &gw, &sk(), 7, &exp) + .await + .unwrap() + .unwrap(); + assert_eq!(first.leaves, 2); + assert_eq!(first.burn_units, SCORE_MAX); // zero approved → full burn + let pk = public_key_from_secret(&sk()).unwrap(); + for leaf in first.signed.values() { + verify_leaf_sig(leaf, &pk).unwrap(); + } + let second = emit_epoch(store, &gw, &sk(), 7, &exp).await.unwrap(); + assert!(second.is_none()); + } +} diff --git a/crates/bounty-challenge/src/lib.rs b/crates/bounty-challenge/src/lib.rs new file mode 100644 index 000000000..fc81873b4 --- /dev/null +++ b/crates/bounty-challenge/src/lib.rs @@ -0,0 +1,57 @@ +//! Bounty challenge orchestrator on Base. +//! +//! Miner video bug report → ffmpeg compress → agentic similar-24h → admin +//! approve → TARGET_BUGS burn-sink epoch leaves under `challenge_id = "bounty"`. + +#![forbid(unsafe_code)] +#![allow(clippy::missing_errors_doc, clippy::doc_markdown)] +#![allow(clippy::too_many_lines)] + +pub mod compress; +pub mod emit; +pub mod orchestrator; +pub mod similarity; + +pub use bounty_challenge_task::{ + score_epoch, EpochScoreInput, EpochScoreOutcome, BUG_ID_DOMAIN, CHALLENGE_ID, + CHALLENGE_ID_BYTES, SCORE_MAX, SCORING_VERSION, SUBMISSION_DOMAIN, TARGET_BUGS, +}; +pub use bounty_http::{bounty_router, AppState}; +pub use bounty_store::{ + BountyStore, BugPatch, BugRow, BugStatus, EpochScoreRow, FinalScore, MemoryBountyStore, + StageEvent, StoreError, +}; +pub use bounty_store_pg::DbBountyStore; +pub use challenge_common::{ + public_key_from_secret, submit_signed_leaf_set, verify_leaf_sig, GatewayClient, + GatewayClientConfig, LeafEmitError, SubmitError, SubmitOutcome, DRY_RUN_BASE_URL, +}; +pub use compress::{compress_video, CompressError, CompressResult}; +pub use emit::{ + build_epoch_scores, emit_epoch, emit_signed_bounty_leaf_set, EmitError, EmitSummary, +}; +pub use orchestrator::{Orchestrator, OrchestratorConfig}; +pub use similarity::{ + map_agentic_verdict, openrouter_model, report_text, review_similarity, BountySimAgent, + SimilarityError, SimilarityKind, SimilarityVerdict, BOUNTY_DOMAIN_RULES, + DEFAULT_OPENROUTER_MODEL, +}; + +/// Crate identity smoke. +#[must_use] +pub fn crate_name() -> &'static str { + "bounty-challenge" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity() { + assert_eq!(crate_name(), "bounty-challenge"); + assert_eq!(CHALLENGE_ID, "bounty"); + assert_eq!(SCORING_VERSION, 1); + assert_eq!(TARGET_BUGS, 50); + } +} diff --git a/crates/bounty-challenge/src/orchestrator.rs b/crates/bounty-challenge/src/orchestrator.rs new file mode 100644 index 000000000..012bacb09 --- /dev/null +++ b/crates/bounty-challenge/src/orchestrator.rs @@ -0,0 +1,302 @@ +//! Pipeline worker (compress → similarity). Leaf emit lives in [`crate::emit`]. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use bounty_store::{BountyStore, BugPatch, BugStatus, StageEvent}; +use challenge_agentic::AgenticBackend; +use serde_json::json; +use tokio::sync::Semaphore; +use tokio::time::sleep; +use tracing::{info, warn}; + +use crate::compress::compress_video; +use crate::similarity::{review_similarity, SimilarityKind}; + +/// Runtime knobs for the bounty worker. +#[derive(Debug, Clone)] +pub struct OrchestratorConfig { + /// Max concurrent compress / review jobs. + pub max_concurrent: u32, + /// Force deterministic sim backends (CI) — skip host ffmpeg. + pub force_sim: bool, + /// Artifacts root (`BOUNTY_ARTIFACTS_ROOT`). + pub artifacts_root: PathBuf, + /// Worker poll when queue empty. + pub worker_poll: Duration, + /// Similarity corpus lookback (ms). + pub corpus_lookback_ms: u64, +} + +impl Default for OrchestratorConfig { + fn default() -> Self { + Self { + max_concurrent: 2, + force_sim: false, + artifacts_root: PathBuf::from("/var/lib/bounty/artifacts"), + worker_poll: Duration::from_secs(2), + corpus_lookback_ms: 24 * 60 * 60 * 1000, + } + } +} + +/// Background pipeline orchestrator. +pub struct Orchestrator { + /// Persistence. + pub store: Arc, + /// Config. + pub config: OrchestratorConfig, + /// Agentic backend. + pub agentic: Arc, +} + +impl std::fmt::Debug for Orchestrator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Orchestrator") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl Orchestrator { + /// Construct. + #[must_use] + pub fn new( + store: Arc, + config: OrchestratorConfig, + agentic: Arc, + ) -> Self { + Self { + store, + config, + agentic, + } + } + + /// Process one claimed bug through compress → similarity. + pub async fn process_bug(&self, id: &str) -> Result<(), String> { + let bug = self + .store + .get_bug(id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "bug not found".to_owned())?; + let raw_path = bug + .video_path + .clone() + .ok_or_else(|| "missing raw video_path".to_owned())?; + let raw = PathBuf::from(&raw_path); + let out_dir = self.config.artifacts_root.join(id); + let out = out_dir.join("video.mp4"); + + let compressed = match compress_video(&raw, &out, self.config.force_sim).await { + Ok(c) => c, + Err(e) => { + let _ = self + .store + .apply( + id, + &BugPatch { + status: Some(BugStatus::Failed), + reject_reason: Some(format!("compress: {e}")), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "failed".into(), + detail: Some(json!({"reason": "compress", "error": e.to_string()})), + at_ms: 0, + }), + ) + .await; + return Err(format!("compress: {e}")); + } + }; + + let _ = tokio::fs::remove_file(&raw).await; + + self.store + .apply( + id, + &BugPatch { + status: Some(BugStatus::AgenticReview), + video_path: Some(compressed.video_path.display().to_string()), + video_sha256: Some(compressed.sha256.clone()), + video_bytes: Some(compressed.bytes), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "agentic_review".into(), + detail: Some(json!({ + "video_sha256": compressed.sha256, + "video_bytes": compressed.bytes, + })), + at_ms: 0, + }), + ) + .await + .map_err(|e| e.to_string())?; + + let now = now_ms(); + let since = now.saturating_sub(self.config.corpus_lookback_ms); + let corpus = self + .store + .list_similarity_corpus(since, &bug.miner_hotkey, bug.miner_coldkey.as_deref(), 64) + .await + .map_err(|e| e.to_string())?; + + let review_dir = out_dir.join("review"); + let verdict = match review_similarity(self.agentic.as_ref(), &bug, &corpus, &review_dir) + .await + { + Ok(v) => v, + Err(e) => { + let _ = self + .store + .apply( + id, + &BugPatch { + status: Some(BugStatus::Failed), + reject_reason: Some(format!("similarity: {e}")), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "failed".into(), + detail: Some(json!({"reason": "similarity", "error": e.to_string()})), + at_ms: 0, + }), + ) + .await; + return Err(format!("similarity: {e}")); + } + }; + + let verdict_json = serde_json::to_value(&verdict).unwrap_or(json!({})); + match verdict.kind { + SimilarityKind::Duplicate => { + self.store + .apply( + id, + &BugPatch { + status: Some(BugStatus::Rejected), + agentic_verdict: Some(verdict_json), + nearest_id: verdict.nearest_id.clone(), + reject_reason: Some("duplicate_24h".into()), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "rejected".into(), + detail: Some(json!({"reason": "duplicate_24h", "verdict": verdict})), + at_ms: 0, + }), + ) + .await + .map_err(|e| e.to_string())?; + } + SimilarityKind::Novel => { + self.store + .apply( + id, + &BugPatch { + status: Some(BugStatus::PendingAdmin), + agentic_verdict: Some(verdict_json), + nearest_id: verdict.nearest_id.clone(), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "pending_admin".into(), + detail: Some(json!({"verdict": verdict})), + at_ms: 0, + }), + ) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) + } + + /// Worker loop: claim uploaded → process. + pub async fn run_worker(self: Arc, permit: Arc) { + loop { + let Ok(guard) = permit.acquire().await else { + sleep(self.config.worker_poll).await; + continue; + }; + match self.store.claim_next().await { + Ok(Some(bug)) => { + let id = bug.id.clone(); + info!(bug_id = %id, "claimed bug for processing"); + if let Err(e) = self.process_bug(&id).await { + warn!(bug_id = %id, error = %e, "bug processing failed"); + } + } + Ok(None) => { + drop(guard); + sleep(self.config.worker_poll).await; + } + Err(e) => { + warn!(error = %e, "claim_next failed"); + drop(guard); + sleep(self.config.worker_poll).await; + } + } + } + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::similarity::BountySimAgent; + use bounty_store::{BugRow, MemoryBountyStore}; + + #[tokio::test] + async fn process_novel_path_with_sim() { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(MemoryBountyStore::new()); + let raw = dir.path().join("raw.mp4"); + tokio::fs::write(&raw, b"video-bytes").await.unwrap(); + let row = BugRow { + id: "bug1".into(), + miner_hotkey: "aa".repeat(32), + miner_coldkey: None, + app_id: "demo".into(), + title: "unique title xyz".into(), + description: "unique description abc".into(), + steps: None, + status: BugStatus::Uploaded, + agentic_verdict: None, + nearest_id: None, + video_sha256: None, + video_bytes: None, + video_path: Some(raw.display().to_string()), + reject_reason: None, + epoch: 1, + created_at_ms: 1, + updated_at_ms: 1, + }; + store.insert_bug(&row).await.unwrap(); + let claimed = store.claim_next().await.unwrap().unwrap(); + let orch = Orchestrator::new( + store.clone(), + OrchestratorConfig { + force_sim: true, + artifacts_root: dir.path().to_path_buf(), + ..OrchestratorConfig::default() + }, + Arc::new(BountySimAgent::new()), + ); + orch.process_bug(&claimed.id).await.unwrap(); + let done = store.get_bug("bug1").await.unwrap().unwrap(); + assert_eq!(done.status, BugStatus::PendingAdmin); + assert!(done.video_sha256.is_some()); + } +} diff --git a/crates/bounty-challenge/src/similarity.rs b/crates/bounty-challenge/src/similarity.rs new file mode 100644 index 000000000..cee25646d --- /dev/null +++ b/crates/bounty-challenge/src/similarity.rs @@ -0,0 +1,240 @@ +//! Agentic similar-24h review (OpenRouter / Sim). + +use std::path::Path; + +use async_trait::async_trait; +use challenge_agentic::{ + AgenticBackend, AgenticError, AgenticVerdict, CheatCode, CorpusEntry, ReviewRequest, + VerdictKind, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use bounty_store::BugRow; + +/// Default OpenRouter model (`BOUNTY_OPENROUTER_MODEL` override). +pub const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-flash"; + +/// Domain rules appended to the agentic system prompt. +pub const BOUNTY_DOMAIN_RULES: &str = r" +Bounty domain (video bug reports): +- Primaries are text reports (title/description/steps), not Python harnesses. +- Verdict Clean means the report is NOVEL vs the 24h corpus → pending_admin. +- Verdict Cheat or Suspicious with near_identical / high similarity means DUPLICATE + → reject (Score 0). Prefer cheat_code near_identical_harness_copy for copies. +- Compare semantic content of the bug report, not video pixels. +- Fail closed: if unsure whether novel, prefer suspicious/cheat over clean. +"; + +/// Structured similarity verdict for the bounty pipeline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SimilarityKind { + /// Novel relative to 24h corpus. + Novel, + /// Duplicate / near-duplicate of `nearest_id`. + Duplicate, +} + +/// Verdict after agentic review. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimilarityVerdict { + /// novel | duplicate. + pub kind: SimilarityKind, + /// Nearest corpus bug id when duplicate. + pub nearest_id: Option, + /// Similarity in basis points (0..=10000). + pub similarity_bps: u16, + /// Short rationale. + pub rationale: String, +} + +/// Similarity failures (fail-closed → retry / failed, never invent novel). +#[derive(Debug, Error)] +pub enum SimilarityError { + /// Agentic backend fault. + #[error("agentic: {0}")] + Agentic(#[from] AgenticError), + /// Staging IO. + #[error("io: {0}")] + Io(#[from] std::io::Error), +} + +/// Canonical report text used for hash / agentic primary. +#[must_use] +pub fn report_text(bug: &BugRow) -> String { + format!( + "app_id: {}\ntitle: {}\ndescription: {}\nsteps: {}\n", + bug.app_id, + bug.title, + bug.description, + bug.steps.as_deref().unwrap_or("") + ) +} + +fn report_hash(text: &str) -> String { + let mut h = Sha256::new(); + h.update(text.as_bytes()); + hex::encode(h.finalize()) +} + +/// Deterministic offline similarity (CI / `BOUNTY_FORCE_SIM`). +/// +/// Exact report-text hash match → duplicate; otherwise novel. Does **not** use +/// Python AST heuristics (those false-positive on prose bug reports). +#[derive(Debug, Default)] +pub struct BountySimAgent; + +impl BountySimAgent { + /// New sim backend. + #[must_use] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl AgenticBackend for BountySimAgent { + async fn review(&self, req: &ReviewRequest) -> Result { + let primary = req + .primary_relpaths + .first() + .ok_or_else(|| AgenticError::NoVerdict("no primary".into()))?; + let path = req.workdir.join(primary); + let text = std::fs::read_to_string(&path) + .map_err(|e| AgenticError::Tool(format!("read primary: {e}")))?; + let cand = report_hash(text.trim()); + for entry in &req.corpus { + if report_hash(entry.source.trim()) == cand { + return Ok(AgenticVerdict { + verdict: VerdictKind::Cheat, + cheat_codes: vec![CheatCode::NearIdenticalHarnessCopy], + nearest_id: Some(entry.id.clone()), + similarity_bps: 10_000, + rationale: "sim: exact report hash match".into(), + }); + } + } + Ok(AgenticVerdict { + verdict: VerdictKind::Clean, + cheat_codes: vec![], + nearest_id: None, + similarity_bps: 0, + rationale: "sim: no exact report match in 24h corpus".into(), + }) + } +} + +/// Map agentic anti-cheat verdict → bounty novel/duplicate. +#[must_use] +pub fn map_agentic_verdict(v: &AgenticVerdict) -> SimilarityVerdict { + match v.verdict { + VerdictKind::Clean => SimilarityVerdict { + kind: SimilarityKind::Novel, + nearest_id: v.nearest_id.clone(), + similarity_bps: v.similarity_bps, + rationale: v.rationale.clone(), + }, + VerdictKind::Suspicious | VerdictKind::Cheat => SimilarityVerdict { + kind: SimilarityKind::Duplicate, + nearest_id: v.nearest_id.clone(), + similarity_bps: v.similarity_bps, + rationale: v.rationale.clone(), + }, + } +} + +/// Stage candidate + corpus text under `workdir` and run agentic review. +/// +/// # Errors +/// IO / agentic fail-closed errors. +pub async fn review_similarity( + agent: &dyn AgenticBackend, + bug: &BugRow, + corpus: &[BugRow], + workdir: &Path, +) -> Result { + tokio::fs::create_dir_all(workdir).await?; + let primary = "report.txt"; + tokio::fs::write(workdir.join(primary), report_text(bug)).await?; + let corpus_entries: Vec = corpus + .iter() + .map(|c| CorpusEntry { + id: c.id.clone(), + source: report_text(c), + }) + .collect(); + let req = ReviewRequest { + workdir: workdir.to_path_buf(), + primary_relpaths: vec![primary.into()], + corpus: corpus_entries, + metrics_relpath: None, + pages_relpath: None, + sanitize_report_relpath: None, + domain_rules: BOUNTY_DOMAIN_RULES.into(), + }; + let verdict = agent.review(&req).await?; + Ok(map_agentic_verdict(&verdict)) +} + +/// Resolve OpenRouter model from env (default [`DEFAULT_OPENROUTER_MODEL`]). +#[must_use] +pub fn openrouter_model() -> String { + std::env::var("BOUNTY_OPENROUTER_MODEL") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_owned()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use bounty_store::BugStatus; + fn bug(id: &str, title: &str, desc: &str) -> BugRow { + BugRow { + id: id.into(), + miner_hotkey: "aa".repeat(32), + miner_coldkey: None, + app_id: "demo".into(), + title: title.into(), + description: desc.into(), + steps: None, + status: BugStatus::PendingAdmin, + agentic_verdict: None, + nearest_id: None, + video_sha256: None, + video_bytes: None, + video_path: None, + reject_reason: None, + epoch: 1, + created_at_ms: 1, + updated_at_ms: 1, + } + } + + #[tokio::test] + async fn sim_detects_duplicate_report() { + let dir = tempfile::tempdir().unwrap(); + let cand = bug("c1", "crash", "null deref on save"); + let prior = bug("p1", "crash", "null deref on save"); + let v = review_similarity(&BountySimAgent::new(), &cand, &[prior], dir.path()) + .await + .unwrap(); + assert_eq!(v.kind, SimilarityKind::Duplicate); + assert_eq!(v.nearest_id.as_deref(), Some("p1")); + } + + #[tokio::test] + async fn sim_novel_when_distinct() { + let dir = tempfile::tempdir().unwrap(); + let cand = bug("c1", "crash A", "unique bug one"); + let prior = bug("p1", "crash B", "totally different"); + let v = review_similarity(&BountySimAgent::new(), &cand, &[prior], dir.path()) + .await + .unwrap(); + assert_eq!(v.kind, SimilarityKind::Novel); + } +} diff --git a/crates/bounty-http/Cargo.toml b/crates/bounty-http/Cargo.toml new file mode 100644 index 000000000..fb9ebbd72 --- /dev/null +++ b/crates/bounty-http/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bounty-http" +description = "Bounty challenge HTTP API (miner/admin/public)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "multipart"] } +bounty-challenge-task = { path = "../bounty-challenge-task" } +bounty-store = { path = "../bounty-store" } +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +submission-gating = { path = "../submission-gating" } +tokio = { version = "1", features = ["fs", "io-util"] } +tokio-util = { version = "0.7", features = ["io"] } + +[dev-dependencies] +http-body-util = "0.1" +tempfile = "3" +tower = { version = "0.5", features = ["util"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } + +[lints] +workspace = true diff --git a/crates/bounty-http/src/api.rs b/crates/bounty-http/src/api.rs new file mode 100644 index 000000000..45a0ef992 --- /dev/null +++ b/crates/bounty-http/src/api.rs @@ -0,0 +1,763 @@ +//! Miner + admin + public HTTP routes. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Multipart, Path as AxumPath, Query, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use bounty_challenge_task::{ + score_epoch, EpochScoreInput, BUG_ID_DOMAIN, CHALLENGE_ID, SCORE_MAX, SCORING_VERSION, + TARGET_BUGS, +}; +use bounty_store::{BountyStore, BugPatch, BugRow, BugStatus, StageEvent}; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use submission_gating::{MetagraphCache, METAGRAPH_CACHE_TTL_SECS}; +use tokio_util::io::ReaderStream; + +/// Shared HTTP state. +pub struct AppState { + /// Persistence. + pub store: Arc, + /// Backend label (`memory` / `postgres` / …). + pub backend_mode: String, + /// Artifacts root for video files. + pub artifacts_root: PathBuf, + /// Admin bearer token sha256 hex digests. + pub admin_token_hashes: Vec, + /// Cached metagraph for intake membership. `None` disables the check. + pub metagraph: Option>, + /// Current chain epoch cache. + pub epoch: Arc, + /// Max raw upload bytes (default ~100 MiB). + pub max_upload_bytes: usize, +} + +/// Build router. +pub fn bounty_router(state: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/v1/status", get(get_status)) + .route("/v1/bugs", post(post_bug).get(list_bugs)) + .route("/v1/bugs/{id}", get(get_bug)) + .route("/v1/bugs/{id}/video", get(get_bug_video)) + .route("/v1/admin/bugs", get(admin_list_bugs)) + .route("/v1/admin/bugs/{id}", get(admin_get_bug)) + .route("/v1/admin/bugs/{id}/approve", post(admin_approve)) + .route("/v1/admin/bugs/{id}/reject", post(admin_reject)) + .with_state(state) +} + +async fn health() -> impl IntoResponse { + ( + StatusCode::OK, + Json(json!({"status":"ok","challenge_id":CHALLENGE_ID})), + ) +} + +async fn get_status(State(st): State>) -> Response { + let epoch = st.epoch.load(Ordering::Relaxed); + let pending = st + .store + .list_bugs(Some("pending_admin"), None, 500) + .await + .map_or(0, |v| v.len()); + let approved_rows = st + .store + .approved_points_for_epoch(epoch) + .await + .unwrap_or_default(); + let total_approved: u64 = approved_rows.values().map(|p| u64::from(*p)).sum(); + let preview = score_epoch(&EpochScoreInput { + approved_points: approved_rows, + }); + Json(json!({ + "challenge_id": CHALLENGE_ID, + "scoring_version": SCORING_VERSION, + "target_bugs": TARGET_BUGS, + "backend": st.backend_mode, + "epoch": epoch, + "pending_admin": pending, + "approved_this_epoch": total_approved, + "burn_preview": { + "miner_pool": preview.miner_pool, + "burn_units": preview.burn_units, + "capped": preview.capped, + "score_max": SCORE_MAX, + }, + })) + .into_response() +} + +fn json_err(code: StatusCode, kind: &str, msg: &str) -> Response { + (code, Json(json!({"error": kind, "message": msg}))).into_response() +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) +} + +fn token_hash(token: &str) -> String { + let mut h = Sha256::new(); + h.update(token.as_bytes()); + hex::encode(h.finalize()) +} + +fn bearer_token(headers: &HeaderMap) -> Result<&str, Response> { + let auth = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let token = auth + .strip_prefix("Bearer ") + .or_else(|| auth.strip_prefix("bearer ")) + .unwrap_or("") + .trim(); + if token.is_empty() { + return Err(json_err( + StatusCode::UNAUTHORIZED, + "unauthorized", + "missing bearer", + )); + } + Ok(token) +} + +fn check_admin(st: &AppState, headers: &HeaderMap) -> Result<(), Response> { + if st.admin_token_hashes.is_empty() { + return Err(json_err( + StatusCode::UNAUTHORIZED, + "unauthorized", + "admin tokens not configured", + )); + } + let token = bearer_token(headers)?; + let th = token_hash(token); + if !st.admin_token_hashes.iter().any(|h| h == &th) { + return Err(json_err( + StatusCode::UNAUTHORIZED, + "unauthorized", + "bad token", + )); + } + Ok(()) +} + +fn miner_hotkey(headers: &HeaderMap) -> Result { + let hk = headers + .get("x-miner-hotkey") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + json_err( + StatusCode::BAD_REQUEST, + "missing_hotkey", + "X-Miner-Hotkey required", + ) + })?; + let hk = hk.to_ascii_lowercase(); + if hk.len() != 64 || !hk.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(json_err( + StatusCode::BAD_REQUEST, + "bad_hotkey", + "X-Miner-Hotkey must be 64 hex chars", + )); + } + Ok(hk) +} + +fn metagraph_check(st: &AppState, hotkey: &str) -> Result, Response> { + let Some(cache) = &st.metagraph else { + return Ok(None); + }; + match cache.snapshot_fresh(METAGRAPH_CACHE_TTL_SECS) { + Some(view) => match view.uid_of_hex(hotkey) { + Some(_) => Ok(view.coldkey_hex_of(hotkey)), + None => Err(json_err( + StatusCode::FORBIDDEN, + "hotkey_not_in_metagraph", + "miner hotkey is not registered on this subnet", + )), + }, + None => Err(json_err( + StatusCode::SERVICE_UNAVAILABLE, + "metagraph_unavailable", + "metagraph snapshot not ready; retry shortly", + )), + } +} + +fn bug_id(hotkey: &str, video: &[u8], title: &str, description: &str, at_ms: u64) -> String { + let mut h = Sha256::new(); + h.update(BUG_ID_DOMAIN); + h.update(hotkey.as_bytes()); + h.update(video); + h.update(title.as_bytes()); + h.update(description.as_bytes()); + h.update(at_ms.to_le_bytes()); + hex::encode(h.finalize()) +} + +fn ext_for_mime(mime: &str, filename: Option<&str>) -> &'static str { + let lower = mime.to_ascii_lowercase(); + if lower.contains("webm") { + return "webm"; + } + if lower.contains("quicktime") || lower.contains("mov") { + return "mov"; + } + if let Some(name) = filename { + let n = name.to_ascii_lowercase(); + if n.ends_with(".webm") { + return "webm"; + } + if n.ends_with(".mov") { + return "mov"; + } + } + "mp4" +} + +fn validate_video_mime(mime: &str, filename: Option<&str>) -> bool { + let m = mime.to_ascii_lowercase(); + if m.contains("mp4") || m.contains("webm") || m.contains("quicktime") { + return true; + } + if let Some(name) = filename { + let n = name.to_ascii_lowercase(); + return n.ends_with(".mp4") || n.ends_with(".webm") || n.ends_with(".mov"); + } + false +} + +fn bug_public_json(b: &BugRow, admin: bool) -> serde_json::Value { + let mut v = json!({ + "id": b.id, + "miner_hotkey": b.miner_hotkey, + "app_id": b.app_id, + "title": b.title, + "description": b.description, + "steps": b.steps, + "status": b.status.as_str(), + "nearest_id": b.nearest_id, + "video_sha256": b.video_sha256, + "video_bytes": b.video_bytes, + "reject_reason": b.reject_reason, + "epoch": b.epoch, + "created_at_ms": b.created_at_ms, + "updated_at_ms": b.updated_at_ms, + "has_video": b.video_path.is_some(), + }); + if admin { + v["agentic_verdict"] = b.agentic_verdict.clone().unwrap_or(json!(null)); + v["miner_coldkey"] = json!(b.miner_coldkey); + v["video_path"] = json!(b.video_path); + } + v +} + +async fn post_bug( + State(st): State>, + headers: HeaderMap, + mut multipart: Multipart, +) -> Response { + let hotkey = match miner_hotkey(&headers) { + Ok(h) => h, + Err(r) => return r, + }; + let coldkey = match metagraph_check(&st, &hotkey) { + Ok(c) => c, + Err(r) => return r, + }; + + let mut title = String::new(); + let mut description = String::new(); + let mut app_id = String::new(); + let mut steps: Option = None; + let mut video: Option<(Vec, String, Option)> = None; + + loop { + let field = match multipart.next_field().await { + Ok(Some(f)) => f, + Ok(None) => break, + Err(e) => { + return json_err( + StatusCode::BAD_REQUEST, + "multipart", + &format!("invalid multipart: {e}"), + ); + } + }; + let name = field.name().unwrap_or("").to_owned(); + match name.as_str() { + "title" => title = field.text().await.unwrap_or_default(), + "description" => description = field.text().await.unwrap_or_default(), + "app_id" => app_id = field.text().await.unwrap_or_default(), + "steps" => { + let t = field.text().await.unwrap_or_default(); + if !t.trim().is_empty() { + steps = Some(t); + } + } + "video" => { + let mime = field.content_type().map(str::to_owned).unwrap_or_default(); + let filename = field.file_name().map(str::to_owned); + let bytes = match field.bytes().await { + Ok(b) => b.to_vec(), + Err(e) => { + return json_err( + StatusCode::BAD_REQUEST, + "video_read", + &format!("failed to read video: {e}"), + ); + } + }; + if bytes.len() > st.max_upload_bytes { + return json_err( + StatusCode::PAYLOAD_TOO_LARGE, + "video_too_large", + &format!("max {} bytes", st.max_upload_bytes), + ); + } + if bytes.is_empty() { + return json_err(StatusCode::BAD_REQUEST, "empty_video", "video is empty"); + } + if !validate_video_mime(&mime, filename.as_deref()) { + return json_err( + StatusCode::BAD_REQUEST, + "bad_video_type", + "video must be mp4/webm/mov", + ); + } + video = Some((bytes, mime, filename)); + } + _ => {} + } + } + + let title = title.trim().to_owned(); + let description = description.trim().to_owned(); + let app_id = app_id.trim().to_owned(); + if title.is_empty() || title.len() > 256 { + return json_err( + StatusCode::BAD_REQUEST, + "bad_title", + "title required (≤256)", + ); + } + if description.is_empty() || description.len() > 65_536 { + return json_err( + StatusCode::BAD_REQUEST, + "bad_description", + "description required (≤64KiB)", + ); + } + if app_id.is_empty() || app_id.len() > 128 { + return json_err( + StatusCode::BAD_REQUEST, + "bad_app_id", + "app_id required (≤128)", + ); + } + let Some((video_bytes, mime, filename)) = video else { + return json_err( + StatusCode::BAD_REQUEST, + "missing_video", + "video field required", + ); + }; + + let at_ms = now_ms(); + let id = bug_id(&hotkey, &video_bytes, &title, &description, at_ms); + let ext = ext_for_mime(&mime, filename.as_deref()); + let dir = st.artifacts_root.join(&id); + if let Err(e) = tokio::fs::create_dir_all(&dir).await { + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + "artifacts", + &e.to_string(), + ); + } + let raw_path = dir.join(format!("raw.{ext}")); + if let Err(e) = tokio::fs::write(&raw_path, &video_bytes).await { + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + "artifacts", + &e.to_string(), + ); + } + + let row = BugRow { + id: id.clone(), + miner_hotkey: hotkey, + miner_coldkey: coldkey, + app_id, + title, + description, + steps, + status: BugStatus::Uploaded, + agentic_verdict: None, + nearest_id: None, + video_sha256: None, + video_bytes: None, + video_path: Some(raw_path.display().to_string()), + reject_reason: None, + epoch: st.epoch.load(Ordering::Relaxed), + created_at_ms: at_ms, + updated_at_ms: at_ms, + }; + match st.store.insert_bug(&row).await { + Ok(()) => ( + StatusCode::ACCEPTED, + Json(json!({ + "id": id, + "status": "uploaded", + "epoch": row.epoch, + })), + ) + .into_response(), + Err(bounty_store::StoreError::Duplicate) => { + json_err(StatusCode::CONFLICT, "duplicate", "bug id already exists") + } + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + } +} + +#[derive(Debug, Deserialize)] +struct ListQuery { + status: Option, + mine: Option, + limit: Option, +} + +async fn list_bugs( + State(st): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + let limit = q.limit.unwrap_or(50).min(200); + let miner = if matches!(q.mine.as_deref(), Some("1") | Some("true") | Some("yes")) { + match miner_hotkey(&headers) { + Ok(h) => Some(h), + Err(r) => return r, + } + } else { + None + }; + let status = q.status.as_deref(); + match st.store.list_bugs(status, miner.as_deref(), limit).await { + Ok(rows) => Json(json!({ + "bugs": rows.iter().map(|b| bug_public_json(b, false)).collect::>(), + })) + .into_response(), + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + } +} + +async fn get_bug(State(st): State>, AxumPath(id): AxumPath) -> Response { + match st.store.get_bug(&id).await { + Ok(Some(b)) => Json(bug_public_json(&b, false)).into_response(), + Ok(None) => json_err(StatusCode::NOT_FOUND, "not_found", "bug not found"), + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + } +} + +async fn get_bug_video( + State(st): State>, + AxumPath(id): AxumPath, +) -> Response { + let bug = match st.store.get_bug(&id).await { + Ok(Some(b)) => b, + Ok(None) => return json_err(StatusCode::NOT_FOUND, "not_found", "bug not found"), + Err(e) => { + return json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()); + } + }; + let Some(path) = bug.video_path.as_deref() else { + return json_err(StatusCode::NOT_FOUND, "no_video", "video not ready"); + }; + // Prefer compressed artifact when present. + let compressed = st.artifacts_root.join(&id).join("video.mp4"); + let serve = if compressed.is_file() { + compressed + } else { + PathBuf::from(path) + }; + serve_video_file(&serve).await +} + +async fn serve_video_file(path: &Path) -> Response { + let file = match tokio::fs::File::open(path).await { + Ok(f) => f, + Err(_) => return json_err(StatusCode::NOT_FOUND, "no_video", "video file missing"), + }; + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + (StatusCode::OK, [(header::CONTENT_TYPE, "video/mp4")], body).into_response() +} + +async fn admin_list_bugs( + State(st): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(r) = check_admin(&st, &headers) { + return r; + } + let limit = q.limit.unwrap_or(50).min(200); + let status = q.status.as_deref().unwrap_or("pending_admin"); + match st.store.list_bugs(Some(status), None, limit).await { + Ok(rows) => Json(json!({ + "bugs": rows.iter().map(|b| bug_public_json(b, true)).collect::>(), + })) + .into_response(), + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + } +} + +async fn admin_get_bug( + State(st): State>, + headers: HeaderMap, + AxumPath(id): AxumPath, +) -> Response { + if let Err(r) = check_admin(&st, &headers) { + return r; + } + match st.store.get_bug(&id).await { + Ok(Some(b)) => { + let events = st.store.events(&id).await.unwrap_or_default(); + let mut v = bug_public_json(&b, true); + v["events"] = json!(events); + Json(v).into_response() + } + Ok(None) => json_err(StatusCode::NOT_FOUND, "not_found", "bug not found"), + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + } +} + +async fn admin_approve( + State(st): State>, + headers: HeaderMap, + AxumPath(id): AxumPath, +) -> Response { + if let Err(r) = check_admin(&st, &headers) { + return r; + } + let bug = match st.store.get_bug(&id).await { + Ok(Some(b)) => b, + Ok(None) => return json_err(StatusCode::NOT_FOUND, "not_found", "bug not found"), + Err(e) => { + return json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()); + } + }; + if bug.status != BugStatus::PendingAdmin { + return json_err( + StatusCode::CONFLICT, + "not_pending", + &format!("status={}", bug.status.as_str()), + ); + } + match st + .store + .apply( + &id, + &BugPatch { + status: Some(BugStatus::Approved), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "approved".into(), + detail: Some(json!({"by": "admin"})), + at_ms: 0, + }), + ) + .await + { + Ok(b) => Json(bug_public_json(&b, true)).into_response(), + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + } +} + +#[derive(Debug, Deserialize)] +struct RejectBody { + reason: Option, +} + +async fn admin_reject( + State(st): State>, + headers: HeaderMap, + AxumPath(id): AxumPath, + body: Option>, +) -> Response { + if let Err(r) = check_admin(&st, &headers) { + return r; + } + let bug = match st.store.get_bug(&id).await { + Ok(Some(b)) => b, + Ok(None) => return json_err(StatusCode::NOT_FOUND, "not_found", "bug not found"), + Err(e) => { + return json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()); + } + }; + if bug.status != BugStatus::PendingAdmin { + return json_err( + StatusCode::CONFLICT, + "not_pending", + &format!("status={}", bug.status.as_str()), + ); + } + let reason = body + .and_then(|b| b.reason.clone()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "admin_reject".into()); + let reason: String = reason.chars().take(500).collect(); + match st + .store + .apply( + &id, + &BugPatch { + status: Some(BugStatus::Rejected), + reject_reason: Some(reason.clone()), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "rejected".into(), + detail: Some(json!({"by": "admin", "reason": reason})), + at_ms: 0, + }), + ) + .await + { + Ok(b) => Json(bug_public_json(&b, true)).into_response(), + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use bounty_store::MemoryBountyStore; + use http_body_util::BodyExt; + use tower::ServiceExt; + + fn state_with_admin(token: &str) -> Arc { + let dir = tempfile::tempdir().unwrap(); + // Leak tempdir for test process lifetime. + let artifacts = dir.path().to_path_buf(); + std::mem::forget(dir); + Arc::new(AppState { + store: Arc::new(MemoryBountyStore::new()), + backend_mode: "memory".into(), + artifacts_root: artifacts, + admin_token_hashes: vec![token_hash(token)], + metagraph: None, + epoch: Arc::new(AtomicU64::new(42)), + max_upload_bytes: 1_000_000, + }) + } + + #[tokio::test] + async fn health_ok() { + let st = state_with_admin("secret"); + let app = bounty_router(st); + let res = app + .oneshot( + axum::http::Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } + + #[tokio::test] + async fn multipart_submit_and_admin_approve() { + let token = "test-admin-token"; + let st = state_with_admin(token); + let app = bounty_router(Arc::clone(&st)); + let hk = "ab".repeat(32); + let boundary = "----BOUND"; + let mut body = Vec::new(); + for (name, val) in [ + ("title", "crash"), + ("description", "null deref"), + ("app_id", "demo"), + ] { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(), + ); + body.extend_from_slice(val.as_bytes()); + body.extend_from_slice(b"\r\n"); + } + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + b"Content-Disposition: form-data; name=\"video\"; filename=\"a.mp4\"\r\n", + ); + body.extend_from_slice(b"Content-Type: video/mp4\r\n\r\n"); + body.extend_from_slice(b"fake-mp4-bytes"); + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + + let res = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/v1/bugs") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .header("x-miner-hotkey", &hk) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::ACCEPTED); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let id = v["id"].as_str().unwrap().to_owned(); + + // Force pending_admin for admin path (pipeline covered elsewhere). + st.store + .apply( + &id, + &BugPatch { + status: Some(BugStatus::PendingAdmin), + ..BugPatch::default() + }, + None, + ) + .await + .unwrap(); + + let app = bounty_router(Arc::clone(&st)); + let res = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(format!("/v1/admin/bugs/{id}/approve")) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bug = st.store.get_bug(&id).await.unwrap().unwrap(); + assert_eq!(bug.status, BugStatus::Approved); + } +} diff --git a/crates/bounty-http/src/lib.rs b/crates/bounty-http/src/lib.rs new file mode 100644 index 000000000..d8a7553c6 --- /dev/null +++ b/crates/bounty-http/src/lib.rs @@ -0,0 +1,18 @@ +//! Bounty challenge HTTP surface (miner / admin / public). + +#![forbid(unsafe_code)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::too_many_lines)] +#![allow(clippy::result_large_err)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::cast_sign_loss)] +#![allow(clippy::map_unwrap_or)] +#![allow(clippy::case_sensitive_file_extension_comparisons)] +#![allow(clippy::unnested_or_patterns)] +#![allow(clippy::manual_let_else)] + +mod api; + +pub use api::{bounty_router, AppState}; +pub use bounty_challenge_task::CHALLENGE_ID; diff --git a/crates/bounty-store-pg/Cargo.toml b/crates/bounty-store-pg/Cargo.toml new file mode 100644 index 000000000..1677c2eb0 --- /dev/null +++ b/crates/bounty-store-pg/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bounty-store-pg" +description = "Postgres BountyStore adapter" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +async-trait = "0.1" +bounty-store = { path = "../bounty-store" } +db = { path = "../db" } +serde_json = "1" +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio", + "postgres", + "json", +] } + +[lints] +workspace = true diff --git a/crates/bounty-store-pg/src/lib.rs b/crates/bounty-store-pg/src/lib.rs new file mode 100644 index 000000000..647dcda54 --- /dev/null +++ b/crates/bounty-store-pg/src/lib.rs @@ -0,0 +1,475 @@ +//! Postgres [`BountyStore`] adapter. + +#![forbid(unsafe_code)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::cast_sign_loss)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::too_many_arguments)] +#![allow(clippy::needless_raw_string_hashes)] + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use bounty_store::{ + BountyStore, BugPatch, BugRow, BugStatus, EpochScoreRow, FinalScore, StageEvent, StoreError, +}; +use db::PgPool; +use serde_json::Value; +use sqlx::Row; + +/// SQL-backed production store. +#[derive(Debug, Clone)] +pub struct DbBountyStore { + pool: PgPool, +} + +impl DbBountyStore { + /// From pool. + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Pool getter (diagnostics). + #[must_use] + pub const fn pool(&self) -> &PgPool { + &self.pool + } +} + +fn map_sqlx(e: sqlx::Error) -> StoreError { + let s = e.to_string(); + if s.contains("duplicate") || s.contains("unique") { + StoreError::Duplicate + } else { + StoreError::Backend(s) + } +} + +fn final_from(kind: Option<&str>, score: Option, absence: Option) -> Option { + match kind { + Some("score") => score.map(|s| FinalScore::Score(s.cast_unsigned())), + Some("no_score") => absence.map(|a| FinalScore::NoScore(u8::try_from(a).unwrap_or(0))), + _ => None, + } +} + +fn kind_score(f: Option<&FinalScore>) -> (Option<&'static str>, Option, Option) { + match f { + Some(FinalScore::Score(v)) => ( + Some("score"), + Some(i64::try_from(*v).unwrap_or(i64::MAX)), + None, + ), + Some(FinalScore::NoScore(r)) => (Some("no_score"), None, Some(i16::from(*r))), + None => (None, None, None), + } +} + +fn row_from( + id: String, + miner_hotkey: String, + miner_coldkey: Option, + app_id: String, + title: String, + description: String, + steps: Option, + status: String, + agentic_verdict: Option, + nearest_id: Option, + video_sha256: Option, + video_bytes: Option, + video_path: Option, + reject_reason: Option, + epoch: i64, + created_at_ms: i64, + updated_at_ms: i64, +) -> BugRow { + BugRow { + id, + miner_hotkey, + miner_coldkey, + app_id, + title, + description, + steps, + status: BugStatus::parse(&status).unwrap_or(BugStatus::Failed), + agentic_verdict, + nearest_id, + video_sha256, + video_bytes: video_bytes.map(|b| b.max(0).cast_unsigned()), + video_path, + reject_reason, + epoch: epoch.max(0).cast_unsigned(), + created_at_ms: created_at_ms.max(0).cast_unsigned(), + updated_at_ms: updated_at_ms.max(0).cast_unsigned(), + } +} + +macro_rules! bug_select { + () => { + r#" + SELECT id, miner_hotkey, miner_coldkey, app_id, title, description, steps, + status, agentic_verdict, nearest_id, video_sha256, video_bytes, video_path, + reject_reason, epoch, + (EXTRACT(EPOCH FROM created_at) * 1000)::BIGINT AS created_at_ms, + (EXTRACT(EPOCH FROM updated_at) * 1000)::BIGINT AS updated_at_ms + FROM bounty_bug + "# + }; +} + +fn map_bug_row(r: sqlx::postgres::PgRow) -> Result { + Ok(row_from( + r.try_get("id").map_err(map_sqlx)?, + r.try_get("miner_hotkey").map_err(map_sqlx)?, + r.try_get("miner_coldkey").map_err(map_sqlx)?, + r.try_get("app_id").map_err(map_sqlx)?, + r.try_get("title").map_err(map_sqlx)?, + r.try_get("description").map_err(map_sqlx)?, + r.try_get("steps").map_err(map_sqlx)?, + r.try_get("status").map_err(map_sqlx)?, + r.try_get("agentic_verdict").map_err(map_sqlx)?, + r.try_get("nearest_id").map_err(map_sqlx)?, + r.try_get("video_sha256").map_err(map_sqlx)?, + r.try_get("video_bytes").map_err(map_sqlx)?, + r.try_get("video_path").map_err(map_sqlx)?, + r.try_get("reject_reason").map_err(map_sqlx)?, + r.try_get("epoch").map_err(map_sqlx)?, + r.try_get("created_at_ms").map_err(map_sqlx)?, + r.try_get("updated_at_ms").map_err(map_sqlx)?, + )) +} + +#[async_trait] +impl BountyStore for DbBountyStore { + async fn insert_bug(&self, row: &BugRow) -> Result<(), StoreError> { + let epoch = i64::try_from(row.epoch).unwrap_or(i64::MAX); + let res = sqlx::query( + r#" + INSERT INTO bounty_bug ( + id, miner_hotkey, miner_coldkey, app_id, title, description, steps, + status, agentic_verdict, nearest_id, video_sha256, video_bytes, video_path, + reject_reason, epoch + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + "#, + ) + .bind(&row.id) + .bind(&row.miner_hotkey) + .bind(row.miner_coldkey.as_deref()) + .bind(&row.app_id) + .bind(&row.title) + .bind(&row.description) + .bind(row.steps.as_deref()) + .bind(row.status.as_str()) + .bind(&row.agentic_verdict) + .bind(row.nearest_id.as_deref()) + .bind(row.video_sha256.as_deref()) + .bind( + row.video_bytes + .map(|b| i64::try_from(b).unwrap_or(i64::MAX)), + ) + .bind(row.video_path.as_deref()) + .bind(row.reject_reason.as_deref()) + .bind(epoch) + .execute(&self.pool) + .await + .map_err(map_sqlx)?; + if res.rows_affected() == 0 { + return Err(StoreError::Backend("insert_bug: no rows".into())); + } + sqlx::query( + r#" + INSERT INTO bounty_stage_event (bug_id, stage, detail) + VALUES ($1, $2, NULL) + "#, + ) + .bind(&row.id) + .bind(row.status.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx)?; + Ok(()) + } + + async fn get_bug(&self, id: &str) -> Result, StoreError> { + let q = concat!(bug_select!(), " WHERE id = $1"); + let row = sqlx::query(q) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx)?; + row.map(map_bug_row).transpose() + } + + async fn apply( + &self, + id: &str, + patch: &BugPatch, + event: Option<&StageEvent>, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(map_sqlx)?; + let existing = sqlx::query(concat!(bug_select!(), " WHERE id = $1 FOR UPDATE")) + .bind(id) + .fetch_optional(&mut *tx) + .await + .map_err(map_sqlx)? + .ok_or(StoreError::NotFound)?; + let mut row = map_bug_row(existing)?; + if let Some(s) = patch.status { + row.status = s; + } + if let Some(v) = &patch.agentic_verdict { + row.agentic_verdict = Some(v.clone()); + } + if let Some(n) = &patch.nearest_id { + row.nearest_id = Some(n.clone()); + } + if let Some(h) = &patch.video_sha256 { + row.video_sha256 = Some(h.clone()); + } + if let Some(b) = patch.video_bytes { + row.video_bytes = Some(b); + } + if let Some(p) = &patch.video_path { + row.video_path = Some(p.clone()); + } + if let Some(r) = &patch.reject_reason { + row.reject_reason = Some(r.clone()); + } + sqlx::query( + r#" + UPDATE bounty_bug SET + status = $2, + agentic_verdict = $3, + nearest_id = $4, + video_sha256 = $5, + video_bytes = $6, + video_path = $7, + reject_reason = $8, + updated_at = now() + WHERE id = $1 + "#, + ) + .bind(id) + .bind(row.status.as_str()) + .bind(&row.agentic_verdict) + .bind(row.nearest_id.as_deref()) + .bind(row.video_sha256.as_deref()) + .bind( + row.video_bytes + .map(|b| i64::try_from(b).unwrap_or(i64::MAX)), + ) + .bind(row.video_path.as_deref()) + .bind(row.reject_reason.as_deref()) + .execute(&mut *tx) + .await + .map_err(map_sqlx)?; + if let Some(ev) = event { + sqlx::query( + r#" + INSERT INTO bounty_stage_event (bug_id, stage, detail) + VALUES ($1, $2, $3) + "#, + ) + .bind(id) + .bind(&ev.stage) + .bind(&ev.detail) + .execute(&mut *tx) + .await + .map_err(map_sqlx)?; + } + tx.commit().await.map_err(map_sqlx)?; + self.get_bug(id).await?.ok_or(StoreError::NotFound) + } + + async fn claim_next(&self) -> Result, StoreError> { + let q = concat!( + "WITH cte AS (", + " SELECT id FROM bounty_bug", + " WHERE status = 'uploaded'", + " ORDER BY created_at ASC", + " FOR UPDATE SKIP LOCKED", + " LIMIT 1", + ")", + "UPDATE bounty_bug b SET status = 'processing', updated_at = now()", + "FROM cte WHERE b.id = cte.id", + "RETURNING b.id, b.miner_hotkey, b.miner_coldkey, b.app_id, b.title, b.description,", + " b.steps, b.status, b.agentic_verdict, b.nearest_id, b.video_sha256, b.video_bytes,", + " b.video_path, b.reject_reason, b.epoch,", + " (EXTRACT(EPOCH FROM b.created_at) * 1000)::BIGINT AS created_at_ms,", + " (EXTRACT(EPOCH FROM b.updated_at) * 1000)::BIGINT AS updated_at_ms" + ); + let row = sqlx::query(q) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx)?; + row.map(map_bug_row).transpose() + } + + async fn list_bugs( + &self, + status: Option<&str>, + miner: Option<&str>, + limit: u32, + ) -> Result, StoreError> { + let lim = i64::from(limit); + let q = concat!( + bug_select!(), + " WHERE ($1::text IS NULL OR status = $1)", + " AND ($2::text IS NULL OR miner_hotkey = $2)", + " ORDER BY created_at DESC", + " LIMIT $3" + ); + let rows = sqlx::query(q) + .bind(status) + .bind(miner) + .bind(lim) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx)?; + rows.into_iter().map(map_bug_row).collect() + } + + async fn list_similarity_corpus( + &self, + since_ms: u64, + exclude_hotkey: &str, + exclude_coldkey: Option<&str>, + limit: u32, + ) -> Result, StoreError> { + let lim = i64::from(limit); + let since = i64::try_from(since_ms).unwrap_or(i64::MAX); + let q = concat!( + bug_select!(), + " WHERE status IN ('approved','pending_admin','rejected')", + " AND (EXTRACT(EPOCH FROM created_at) * 1000)::BIGINT >= $1", + " AND miner_hotkey <> $2", + " AND ($3::text IS NULL OR miner_coldkey IS DISTINCT FROM $3)", + " ORDER BY created_at DESC", + " LIMIT $4" + ); + let rows = sqlx::query(q) + .bind(since) + .bind(exclude_hotkey) + .bind(exclude_coldkey) + .bind(lim) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx)?; + rows.into_iter().map(map_bug_row).collect() + } + + async fn events(&self, id: &str) -> Result, StoreError> { + let rows = sqlx::query( + r#" + SELECT stage, detail, + (EXTRACT(EPOCH FROM created_at) * 1000)::BIGINT AS at_ms + FROM bounty_stage_event + WHERE bug_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(id) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx)?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + let at_ms: i64 = r.try_get("at_ms").map_err(map_sqlx)?; + out.push(StageEvent { + stage: r.try_get("stage").map_err(map_sqlx)?, + detail: r.try_get("detail").map_err(map_sqlx)?, + at_ms: at_ms.max(0).cast_unsigned(), + }); + } + Ok(out) + } + + async fn approved_points_for_epoch( + &self, + epoch: u64, + ) -> Result, StoreError> { + let ep = i64::try_from(epoch).unwrap_or(i64::MAX); + let rows = sqlx::query( + r#" + SELECT miner_hotkey, COUNT(*)::INT AS points + FROM bounty_bug + WHERE epoch = $1 AND status = 'approved' + GROUP BY miner_hotkey + "#, + ) + .bind(ep) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx)?; + let mut out = BTreeMap::new(); + for r in rows { + let hk: String = r.try_get("miner_hotkey").map_err(map_sqlx)?; + let pts: i32 = r.try_get("points").map_err(map_sqlx)?; + out.insert(hk, pts.max(0).cast_unsigned()); + } + Ok(out) + } + + async fn upsert_epoch_score(&self, row: &EpochScoreRow) -> Result<(), StoreError> { + let (kind, score, absence) = kind_score(row.final_score.as_ref()); + let ep = i64::try_from(row.epoch).unwrap_or(i64::MAX); + let pts = i32::try_from(row.points).unwrap_or(i32::MAX); + sqlx::query( + r#" + INSERT INTO bounty_epoch_score (epoch, miner_hotkey, points, kind, score, absence_reason) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (epoch, miner_hotkey) DO UPDATE SET + points = EXCLUDED.points, + kind = EXCLUDED.kind, + score = EXCLUDED.score, + absence_reason = EXCLUDED.absence_reason, + updated_at = now() + "#, + ) + .bind(ep) + .bind(&row.miner_hotkey) + .bind(pts) + .bind(kind) + .bind(score) + .bind(absence) + .execute(&self.pool) + .await + .map_err(map_sqlx)?; + Ok(()) + } + + async fn list_epoch_scores(&self, epoch: u64) -> Result, StoreError> { + let ep = i64::try_from(epoch).unwrap_or(i64::MAX); + let rows = sqlx::query( + r#" + SELECT epoch, miner_hotkey, points, kind, score, absence_reason + FROM bounty_epoch_score + WHERE epoch = $1 + ORDER BY miner_hotkey + "#, + ) + .bind(ep) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx)?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + let epoch_i: i64 = r.try_get("epoch").map_err(map_sqlx)?; + let points: i32 = r.try_get("points").map_err(map_sqlx)?; + let kind: Option = r.try_get("kind").map_err(map_sqlx)?; + let score: Option = r.try_get("score").map_err(map_sqlx)?; + let absence: Option = r.try_get("absence_reason").map_err(map_sqlx)?; + out.push(EpochScoreRow { + epoch: epoch_i.max(0).cast_unsigned(), + miner_hotkey: r.try_get("miner_hotkey").map_err(map_sqlx)?, + points: points.max(0).cast_unsigned(), + final_score: final_from(kind.as_deref(), score, absence), + }); + } + Ok(out) + } +} diff --git a/crates/bounty-store/Cargo.toml b/crates/bounty-store/Cargo.toml new file mode 100644 index 000000000..b0d837f0e --- /dev/null +++ b/crates/bounty-store/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "bounty-store" +description = "Bounty challenge persistence trait + memory impl" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } + +[lints] +workspace = true diff --git a/crates/bounty-store/src/lib.rs b/crates/bounty-store/src/lib.rs new file mode 100644 index 000000000..2c3b2e3a6 --- /dev/null +++ b/crates/bounty-store/src/lib.rs @@ -0,0 +1,15 @@ +//! Bounty challenge persistence surface. +//! +//! [`MemoryBountyStore`] for CI/sim; Postgres adapter lives in `bounty-store-pg`. + +#![forbid(unsafe_code)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::cast_possible_truncation)] + +mod store; + +pub use store::{ + BountyStore, BugPatch, BugRow, BugStatus, EpochScoreRow, FinalScore, MemoryBountyStore, + StageEvent, StoreError, +}; diff --git a/crates/bounty-store/src/store.rs b/crates/bounty-store/src/store.rs new file mode 100644 index 000000000..e4f7a3be3 --- /dev/null +++ b/crates/bounty-store/src/store.rs @@ -0,0 +1,514 @@ +//! Async persistence contract + in-memory impl. + +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::sync::Mutex; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +/// Chain-facing score columns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FinalScore { + /// Lattice score. + Score(u64), + /// Explicit absence (`NoScoreReasonCode` as u8). + NoScore(u8), +} + +/// Bug lifecycle (DB CHECK mirrors this list). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BugStatus { + /// Raw video accepted; awaiting compress worker. + Uploaded, + /// ffmpeg / fingerprint in flight. + Processing, + /// Agentic similar-24h review. + AgenticReview, + /// Novel; waiting for admin approve/reject. + PendingAdmin, + /// Admin-approved (+1 epoch point). + Approved, + /// Terminal reject (duplicate / admin / policy). + Rejected, + /// Infra failure (retryable or terminal). + Failed, +} + +impl BugStatus { + /// DB string. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Uploaded => "uploaded", + Self::Processing => "processing", + Self::AgenticReview => "agentic_review", + Self::PendingAdmin => "pending_admin", + Self::Approved => "approved", + Self::Rejected => "rejected", + Self::Failed => "failed", + } + } + + /// Parse DB string. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "uploaded" => Some(Self::Uploaded), + "processing" => Some(Self::Processing), + "agentic_review" => Some(Self::AgenticReview), + "pending_admin" => Some(Self::PendingAdmin), + "approved" => Some(Self::Approved), + "rejected" => Some(Self::Rejected), + "failed" => Some(Self::Failed), + _ => None, + } + } + + /// Terminal. + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Approved | Self::Rejected | Self::Failed) + } +} + +/// One bug report row. +#[derive(Debug, Clone)] +pub struct BugRow { + /// Stable id (digest hex). + pub id: String, + /// Miner hotkey (lowercase 64 hex). + pub miner_hotkey: String, + /// Owning coldkey when known at intake. + pub miner_coldkey: Option, + /// Target app slug. + pub app_id: String, + /// Short title. + pub title: String, + /// Description body. + pub description: String, + /// Optional repro steps. + pub steps: Option, + /// Lifecycle. + pub status: BugStatus, + /// Agentic similarity verdict JSON. + pub agentic_verdict: Option, + /// Nearest duplicate bug id (when flagged). + pub nearest_id: Option, + /// Compressed video sha256 hex. + pub video_sha256: Option, + /// Compressed video byte length. + pub video_bytes: Option, + /// Artifact path under bounty-artifacts volume. + pub video_path: Option, + /// Miner-visible reject reason. + pub reject_reason: Option, + /// Chain epoch at acceptance. + pub epoch: u64, + /// Created unix ms. + pub created_at_ms: u64, + /// Updated unix ms. + pub updated_at_ms: u64, +} + +/// Append-only stage journal entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageEvent { + /// Stage entered/annotated. + pub stage: String, + /// Structured detail (bounded). + pub detail: Option, + /// Event unix ms (0 = server-side timestamp). + pub at_ms: u64, +} + +/// Per-(epoch, miner) score / points projection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EpochScoreRow { + /// Chain epoch. + pub epoch: u64, + /// Miner hotkey. + pub miner_hotkey: String, + /// Approved bug points this epoch. + pub points: u32, + /// Final chain-facing score. + pub final_score: Option, +} + +/// Partial bug update. +#[derive(Debug, Clone, Default)] +pub struct BugPatch { + /// New status. + pub status: Option, + /// Agentic verdict JSON. + pub agentic_verdict: Option, + /// Nearest duplicate id. + pub nearest_id: Option, + /// Video sha256. + pub video_sha256: Option, + /// Video bytes. + pub video_bytes: Option, + /// Video path. + pub video_path: Option, + /// Reject reason. + pub reject_reason: Option, +} + +/// Persistence errors. +#[derive(Debug, Error)] +pub enum StoreError { + /// Backend fault. + #[error("backend: {0}")] + Backend(String), + /// Row vanished mid-flight. + #[error("not found")] + NotFound, + /// Duplicate key. + #[error("duplicate")] + Duplicate, +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) +} + +/// Async persistence contract (production + sim). +#[async_trait] +pub trait BountyStore: Send + Sync + std::fmt::Debug { + /// Insert a freshly uploaded bug (`status=uploaded`). + async fn insert_bug(&self, row: &BugRow) -> Result<(), StoreError>; + + /// Fetch one bug. + async fn get_bug(&self, id: &str) -> Result, StoreError>; + + /// Apply a partial update + optional journal event. + async fn apply( + &self, + id: &str, + patch: &BugPatch, + event: Option<&StageEvent>, + ) -> Result; + + /// Claim next `uploaded` row → `processing` (`SKIP LOCKED` in PG). + async fn claim_next(&self) -> Result, StoreError>; + + /// List bugs (`status` / `miner` optional filters), newest first. + async fn list_bugs( + &self, + status: Option<&str>, + miner: Option<&str>, + limit: u32, + ) -> Result, StoreError>; + + /// Similarity corpus: bugs in `approved|pending_admin|rejected` with + /// `created_at_ms >= since_ms`, excluding same hotkey/coldkey. + async fn list_similarity_corpus( + &self, + since_ms: u64, + exclude_hotkey: &str, + exclude_coldkey: Option<&str>, + limit: u32, + ) -> Result, StoreError>; + + /// Ascending stage journal for a bug. + async fn events(&self, id: &str) -> Result, StoreError>; + + /// Approved point counts for an epoch (hotkey → count). + async fn approved_points_for_epoch( + &self, + epoch: u64, + ) -> Result, StoreError>; + + /// Upsert epoch score row. + async fn upsert_epoch_score(&self, row: &EpochScoreRow) -> Result<(), StoreError>; + + /// List epoch score rows. + async fn list_epoch_scores(&self, epoch: u64) -> Result, StoreError>; +} + +/// In-memory store for tests / local sim. +#[derive(Debug, Default)] +pub struct MemoryBountyStore { + bugs: Mutex>, + events: Mutex>>, + epoch_scores: Mutex>, + /// FIFO of uploaded ids awaiting claim. + queue: Mutex>, +} + +impl MemoryBountyStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl BountyStore for MemoryBountyStore { + async fn insert_bug(&self, row: &BugRow) -> Result<(), StoreError> { + let mut bugs = self + .bugs + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + if bugs.contains_key(&row.id) { + return Err(StoreError::Duplicate); + } + bugs.insert(row.id.clone(), row.clone()); + if row.status == BugStatus::Uploaded { + let mut q = self + .queue + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + q.push_back(row.id.clone()); + } + let mut ev = self + .events + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + ev.entry(row.id.clone()).or_default().push_back(StageEvent { + stage: row.status.as_str().into(), + detail: None, + at_ms: row.created_at_ms, + }); + Ok(()) + } + + async fn get_bug(&self, id: &str) -> Result, StoreError> { + let bugs = self + .bugs + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + Ok(bugs.get(id).cloned()) + } + + async fn apply( + &self, + id: &str, + patch: &BugPatch, + event: Option<&StageEvent>, + ) -> Result { + let mut bugs = self + .bugs + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + let row = bugs.get_mut(id).ok_or(StoreError::NotFound)?; + if let Some(s) = patch.status { + row.status = s; + } + if let Some(v) = &patch.agentic_verdict { + row.agentic_verdict = Some(v.clone()); + } + if let Some(n) = &patch.nearest_id { + row.nearest_id = Some(n.clone()); + } + if let Some(h) = &patch.video_sha256 { + row.video_sha256 = Some(h.clone()); + } + if let Some(b) = patch.video_bytes { + row.video_bytes = Some(b); + } + if let Some(p) = &patch.video_path { + row.video_path = Some(p.clone()); + } + if let Some(r) = &patch.reject_reason { + row.reject_reason = Some(r.clone()); + } + row.updated_at_ms = now_ms(); + let out = row.clone(); + drop(bugs); + if let Some(ev) = event { + let mut events = self + .events + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + let mut e = ev.clone(); + if e.at_ms == 0 { + e.at_ms = now_ms(); + } + events.entry(id.to_owned()).or_default().push_back(e); + } + Ok(out) + } + + async fn claim_next(&self) -> Result, StoreError> { + let mut q = self + .queue + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + let mut bugs = self + .bugs + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + while let Some(id) = q.pop_front() { + if let Some(row) = bugs.get_mut(&id) { + if row.status == BugStatus::Uploaded { + row.status = BugStatus::Processing; + row.updated_at_ms = now_ms(); + return Ok(Some(row.clone())); + } + } + } + Ok(None) + } + + async fn list_bugs( + &self, + status: Option<&str>, + miner: Option<&str>, + limit: u32, + ) -> Result, StoreError> { + let bugs = self + .bugs + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + let mut rows: Vec<_> = bugs + .values() + .filter(|r| status.is_none_or(|s| r.status.as_str() == s)) + .filter(|r| miner.is_none_or(|m| r.miner_hotkey == m)) + .cloned() + .collect(); + rows.sort_by_key(|b| std::cmp::Reverse(b.created_at_ms)); + rows.truncate(limit as usize); + Ok(rows) + } + + async fn list_similarity_corpus( + &self, + since_ms: u64, + exclude_hotkey: &str, + exclude_coldkey: Option<&str>, + limit: u32, + ) -> Result, StoreError> { + let bugs = self + .bugs + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + let mut rows: Vec<_> = bugs + .values() + .filter(|r| { + matches!( + r.status, + BugStatus::Approved | BugStatus::PendingAdmin | BugStatus::Rejected + ) + }) + .filter(|r| r.created_at_ms >= since_ms) + .filter(|r| r.miner_hotkey != exclude_hotkey) + .filter(|r| exclude_coldkey.is_none_or(|ck| r.miner_coldkey.as_deref() != Some(ck))) + .cloned() + .collect(); + rows.sort_by_key(|b| std::cmp::Reverse(b.created_at_ms)); + rows.truncate(limit as usize); + Ok(rows) + } + + async fn events(&self, id: &str) -> Result, StoreError> { + let events = self + .events + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + Ok(events + .get(id) + .map(|d| d.iter().cloned().collect()) + .unwrap_or_default()) + } + + async fn approved_points_for_epoch( + &self, + epoch: u64, + ) -> Result, StoreError> { + let bugs = self + .bugs + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + let mut out = BTreeMap::new(); + for r in bugs.values() { + if r.epoch == epoch && r.status == BugStatus::Approved { + *out.entry(r.miner_hotkey.clone()).or_insert(0u32) += 1; + } + } + Ok(out) + } + + async fn upsert_epoch_score(&self, row: &EpochScoreRow) -> Result<(), StoreError> { + let mut scores = self + .epoch_scores + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + scores.insert((row.epoch, row.miner_hotkey.clone()), row.clone()); + Ok(()) + } + + async fn list_epoch_scores(&self, epoch: u64) -> Result, StoreError> { + let scores = self + .epoch_scores + .lock() + .map_err(|e| StoreError::Backend(e.to_string()))?; + let mut rows: Vec<_> = scores + .values() + .filter(|r| r.epoch == epoch) + .cloned() + .collect(); + rows.sort_by_key(|r| r.miner_hotkey.clone()); + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn sample(id: &str, status: BugStatus) -> BugRow { + BugRow { + id: id.into(), + miner_hotkey: "aa".repeat(32), + miner_coldkey: None, + app_id: "demo".into(), + title: "t".into(), + description: "d".into(), + steps: None, + status, + agentic_verdict: None, + nearest_id: None, + video_sha256: None, + video_bytes: None, + video_path: None, + reject_reason: None, + epoch: 1, + created_at_ms: 1_000, + updated_at_ms: 1_000, + } + } + + #[tokio::test] + async fn memory_insert_claim_approve_points() { + let s = MemoryBountyStore::new(); + s.insert_bug(&sample("b1", BugStatus::Uploaded)) + .await + .unwrap(); + let claimed = s.claim_next().await.unwrap().unwrap(); + assert_eq!(claimed.status, BugStatus::Processing); + s.apply( + "b1", + &BugPatch { + status: Some(BugStatus::Approved), + ..BugPatch::default() + }, + Some(&StageEvent { + stage: "approved".into(), + detail: None, + at_ms: 0, + }), + ) + .await + .unwrap(); + let pts = s.approved_points_for_epoch(1).await.unwrap(); + assert_eq!(pts.get(&"aa".repeat(32)), Some(&1)); + assert_eq!(s.events("b1").await.unwrap().len(), 2); + } +} diff --git a/crates/challenge-agentic/src/sim.rs b/crates/challenge-agentic/src/sim.rs index 8fff93cfd..c25ca917a 100644 --- a/crates/challenge-agentic/src/sim.rs +++ b/crates/challenge-agentic/src/sim.rs @@ -214,52 +214,22 @@ fn static_source_cheat_verdict( /// Brand clusters for scrape-style famous-site clones (≥2 hits in one cluster). const FAMOUS_SITE_CLUSTERS: &[&[&str]] = &[ + // x.com survives sanitize when og:/data-testid markers are stripped. &[ - "twitter.com", - // Survives sanitize when og:/data-testid markers are stripped. - "x.com", - "property=\"og:site_name\" content=\"twitter\"", - "property=\"og:site_name\" content=\"x\"", - "data-testid=\"tweet\"", + "twitter.com", "x.com", "property=\"og:site_name\" content=\"twitter\"", + "property=\"og:site_name\" content=\"x\"", "data-testid=\"tweet\"", "aria-label=\"twitter\"", ], &[ - "facebook.com", - "property=\"og:site_name\" content=\"facebook\"", - "fbcdn.net", + "facebook.com", "property=\"og:site_name\" content=\"facebook\"", "fbcdn.net", "data-testid=\"facebook", ], - &[ - "instagram.com", - "property=\"og:site_name\" content=\"instagram\"", - "cdninstagram.com", - ], - &[ - "youtube.com", - "property=\"og:site_name\" content=\"youtube\"", - "ytimg.com", - "ytd-app", - ], - &[ - "linkedin.com", - "property=\"og:site_name\" content=\"linkedin\"", - "licdn.com", - ], - &[ - "netflix.com", - "property=\"og:site_name\" content=\"netflix\"", - "nflxext.com", - ], - &[ - "amazon.com", - "property=\"og:site_name\" content=\"amazon\"", - "amazon-adsystem.com", - ], - &[ - "github.com", - "property=\"og:site_name\" content=\"github\"", - "github.githubassets.com", - ], + &["instagram.com", "property=\"og:site_name\" content=\"instagram\"", "cdninstagram.com"], + &["youtube.com", "property=\"og:site_name\" content=\"youtube\"", "ytimg.com", "ytd-app"], + &["linkedin.com", "property=\"og:site_name\" content=\"linkedin\"", "licdn.com"], + &["netflix.com", "property=\"og:site_name\" content=\"netflix\"", "nflxext.com"], + &["amazon.com", "property=\"og:site_name\" content=\"amazon\"", "amazon-adsystem.com"], + &["github.com", "property=\"og:site_name\" content=\"github\"", "github.githubassets.com"], ]; /// Ultra-strong single markers (`og:site_name` of a famous property). diff --git a/crates/db/migrations/0017_bounty_challenge.sql b/crates/db/migrations/0017_bounty_challenge.sql new file mode 100644 index 000000000..55dc3bbab --- /dev/null +++ b/crates/db/migrations/0017_bounty_challenge.sql @@ -0,0 +1,91 @@ +-- Bounty challenge: video bug reports, stage journal, epoch scores. +-- +-- `bounty_bug` is the mutable state row behind miner/admin APIs. Video bytes +-- live on the `bounty-artifacts` volume (`video_path`); only sha256 + size +-- are stored here. `bounty_stage_event` is append-only (INSERT for base_app). +-- `bounty_epoch_score` holds approved points + leaf projection per epoch. + +CREATE TABLE bounty_bug ( + id TEXT PRIMARY KEY, + miner_hotkey TEXT NOT NULL, + miner_coldkey TEXT, + app_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + steps TEXT, + status TEXT NOT NULL, + agentic_verdict JSONB, + nearest_id TEXT, + video_sha256 TEXT, + video_bytes BIGINT, + video_path TEXT, + reject_reason TEXT, + epoch BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT bounty_bug_status_check CHECK (status IN ( + 'uploaded','processing','agentic_review','pending_admin', + 'approved','rejected','failed')), + CONSTRAINT bounty_bug_epoch_nonneg CHECK (epoch >= 0), + CONSTRAINT bounty_bug_miner_hotkey_hex CHECK (miner_hotkey ~ '^[0-9a-f]{64}$'), + CONSTRAINT bounty_bug_miner_coldkey_hex + CHECK (miner_coldkey IS NULL OR miner_coldkey ~ '^[0-9a-f]{64}$'), + CONSTRAINT bounty_bug_app_id_len CHECK (char_length(app_id) <= 128), + CONSTRAINT bounty_bug_title_len CHECK (char_length(title) <= 256), + CONSTRAINT bounty_bug_description_len CHECK (octet_length(description) <= 65536), + CONSTRAINT bounty_bug_steps_len + CHECK (steps IS NULL OR octet_length(steps) <= 65536), + CONSTRAINT bounty_bug_video_sha_hex + CHECK (video_sha256 IS NULL OR video_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT bounty_bug_video_bytes_nonneg + CHECK (video_bytes IS NULL OR video_bytes >= 0), + CONSTRAINT bounty_bug_video_path_len + CHECK (video_path IS NULL OR char_length(video_path) <= 512), + CONSTRAINT bounty_bug_reject_reason_len + CHECK (reject_reason IS NULL OR char_length(reject_reason) <= 500) +); + +CREATE TABLE bounty_stage_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bug_id TEXT NOT NULL REFERENCES bounty_bug(id) ON DELETE CASCADE, + stage TEXT NOT NULL, + detail JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT bounty_stage_event_stage_len CHECK (char_length(stage) <= 64) +); + +CREATE TABLE bounty_epoch_score ( + epoch BIGINT NOT NULL, + miner_hotkey TEXT NOT NULL, + points INTEGER NOT NULL DEFAULT 0, + kind TEXT, + score BIGINT, + absence_reason SMALLINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (epoch, miner_hotkey), + CONSTRAINT bounty_epoch_score_epoch_nonneg CHECK (epoch >= 0), + CONSTRAINT bounty_epoch_score_points_nonneg CHECK (points >= 0), + CONSTRAINT bounty_epoch_score_miner_hotkey_hex CHECK (miner_hotkey ~ '^[0-9a-f]{64}$'), + CONSTRAINT bounty_epoch_score_kind_check CHECK (kind IS NULL OR kind IN ('score','no_score')), + CONSTRAINT bounty_epoch_score_score_range + CHECK (score IS NULL OR (score >= 0 AND score <= 1000000)), + CONSTRAINT bounty_epoch_score_absence_range + CHECK (absence_reason IS NULL OR (absence_reason >= 0 AND absence_reason <= 7)) +); + +CREATE INDEX ix_bounty_bug_status ON bounty_bug (status); +CREATE INDEX ix_bounty_bug_miner_epoch ON bounty_bug (miner_hotkey, epoch); +CREATE INDEX ix_bounty_bug_created ON bounty_bug (created_at DESC); +CREATE INDEX ix_bounty_bug_corpus ON bounty_bug (created_at DESC) + WHERE status IN ('approved','pending_admin','rejected'); +CREATE INDEX ix_bounty_bug_coldkey ON bounty_bug (miner_coldkey) + WHERE miner_coldkey IS NOT NULL; +CREATE INDEX ix_bounty_bug_claim ON bounty_bug (created_at) + WHERE status = 'uploaded'; +CREATE INDEX ix_bounty_stage_event_bug ON bounty_stage_event (bug_id, created_at); +CREATE INDEX ix_bounty_epoch_score_epoch ON bounty_epoch_score (epoch); + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bounty_bug TO base_app; +GRANT SELECT, INSERT ON TABLE bounty_stage_event TO base_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bounty_epoch_score TO base_app; diff --git a/deploy/AGENTS.md b/deploy/AGENTS.md index 2e56f21fc..b99c13e56 100644 --- a/deploy/AGENTS.md +++ b/deploy/AGENTS.md @@ -37,14 +37,16 @@ Compose always runs a digest-pinned `postgres` service (`base-pgdata` volume, he |------|--------| | Design harnesses / runs / stages / artifacts metadata / admin rounds | **Postgres** (`design_*`) | | Prism submissions / stage events | **Postgres** (`prism_*`) | +| Bounty bugs / stage events / epoch scores | **Postgres** (`bounty_*`) | | Gateway raw weight leaves + sealed bundles | **Postgres** (`raw_weight_snapshot`, `epoch_bundle`, …) | | Validator attestations (when DB configured) | **Postgres** | | Design sandbox staging files | volume `${BASE_STATE_DIR}/design/staging` + `design-artifacts` | +| Bounty compressed videos | volume `bounty-artifacts` | | Gateway challenge **backend registry** | **in-memory** — re-seed after gateway restart (`remote-deploy.sh` does this on master) | | site-api (`GET /v1/site/*`) | no DB — proxies challenge upstreams via gateway | | Unit/integration tests | may construct `Memory*Store` directly; omit `BASE_DATABASE_URL` only there | -Migrations (`crates/db/migrations`) run on boot in gateway / design-challenge / prism-challenge when `BASE_DATABASE_URL` is set. Compose requires `deploy/env/{design,prism}-challenge.env` so challenges cannot silently boot on memory. +Migrations (`crates/db/migrations`) run on boot in gateway / design-challenge / prism-challenge / bounty-challenge when `BASE_DATABASE_URL` is set. Compose requires `deploy/env/{design,prism,bounty}-challenge.env` so challenges cannot silently boot on memory. Verify rows (local master stack): @@ -71,7 +73,7 @@ Full procedure: [`docs/runbooks/local-testnet-e2e.md`](../docs/runbooks/local-te | Docker, Compose v2 | yes | yes | | `cloudflared` (or `--no-tunnel`) | yes | yes | | `deploy/env/*.env` (examples OK) | yes | yes | -| `gateway_sk` (seal) + `prism_sk` / `design_sk` (leaf sigs; pubs ↔ trust root) | yes (prefer `~/.base-secrets/challenge-*.sk`) | real preferred | +| `gateway_sk` (seal) + `prism_sk` / `design_sk` / `bounty_sk` (leaf sigs; pubs ↔ trust root) | yes (prefer `~/.base-secrets/challenge-*.sk` / age decrypt) | real preferred | | `deploy/secrets/wallets/base-owner` | **no** (not needed for `/v1/weights/latest`) | **yes** (netuid 541 owner) | | `base-validator` wallet | **no** (fetch-only) | for on-chain weight submit | | Fresh `target/release/{gateway,validator,…}` (or `BASE_DOCKER_BUILD_FROM=source`) | recommended | **required** for real chain | diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 89c62dbb1..0a0d0dc2c 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -48,6 +48,7 @@ RUN cargo build --release \ -p prism-challenge-bin \ -p design-challenge-bin \ -p design-egress-proxy-bin \ + -p bounty-challenge-bin \ && cargo build --release -p challenge-review-bin \ && strip target/release/validator \ target/release/gateway \ @@ -55,6 +56,7 @@ RUN cargo build --release \ target/release/prism-challenge \ target/release/design-challenge \ target/release/design-egress-proxy \ + target/release/bounty-challenge \ target/release/challenge-review \ && mkdir -p /out \ && cp target/release/validator \ @@ -63,6 +65,7 @@ RUN cargo build --release \ target/release/prism-challenge \ target/release/design-challenge \ target/release/design-egress-proxy \ + target/release/bounty-challenge \ target/release/challenge-review /out/ # ----------------------------------------------------------------------------- @@ -79,6 +82,7 @@ COPY target/release/validator \ target/release/prism-challenge \ target/release/design-challenge \ target/release/design-egress-proxy \ + target/release/bounty-challenge \ target/release/challenge-review \ /out/ @@ -142,6 +146,18 @@ USER base COPY --from=artifacts --chown=base:base /out/design-challenge /usr/local/bin/design-challenge ENTRYPOINT ["/usr/local/bin/design-challenge"] +FROM runtime-base AS bounty-challenge +USER root +# ffmpeg for miner video compress (720p CRF~28); keep off other runtime targets. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/lib/bounty/artifacts \ + && chown -R base:base /var/lib/bounty +USER base +COPY --from=artifacts --chown=base:base /out/bounty-challenge /usr/local/bin/bounty-challenge +ENTRYPOINT ["/usr/local/bin/bounty-challenge"] + FROM runtime-base AS design-egress-proxy COPY --from=artifacts --chown=base:base /out/design-egress-proxy /usr/local/bin/design-egress-proxy ENTRYPOINT ["/usr/local/bin/design-egress-proxy"] diff --git a/deploy/compose/env-local.yml b/deploy/compose/env-local.yml index 4672975a3..e03135814 100644 --- a/deploy/compose/env-local.yml +++ b/deploy/compose/env-local.yml @@ -97,3 +97,14 @@ services: environment: # No OpenRouter spend locally unless a real key is mounted. DESIGN_EGRESS_SIM: "${LOCAL_DESIGN_EGRESS_SIM:-true}" + + bounty-challenge: + ports: + - "127.0.0.1:${LOCAL_BOUNTY_HOST_PORT:-28095}:8095" + environment: + BASE_DATABASE_URL: ${LOCAL_DATABASE_URL:-postgres://base:base_dev_only_change_me@postgres:5432/base} + # Deterministic agentic similarity on a laptop unless operator opts in. + BOUNTY_FORCE_SIM: "${LOCAL_BOUNTY_FORCE_SIM:-true}" + # Local miners are usually not registered on testnet 541: gating off by + # default here (staging/prod never set this). 1 = enforce 1-max+metagraph. + BASE_SUBMISSION_GATING: "${LOCAL_SUBMISSION_GATING:-0}" diff --git a/deploy/compose/env-prod.yml b/deploy/compose/env-prod.yml index 0f7e32190..8bf1c8f58 100644 --- a/deploy/compose/env-prod.yml +++ b/deploy/compose/env-prod.yml @@ -61,3 +61,8 @@ services: design-egress-proxy: environment: DESIGN_EGRESS_SIM: "false" + bounty-challenge: + environment: + # Docker + keyed OpenRouter only. Never set BOUNTY_FORCE_SIM here. + BOUNTY_FORCE_SIM: "false" + BASE_CHAIN_ENDPOINTS: "wss://bittensor-finney.api.onfinality.io/public-ws,wss://entrypoint-finney.opentensor.ai:443" diff --git a/deploy/compose/env-staging.yml b/deploy/compose/env-staging.yml index 9e19136e4..5254f1ba5 100644 --- a/deploy/compose/env-staging.yml +++ b/deploy/compose/env-staging.yml @@ -42,8 +42,9 @@ services: volumes: - ./deploy/secrets/wallets:/run/base/wallets:ro # The sealer must sign with the same staging trust root the validator - # verifies against (design/prism 5000/5000), or /v1/weights/latest never - # shows design weight and the validator flags emission share mismatch (D23). + # verifies against (design/prism/bounty 3000/4500/2500), or + # /v1/weights/latest never shows challenge weight and the validator flags + # emission share mismatch (D23). - ./config/challenges.staging.toml:/etc/base/config/challenges.toml:ro - ./config/challenges.staging.toml.sig:/etc/base/config/challenges.toml.sig:ro prism-challenge: @@ -75,3 +76,9 @@ services: environment: # Real OpenRouter when deploy/secrets/openrouter/api_key is non-empty. DESIGN_EGRESS_SIM: "false" + bounty-challenge: + environment: + BASE_CHAIN_ENDPOINT: "wss://test.chain.opentensor.ai:443" + BASE_CHAIN_ENDPOINTS: "wss://test.chain.opentensor.ai:443,wss://test.finney.opentensor.ai:443" + # Real OpenRouter similarity when key is mounted; never host Sim on droplets. + BOUNTY_FORCE_SIM: "false" diff --git a/deploy/compose/role-validator.yml b/deploy/compose/role-validator.yml index 89102a7a6..1fc59fc09 100644 --- a/deploy/compose/role-validator.yml +++ b/deploy/compose/role-validator.yml @@ -26,5 +26,7 @@ services: profiles: ["never"] design-egress-proxy: profiles: ["never"] + bounty-challenge: + profiles: ["never"] socket-proxy: profiles: ["never"] diff --git a/deploy/env/bounty-challenge.env.example b/deploy/env/bounty-challenge.env.example new file mode 100644 index 000000000..4e69cdfb8 --- /dev/null +++ b/deploy/env/bounty-challenge.env.example @@ -0,0 +1,23 @@ +# operator-managed, never committed to git. +# Bounty challenge orchestration surface (env file, not inline). + +# Compose requires this file. Bug rows / epoch scores persist to Postgres when set. +# Omit only for unit/integration tests that construct MemoryBountyStore directly. +# Must match deploy/env/postgres.env on the host (see postgres.env.example). +BASE_DATABASE_URL=postgres://base:base_dev_only_change_me@postgres:5432/base +BASE_NETUID=541 +# Live chain; default falls back to finney testnet. +# BASE_CHAIN_ENDPOINT=wss://test.finney.opentensor.ai:443 +# Optional ordered failover list (comma-separated); wins over BASE_CHAIN_ENDPOINT. +# BASE_CHAIN_ENDPOINTS=wss://bittensor-finney.api.onfinality.io/public-ws,wss://entrypoint-finney.opentensor.ai:443 + +# Orchestrator knobs (defaults in place). +# BOUNTY_FORCE_SIM=false +# BOUNTY_OPENROUTER_MODEL=deepseek/deepseek-v4-flash +# BOUNTY_ARTIFACTS_ROOT=/var/lib/bounty + +# Admin bearers: one token per line in deploy/secrets/bounty/admin_tokens +# (hashed at boot). Empty file → admin API rejects all tokens. +# Optional override: BOUNTY_ADMIN_TOKENS_FILE=/run/base/bounty/admin_tokens +# Similarity: OPENROUTER_API_KEY_FILE=/run/base/openrouter/api_key +# (missing key → SimAgent in CI/local only; staging/prod must mount a real key). diff --git a/deploy/scripts/age-encrypt-env.sh b/deploy/scripts/age-encrypt-env.sh index b92afd51b..afefc9c1f 100755 --- a/deploy/scripts/age-encrypt-env.sh +++ b/deploy/scripts/age-encrypt-env.sh @@ -9,7 +9,7 @@ Usage: age-encrypt-env.sh --recipient AGE1... --src-dir DIR [--out-dir DIR] [--s Encrypts .env (or falls back to .env.example) to /.env.age for postgres,validator,gateway,updater,design-challenge,prism-challenge, -design-egress-proxy by default. +design-egress-proxy,bounty-challenge by default. Does not read or write age private keys. EOF @@ -18,7 +18,7 @@ EOF RECIPIENT="" SRC_DIR="" OUT_DIR="" -SERVICES="postgres validator gateway updater design-challenge prism-challenge design-egress-proxy" +SERVICES="postgres validator gateway updater design-challenge prism-challenge design-egress-proxy bounty-challenge" while [[ $# -gt 0 ]]; do case "$1" in diff --git a/deploy/scripts/assert-compose-matrix.sh b/deploy/scripts/assert-compose-matrix.sh index 88a6f9280..7fbb38be9 100755 --- a/deploy/scripts/assert-compose-matrix.sh +++ b/deploy/scripts/assert-compose-matrix.sh @@ -51,7 +51,7 @@ services=$(render \ if echo "$services" | grep -qx "gateway"; then fail "validator role renders gateway (must not)" fi -for banned in design-challenge design-egress-proxy prism-challenge socket-proxy; do +for banned in design-challenge design-egress-proxy prism-challenge bounty-challenge socket-proxy; do if echo "$services" | grep -qx "$banned"; then fail "validator role renders $banned (master-only; must not)" fi @@ -67,7 +67,7 @@ services=$(render \ if ! echo "$services" | grep -qx "gateway"; then fail "master role does not render gateway (must)" fi -for required in design-challenge design-egress-proxy prism-challenge socket-proxy; do +for required in design-challenge design-egress-proxy prism-challenge bounty-challenge socket-proxy; do if ! echo "$services" | grep -qx "$required"; then fail "master role does not render $required (must)" fi @@ -101,7 +101,7 @@ services=$(render \ if echo "$services" | grep -qx "evil-gateway"; then fail "prod validator renders evil-gateway (must not)" fi -for banned in design-challenge design-egress-proxy prism-challenge socket-proxy; do +for banned in design-challenge design-egress-proxy prism-challenge bounty-challenge socket-proxy; do if echo "$services" | grep -qx "$banned"; then fail "prod validator renders $banned (master-only; must not)" fi @@ -122,10 +122,13 @@ for env_file in deploy/compose/env-staging.yml deploy/compose/env-prod.yml; do if echo "$rendered" | grep -qE 'DESIGN_FORCE_SIM:[[:space:]]*["'\'']?(1|true|TRUE|yes)["'\'']?'; then fail "$env_file enables DESIGN_FORCE_SIM (Docker-only on droplets)" fi + if echo "$rendered" | grep -qE 'BOUNTY_FORCE_SIM:[[:space:]]*["'\'']?(1|true|TRUE|yes)["'\'']?'; then + fail "$env_file enables BOUNTY_FORCE_SIM (keyed OpenRouter only on droplets)" + fi done echo "OK: staging/prod do not enable host SimSandbox" -# --- prism-challenge + design-challenge present in default --- +# --- prism-challenge + design-challenge + bounty-challenge present in default --- default_services=$(render \ -f docker-compose.yml \ config --services) @@ -138,7 +141,10 @@ fi if ! echo "$default_services" | grep -qx "design-egress-proxy"; then fail "design-egress-proxy not in default compose" fi -echo "OK: prism-challenge + design-challenge + design-egress-proxy in default compose" +if ! echo "$default_services" | grep -qx "bounty-challenge"; then + fail "bounty-challenge not in default compose" +fi +echo "OK: prism-challenge + design-challenge + design-egress-proxy + bounty-challenge in default compose" # --- no fake chain backend survives anywhere in the matrix --- for env_file in deploy/compose/env-staging.yml deploy/compose/env-prod.yml; do @@ -207,8 +213,12 @@ echo "$local_services" | grep -qx "design-challenge" \ || fail "env-local master stack does not render design-challenge" echo "$local_services" | grep -qx "design-egress-proxy" \ || fail "env-local master stack does not render design-egress-proxy" +echo "$local_services" | grep -qx "bounty-challenge" \ + || fail "env-local master stack does not render bounty-challenge" echo "$local_rendered" | grep -qE 'published: "?28093"?' \ || fail "env-local does not publish design-challenge on 28093" +echo "$local_rendered" | grep -qE 'published: "?28095"?' \ + || fail "env-local does not publish bounty-challenge on 28095" for banned in agent-challenge hypertraining-challenge miner-agent miner-socket-proxy base-agent; do if echo "$local_services" | grep -qx "$banned"; then fail "removed service still rendered: $banned" @@ -220,6 +230,6 @@ for banned in agent-challenge hypertraining-challenge miner-agent miner-socket-p fail "removed service still in default compose: $banned" fi done -echo "OK: env-local preserves testnet/541; design on 28093; removed agent/hypertraining/miner services" +echo "OK: env-local preserves testnet/541; design on 28093; bounty on 28095; removed agent/hypertraining/miner services" echo "assert-compose-matrix: all checks passed" diff --git a/deploy/scripts/local-e2e.sh b/deploy/scripts/local-e2e.sh index 9c0f104fe..ce8f2a7df 100755 --- a/deploy/scripts/local-e2e.sh +++ b/deploy/scripts/local-e2e.sh @@ -40,6 +40,7 @@ GATEWAY_HOST_PORT="${LOCAL_GATEWAY_HOST_PORT:-8080}" VALIDATOR_HOST_PORT="${LOCAL_VALIDATOR_HOST_PORT:-28080}" PRISM_HOST_PORT="${LOCAL_PRISM_HOST_PORT:-28092}" DESIGN_HOST_PORT="${LOCAL_DESIGN_HOST_PORT:-28093}" +BOUNTY_HOST_PORT="${LOCAL_BOUNTY_HOST_PORT:-28095}" BASE_SECRETS_DIR="${BASE_SECRETS_DIR:-${HOME}/.base-secrets}" # Default public-only hotkey for smoke (same placeholder as gateway.env.example usage). @@ -221,7 +222,8 @@ ensure_env_files() { # Challenge env files are required by compose (BASE_DATABASE_URL → Postgres). if [[ ! -f deploy/env/postgres.env || ! -f deploy/env/gateway.env \ || ! -f deploy/env/validator.env || ! -f deploy/env/design-challenge.env \ - || ! -f deploy/env/prism-challenge.env ]]; then + || ! -f deploy/env/prism-challenge.env \ + || ! -f deploy/env/bounty-challenge.env ]]; then log "materializing deploy/env/*.env from examples" ./deploy/scripts/materialize-env.sh fi @@ -230,7 +232,8 @@ ensure_env_files() { local url url="$(database_url_from_postgres_env)" for f in deploy/env/gateway.env deploy/env/validator.env \ - deploy/env/design-challenge.env deploy/env/prism-challenge.env; do + deploy/env/design-challenge.env deploy/env/prism-challenge.env \ + deploy/env/bounty-challenge.env; do if grep -q '^BASE_DATABASE_URL=' "$f" 2>/dev/null; then sed -i "s|^BASE_DATABASE_URL=.*|BASE_DATABASE_URL=${url}|" "$f" else @@ -586,6 +589,8 @@ wait_all_health() { log "warning: prism not healthy (continuing; try BASE_DOCKER_BUILD_FROM=source)" wait_health design-challenge "http://127.0.0.1:${DESIGN_HOST_PORT}/health" || \ log "warning: design not healthy (continuing; try BASE_DOCKER_BUILD_FROM=source)" + wait_health bounty-challenge "http://127.0.0.1:${BOUNTY_HOST_PORT}/health" || \ + log "warning: bounty not healthy (continuing; try BASE_DOCKER_BUILD_FROM=source)" WAIT_SECS="$saved" } diff --git a/deploy/scripts/materialize-env.sh b/deploy/scripts/materialize-env.sh index 9bbd9328e..42c60820c 100755 --- a/deploy/scripts/materialize-env.sh +++ b/deploy/scripts/materialize-env.sh @@ -39,7 +39,7 @@ materialize_one() { # Challenge env files carry BASE_DATABASE_URL (must match postgres.env). # Without them, compose refuses to start design/prism (no silent memory store). -for svc in postgres validator gateway updater design-challenge prism-challenge design-egress-proxy; do +for svc in postgres validator gateway updater design-challenge prism-challenge design-egress-proxy bounty-challenge; do materialize_one "$svc" done diff --git a/deploy/scripts/promote.sh b/deploy/scripts/promote.sh index 82712ca3b..a3f5f6bf1 100755 --- a/deploy/scripts/promote.sh +++ b/deploy/scripts/promote.sh @@ -55,7 +55,7 @@ done [[ -n "$ENV_NAME" ]] || die "--env staging|prod required" case "$ENV_NAME" in staging|prod) ;; *) die "env must be staging|prod" ;; esac -case "$SERVICE" in validator|gateway|updater|prism-challenge|design-challenge) ;; *) die "service must be validator|gateway|updater|prism-challenge|design-challenge" ;; esac +case "$SERVICE" in validator|gateway|updater|prism-challenge|design-challenge|bounty-challenge) ;; *) die "service must be validator|gateway|updater|prism-challenge|design-challenge|bounty-challenge" ;; esac PIN_PATH="$(pin_path_for_env "$ROOT" "$ENV_NAME")" PROD_PATH="$(prod_pin_path "$ROOT")" diff --git a/deploy/scripts/record-image-digests.sh b/deploy/scripts/record-image-digests.sh index 506145493..f109795b2 100755 --- a/deploy/scripts/record-image-digests.sh +++ b/deploy/scripts/record-image-digests.sh @@ -3,7 +3,7 @@ # # record-image-digests.sh [--out deploy/digests/.json] [image:tag ...] # -# Default images: validator:0.1.0 gateway:0.1.0 updater:0.1.0 prism-challenge:0.1.0 design-challenge:0.1.0 +# Default images: validator:0.1.0 gateway:0.1.0 updater:0.1.0 prism-challenge:0.1.0 design-challenge:0.1.0 bounty-challenge:0.1.0 # Writes JSON: # { "commit_sha", "created_at", "images": { "validator": { "id", "digest", "repo_digest", "tag" } } } set -euo pipefail @@ -27,7 +27,7 @@ while [[ $# -gt 0 ]]; do done if [[ ${#IMAGES[@]} -eq 0 ]]; then - IMAGES=(validator:0.1.0 gateway:0.1.0 updater:0.1.0 prism-challenge:0.1.0 design-challenge:0.1.0) + IMAGES=(validator:0.1.0 gateway:0.1.0 updater:0.1.0 prism-challenge:0.1.0 design-challenge:0.1.0 bounty-challenge:0.1.0) fi COMMIT="$(git -C "$ROOT" rev-parse HEAD)" diff --git a/deploy/scripts/register-challenge-backends.sh b/deploy/scripts/register-challenge-backends.sh index 9d05f8d52..c7336df55 100755 --- a/deploy/scripts/register-challenge-backends.sh +++ b/deploy/scripts/register-challenge-backends.sh @@ -16,6 +16,7 @@ cd "$ROOT" GATEWAY_URL="${GATEWAY_URL:-http://127.0.0.1:8080}" PRISM_URL="${PRISM_BACKEND_URL:-http://prism-challenge:8092}" DESIGN_URL="${DESIGN_BACKEND_URL:-http://design-challenge:8093}" +BOUNTY_URL="${BOUNTY_BACKEND_URL:-http://bounty-challenge:8095}" COMPOSE_MODE=0 while [[ $# -gt 0 ]]; do @@ -24,6 +25,7 @@ while [[ $# -gt 0 ]]; do --gateway-url) GATEWAY_URL="$2"; shift 2 ;; --prism-url) PRISM_URL="$2"; shift 2 ;; --design-url) DESIGN_URL="$2"; shift 2 ;; + --bounty-url) BOUNTY_URL="$2"; shift 2 ;; -h|--help) sed -n '2,12p' "$0" exit 0 @@ -62,6 +64,7 @@ register_one() { register_one prism "$PRISM_URL" register_one design "$DESIGN_URL" +register_one bounty "$BOUNTY_URL" # Smoke the proxy path (health, not healthz — challenge services use /health). if [[ "$COMPOSE_MODE" -eq 1 ]]; then @@ -69,8 +72,11 @@ if [[ "$COMPOSE_MODE" -eq 1 ]]; then exec -T gateway curl -fsS -m 5 http://127.0.0.1:8080/challenge/prism/health >/dev/null docker compose -f docker-compose.yml -f deploy/compose/role-master.yml \ exec -T gateway curl -fsS -m 5 http://127.0.0.1:8080/challenge/design/health >/dev/null + docker compose -f docker-compose.yml -f deploy/compose/role-master.yml \ + exec -T gateway curl -fsS -m 5 http://127.0.0.1:8080/challenge/bounty/health >/dev/null else curl -fsS -m 5 "${GATEWAY_URL%/}/challenge/prism/health" >/dev/null curl -fsS -m 5 "${GATEWAY_URL%/}/challenge/design/health" >/dev/null + curl -fsS -m 5 "${GATEWAY_URL%/}/challenge/bounty/health" >/dev/null fi -echo "challenge proxy health: ok (prism + design)" +echo "challenge proxy health: ok (prism + design + bounty)" diff --git a/deploy/scripts/remote-deploy.sh b/deploy/scripts/remote-deploy.sh index 0dbb25aed..f4e82677f 100755 --- a/deploy/scripts/remote-deploy.sh +++ b/deploy/scripts/remote-deploy.sh @@ -31,7 +31,7 @@ REMOTE_DIR="${BASE_REMOTE_DIR:-/opt/base}" # bind source and the container's BASE_VERIFY_WORK_ROOT byte-for-byte. STATE_ROOT="${BASE_STATE_DIR:-/var/lib/base}" GHCR_PREFIX="${BASE_GHCR_PREFIX:-ghcr.io/baseintelligence/base}" -PIN_SERVICES=(validator gateway updater prism-challenge design-challenge) +PIN_SERVICES=(validator gateway updater prism-challenge design-challenge bounty-challenge) SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new) if [[ -n "${BASE_SSH_IDENTITY:-}" ]]; then SSH_OPTS+=(-i "$BASE_SSH_IDENTITY") @@ -160,8 +160,8 @@ fi echo "remote-deploy: rsync tree" if [[ "$BUILD_FROM" == "prebuilt" ]]; then - for b in validator gateway updater prism-challenge design-challenge design-egress-proxy challenge-review; do - [[ -x "$ROOT/target/release/$b" ]] || die "missing prebuilt binary target/release/$b — run: cargo build --release --features validator-bin/dcap -p validator-bin -p gateway-bin -p updater-bin -p prism-challenge-bin -p design-challenge-bin -p design-egress-proxy-bin && cargo build --release -p challenge-review-bin" + for b in validator gateway updater prism-challenge design-challenge design-egress-proxy bounty-challenge challenge-review; do + [[ -x "$ROOT/target/release/$b" ]] || die "missing prebuilt binary target/release/$b — run: cargo build --release --features validator-bin/dcap -p validator-bin -p gateway-bin -p updater-bin -p prism-challenge-bin -p design-challenge-bin -p design-egress-proxy-bin -p bounty-challenge-bin && cargo build --release -p challenge-review-bin" done fi @@ -185,12 +185,13 @@ rsync -az --delete \ # deploy/secrets/lium is bind-mounted by prism-challenge, so it must be a real # directory with real files: compose would otherwise create directories where # the container expects files. -# Same footgun for file mounts: if prism_sk / design_sk are missing, Docker -# creates *directories* at those paths and the challenge bins fail with +# Same footgun for file mounts: if prism_sk / design_sk / bounty_sk are missing, +# Docker creates *directories* at those paths and the challenge bins fail with # "Is a directory" / "secret file missing". Materialize empty files when # absent; if a directory already poisoned the path, replace it with a file. ssh_h "mkdir -p '$REMOTE_DIR/deploy/env' '$REMOTE_DIR/deploy/secrets/lium' \ '$REMOTE_DIR/deploy/secrets/openrouter' '$REMOTE_DIR/deploy/secrets/design' \ + '$REMOTE_DIR/deploy/secrets/bounty' \ '$REMOTE_DIR/deploy/secrets/wallets' \ && chmod 700 '$REMOTE_DIR/deploy/secrets' '$REMOTE_DIR/deploy/secrets/lium' \ && for f in api_key ssh_ed25519 ssh_ed25519.pub; do \ @@ -198,7 +199,8 @@ ssh_h "mkdir -p '$REMOTE_DIR/deploy/env' '$REMOTE_DIR/deploy/secrets/lium' \ done \ && [ -e '$REMOTE_DIR/deploy/secrets/openrouter/api_key' ] || : > '$REMOTE_DIR/deploy/secrets/openrouter/api_key' \ && [ -e '$REMOTE_DIR/deploy/secrets/design/annotator_tokens' ] || : > '$REMOTE_DIR/deploy/secrets/design/annotator_tokens' \ - && for sk in prism_sk design_sk; do \ + && [ -e '$REMOTE_DIR/deploy/secrets/bounty/admin_tokens' ] || : > '$REMOTE_DIR/deploy/secrets/bounty/admin_tokens' \ + && for sk in prism_sk design_sk bounty_sk; do \ p='$REMOTE_DIR/deploy/secrets/'\$sk; \ if [ -d \"\$p\" ]; then rm -rf \"\$p\"; fi; \ [ -e \"\$p\" ] || : > \"\$p\"; \ @@ -207,9 +209,11 @@ ssh_h "mkdir -p '$REMOTE_DIR/deploy/env' '$REMOTE_DIR/deploy/secrets/lium' \ && chmod 400 '$REMOTE_DIR/deploy/secrets/lium/'* \ '$REMOTE_DIR/deploy/secrets/openrouter/api_key' \ '$REMOTE_DIR/deploy/secrets/design/annotator_tokens' \ + '$REMOTE_DIR/deploy/secrets/bounty/admin_tokens' \ && chown -R 65532:65532 '$REMOTE_DIR/deploy/secrets/lium' \ '$REMOTE_DIR/deploy/secrets/openrouter' \ '$REMOTE_DIR/deploy/secrets/design' \ + '$REMOTE_DIR/deploy/secrets/bounty' \ && chmod -R a-w '$REMOTE_DIR/deploy/secrets/wallets' 2>/dev/null; \ chown -R 65532:65532 '$REMOTE_DIR/deploy/secrets/wallets' 2>/dev/null; true" @@ -270,6 +274,7 @@ if [[ "$BUILD_FROM" == "prebuilt" ]]; then "$ROOT/target/release/prism-challenge" \ "$ROOT/target/release/design-challenge" \ "$ROOT/target/release/design-egress-proxy" \ + "$ROOT/target/release/bounty-challenge" \ "$HOST:$REMOTE_DIR/target/release/" fi @@ -332,6 +337,7 @@ optional = { "prism-challenge", "design-challenge", "design-egress-proxy", + "bounty-challenge", "design-runtime", "design-review", "base-attest-helper", @@ -419,7 +425,7 @@ docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} \$UP_PROFILE "\${UP_ARGS[@ # surfaces so smoke health does not see stale unhealthy containers. if [[ '$ROLE' == 'validator' ]]; then docker compose ${COMPOSE_FILES[*]} rm -sf \ - prism-challenge design-challenge design-egress-proxy socket-proxy \ + prism-challenge design-challenge design-egress-proxy bounty-challenge socket-proxy \ >/dev/null 2>&1 || true fi docker compose ${COMPOSE_FILES[*]} ${PROFILE_ARGS[*]} \$UP_PROFILE ps @@ -450,6 +456,7 @@ import json, sys, urllib.request, urllib.error backends = [ ("prism", "http://prism-challenge:8092"), ("design", "http://design-challenge:8093"), + ("bounty", "http://bounty-challenge:8095"), ] failed = False for cid, url in backends: @@ -491,15 +498,16 @@ PY for attempt in \$(seq 1 15); do prism_code=\$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/challenge/prism/health 2>/dev/null || echo 000) design_code=\$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/challenge/design/health 2>/dev/null || echo 000) - echo "remote-deploy: challenge routing probe prism=\$prism_code design=\$design_code (attempt \$attempt)" - if [[ "\$prism_code" == "200" && "\$design_code" == "200" ]]; then + bounty_code=\$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/challenge/bounty/health 2>/dev/null || echo 000) + echo "remote-deploy: challenge routing probe prism=\$prism_code design=\$design_code bounty=\$bounty_code (attempt \$attempt)" + if [[ "\$prism_code" == "200" && "\$design_code" == "200" && "\$bounty_code" == "200" ]]; then route_ok=1 break fi sleep 5 done if [[ "\$route_ok" != 1 ]]; then - echo "remote-deploy: ERROR: challenge routing smoke failed (want 200/200; 503 = backends not registered)" >&2 + echo "remote-deploy: ERROR: challenge routing smoke failed (want 200/200/200; 503 = backends not registered)" >&2 exit 1 fi echo "challenge proxy health: ok" diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index cceeeeed8..47c619737 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -3,8 +3,10 @@ Containers run as `base` (uid **65532**). Host secret files MUST be: ```bash -chown 65532:65532 deploy/secrets/gateway_sk deploy/secrets/prism_sk deploy/secrets/design_sk -chmod 0400 deploy/secrets/gateway_sk deploy/secrets/prism_sk deploy/secrets/design_sk +chown 65532:65532 deploy/secrets/gateway_sk deploy/secrets/prism_sk \ + deploy/secrets/design_sk deploy/secrets/bounty_sk +chmod 0400 deploy/secrets/gateway_sk deploy/secrets/prism_sk \ + deploy/secrets/design_sk deploy/secrets/bounty_sk ``` Bind-mounts use the file inode; directory mode 0700 is OK. @@ -16,17 +18,21 @@ Bind-mounts use the file inode; directory mode 0700 is OK. | `gateway_sk` | gateway | Bundle seal mini-secret (`BASE_GATEWAY_SK_FILE`) | | `prism_sk` | prism-challenge | PRISM challenge mini-secret | | `design_sk` | design-challenge **only** | Design challenge mini-secret; never mount on egress proxy | -| `challenge_sk` | legacy placeholder | Prefer `prism_sk` / `design_sk`; do not reuse across challenges | +| `bounty_sk` | bounty-challenge **only** | Bounty challenge mini-secret; never mount on other services | +| `challenge_sk` | legacy placeholder | Prefer `prism_sk` / `design_sk` / `bounty_sk`; do not reuse across challenges | Local dummy for development: decrypt with age: -`age -d -i ~/.base-secrets/age-identity.txt -o deploy/secrets/design_sk ~/.base-secrets/design-dummy.age` +`age -d -i ~/.base-secrets/age-identity.txt -o deploy/secrets/design_sk ~/.base-secrets/challenge-design.age` + +Bounty (throwaway ceremony key under `~/.base-secrets/`): +`age -d -i ~/.base-secrets/age-identity.txt -o deploy/secrets/bounty_sk ~/.base-secrets/challenge-bounty.age` ## Design challenge | Path | Used by | Notes | |------|---------|-------| | `design/annotator_tokens` | design-challenge | One bearer token per line; hashed (SHA-256) at boot. Mode **0400**, uid **65532** | -| `openrouter/api_key` | design-egress-proxy (**proxy only**); also prism reviewer | Never mount OpenRouter key into design-challenge or sandbox containers | +| `openrouter/api_key` | design-egress-proxy (**proxy only**); also prism reviewer; bounty similarity | Never mount OpenRouter key into design sandboxes | ```bash mkdir -p deploy/secrets/design deploy/secrets/openrouter @@ -35,6 +41,21 @@ chown -R 65532:65532 deploy/secrets/design deploy/secrets/openrouter chmod 0400 deploy/secrets/design/annotator_tokens deploy/secrets/openrouter/api_key ``` +## Bounty challenge + +| Path | Used by | Notes | +|------|---------|-------| +| `bounty/admin_tokens` | bounty-challenge | One bearer token per line; hashed at boot. Mode **0400**, uid **65532** | +| `openrouter/api_key` | bounty-challenge (agentic similar-24h) | Same file mount as other challenges; missing key → SimAgent in CI/local only | + +```bash +mkdir -p deploy/secrets/bounty deploy/secrets/openrouter +touch deploy/secrets/bounty/admin_tokens +# ensure openrouter/api_key exists (see Design section) +chown -R 65532:65532 deploy/secrets/bounty +chmod 0400 deploy/secrets/bounty/admin_tokens +``` + ## Other - `lium/` — prism Lium API + SSH keys (see prism runbook) diff --git a/docker-compose.yml b/docker-compose.yml index e88037693..48c291fbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ # base control-plane stack # # Default: postgres + validator + updater + socket-proxy + prism-challenge -# + design-challenge + design-egress-proxy. +# + design-challenge + design-egress-proxy + bounty-challenge. # Master/owner host only: # docker compose --profile master up -d # brings gateway as an additional service (D3). @@ -345,6 +345,56 @@ services: networks: - base + # --------------------------------------------------------------------------- + # bounty-challenge — miner video bug reports + agentic similar-24h + admin + # approve (:8095). Master-only; OpenRouter key for DeepSeek V4 Flash similarity. + # Admin bearers are master-local only (gateway blocks /v1/admin/*). + # --------------------------------------------------------------------------- + bounty-challenge: + image: bounty-challenge:0.1.0 + build: + context: . + dockerfile: deploy/Dockerfile + target: bounty-challenge + args: + BUILD_FROM: ${BASE_DOCKER_BUILD_FROM:-prebuilt} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + BASE_CHALLENGE_BIND: 0.0.0.0:8095 + BASE_CHALLENGE_SK_FILE: /run/base/challenge_sk + BOUNTY_FORCE_SIM: "${BOUNTY_FORCE_SIM:-false}" + BOUNTY_ADMIN_TOKENS_FILE: /run/base/bounty/admin_tokens + OPENROUTER_API_KEY_FILE: /run/base/openrouter/api_key + BOUNTY_ARTIFACTS_ROOT: /var/lib/bounty + BASE_CHALLENGE_GATEWAY_ENDPOINT: ${BASE_CHALLENGE_GATEWAY_ENDPOINT:-http://gateway:8080} + env_file: + # Required: BASE_DATABASE_URL (+ BASE_NETUID). Missing file → compose + # fails closed (binaries would otherwise fall back to in-memory store). + - path: ./deploy/env/bounty-challenge.env + required: true + volumes: + - ./deploy/secrets/bounty_sk:/run/base/challenge_sk:ro + - ./deploy/secrets/bounty:/run/base/bounty:ro + - ./deploy/secrets/openrouter:/run/base/openrouter:ro + - bounty-artifacts:/var/lib/bounty + expose: + - "8095" + healthcheck: + test: + [ + "CMD-SHELL", + "curl -fsS -m 5 http://127.0.0.1:8095/health || exit 1", + ] + interval: 10s + timeout: 3s + retries: 6 + start_period: 15s + networks: + - base + socket-proxy: image: tecnativa/docker-socket-proxy@sha256:9e4b9e7517a6b660f2cc903a19b257b1852d5b3344794e3ea334ff00ae677ac2 restart: unless-stopped @@ -389,6 +439,7 @@ volumes: base-pgdata: base-updater-state: design-artifacts: + bounty-artifacts: networks: base: diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 1d3aa8afe..ccfb95b55 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -23,6 +23,7 @@ When a spike or evidence report conflicts with a frozen spec or runbook, the nor | [`runbooks/measurement-repin-socket-proxy.md`](runbooks/measurement-repin-socket-proxy.md) | Socket-proxy measurement re-pin | | [`runbooks/design-enable-and-emission.md`](runbooks/design-enable-and-emission.md) | Design keygen + emission unlock | | [`runbooks/prism-enable-lium-and-emission.md`](runbooks/prism-enable-lium-and-emission.md) | Prism Lium + emission | +| [`runbooks/bounty-enable-and-emission.md`](runbooks/bounty-enable-and-emission.md) | Bounty keygen + emission unlock | Deploy topology and CI lanes: [`../deploy/README.md`](../deploy/README.md) and [`../deploy/AGENTS.md`](../deploy/AGENTS.md). Repo-wide agent contract: [`../AGENTS.md`](../AGENTS.md). @@ -35,6 +36,7 @@ Public miner docs live **outside** this monorepo (examples + human guides only |-----------|------| | Design | [`BaseIntelligence/design-challenge`](https://github.com/BaseIntelligence/design-challenge) | | Prism | [`BaseIntelligence/prism`](https://github.com/BaseIntelligence/prism) | +| Bounty | *(public repo follow-up; use [`external-miner/bounty.md`](external-miner/bounty.md))* | `docs/external-miner/` remains the in-repo mirror for CI (`external-docs-check`) and operators. When challenge APIs or rules change, update **both** the public repo and `external-miner/` (see root [`../AGENTS.md`](../AGENTS.md) § Challenge public docs). @@ -42,7 +44,7 @@ Public miner docs live **outside** this monorepo (examples + human guides only When updating challenge or local-subnet docs/runbooks, keep these invariants: -- **Master-only eval** — design/prism challenge services run on master; validator has **no challenge exec** (fetch sealed weights only). +- **Master-only eval** — design/prism/bounty challenge services run on master; validator has **no challenge exec** (fetch sealed weights only). - **Simulate submissions** — submit baseline **and** a cheat fixture through the challenge service; poll `/events` + `/logs`; do not treat `/health` alone as proof. - **Design admin winners** — after clean `awaiting_admin`, operator bearer awards 1|2 winners; then leaf → seal path. - **No host Sim in staging/prod** — Docker only; `SimSandbox` / `BASE_ALLOW_HOST_SIM` are CI/local opt-in. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03ed30b20..bbe03f787 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,6 +7,7 @@ Operator-facing map of the control plane. Normative byte contracts live in the f | [`BUNDLE_SPEC.md`](./BUNDLE_SPEC.md) | **FROZEN** | Epoch bundle SCALE layout, merkle, aggregation, on-chain payload bounds | | [`DESIGN_CHALLENGE.md`](./DESIGN_CHALLENGE.md) | **FROZEN** | `design` challenge: harness sandbox, agentic review, admin winners, D24 leaves | | [`PRISM.md`](./PRISM.md) | live | `prism` Lium GPU recipe challenge (HTTP submit) | +| [`BOUNTY_CHALLENGE.md`](./BOUNTY_CHALLENGE.md) | live | `bounty` video bug-report challenge (HTTP multipart + admin approve) | Do not restate those contracts here. Link them. @@ -23,7 +24,7 @@ Miner-facing docs (version-pinned): [`external-miner/`](./external-miner/). - Gateway runs **only** as subnet owner (master). Startup asserts hotkey == on-chain `SubnetOwnerHotkey` or exits `2` before bind. - Validators **recompute** the weight vector from a signed, merkle-rooted epoch bundle. Challenge keys and measurements come from **owner-signed local files**, never from gateway HTTP. - CRV4 timelock commit-reveal on Bittensor testnet/mainnet as configured. Reveal is automatic on-chain. -- Challenges accept miner work over **HTTP** (design Python harness sandbox; prism Lium GPU eval). No miner Phala/CVM path. +- Challenges accept miner work over **HTTP** (design Python harness sandbox; prism Lium GPU eval; bounty video bug reports). No miner Phala/CVM path. --- @@ -35,7 +36,7 @@ Miner-facing docs (version-pinned): [`external-miner/`](./external-miner/). │ postgres · gateway · validator · │ │ updater · socket-proxy · │ │ prism-challenge · design-challenge · │ - │ design-egress-proxy │ + │ design-egress-proxy · bounty-challenge│ └───────────────┬─────────────────────┘ │ TLS terminates in gateway (D20) │ /challenge/{id}/* /v1/bundle/* @@ -48,7 +49,7 @@ Miner-facing docs (version-pinned): [`external-miner/`](./external-miner/). │ HTTP submit ┌───────────────▼─────────────────────┐ │ Miner clients (no TEE required) │ - │ design harness / prism scripts │ + │ design / prism / bounty │ └─────────────────────────────────────┘ ``` @@ -59,6 +60,7 @@ Miner-facing docs (version-pinned): [`external-miner/`](./external-miner/). | `design-challenge` | **Master-only:** sandbox harness runs, sanitize/viewer, scoring, sign leaves | | `design-egress-proxy` | **Master-only:** open sandbox egress (internal-target blocklist) + budgeted LLM path | | `prism-challenge` | **Master-only:** Lium (or sim) recipe eval, review gate, sign leaves | +| `bounty-challenge` | **Master-only:** video bug intake, ffmpeg compress, similar-24h, admin approve, sign leaves | | `updater` | Digest-pinned rollouts via `docker-socket-proxy` (master) | | `trustroot` | Offline keygen / sign / verify for owner-signed TOML | | `bundle` | SCALE types, seal, verify (`PROTOCOL_VERSION`) | @@ -95,13 +97,15 @@ Miner-facing docs (version-pinned): [`external-miner/`](./external-miner/). | `config/measurements.toml` + `.sig` | yes | every validator from **disk** | | Challenge / owner mini-secrets | **never** | challenge service / offline ceremony only | -Current emission posture: `design = 5000` bps, `prism = 5000` bps (50/50; sum = 10000). +Current emission posture: `design = 3000` bps, `prism = 4500` bps, +`bounty = 2500` bps (sum = 10000). Gateway DB is **routing only**. It is never a source of challenge keys, emission shares, or measurements (D18, D23). Ceremony: [`config/CEREMONY.md`](../config/CEREMONY.md). Rotation: [`runbooks/trust-root-rotation.md`](./runbooks/trust-root-rotation.md) (D21). -Design emission unlock: [`runbooks/design-enable-and-emission.md`](./runbooks/design-enable-and-emission.md). +Design emission unlock: [`runbooks/design-enable-and-emission.md`](./runbooks/design-enable-and-emission.md). +Bounty emission unlock: [`runbooks/bounty-enable-and-emission.md`](./runbooks/bounty-enable-and-emission.md). --- @@ -139,5 +143,6 @@ See D19 in [`THREAT_MODEL.md`](./THREAT_MODEL.md). Short form: | [`runbooks/promote-rollback-restore.md`](./runbooks/promote-rollback-restore.md) | Digest promote, rollback, `pg_dump` | | [`runbooks/gateway-failover.md`](./runbooks/gateway-failover.md) | Manual failover (R9) | | [`runbooks/design-enable-and-emission.md`](./runbooks/design-enable-and-emission.md) | Design keygen + emission | +| [`runbooks/bounty-enable-and-emission.md`](./runbooks/bounty-enable-and-emission.md) | Bounty keygen + emission | | [`external-miner/README.md`](./external-miner/README.md) | Miner HTTP path + `protocol_version` badge | | [`../README.md`](../README.md) | Repo bootstrap | diff --git a/docs/BOUNTY_CHALLENGE.md b/docs/BOUNTY_CHALLENGE.md new file mode 100644 index 000000000..8415ce804 --- /dev/null +++ b/docs/BOUNTY_CHALLENGE.md @@ -0,0 +1,213 @@ +# Bounty Challenge (Base) + +**Status: live contract** for `challenge_id = "bounty"`, `challenge_scoring_version` **u16 = 1**. + +Normative contract for the bounty (video bug-report) challenge on Base. +Byte-level epoch bundle rules live in [`BUNDLE_SPEC.md`](./BUNDLE_SPEC.md) +(`protocol_version = 1`). Enablement / emission ceremony: +[`runbooks/bounty-enable-and-emission.md`](./runbooks/bounty-enable-and-emission.md). +Miner-facing mirror: [`external-miner/bounty.md`](./external-miner/bounty.md). + +Miners upload a **video + structured bug report**. The master pipeline compresses +the video, runs an agentic **similar-24h** check (DeepSeek V4 Flash via +OpenRouter), and queues novel reports for **admin approve / reject**. Scoring +targets **50 approved bugs per epoch** for the full bounty emission share; +shortfall burns via a UID-0 leaf (see §6). + +--- + +## 1. What runs where (topology) + +```text +Miner --POST /v1/bugs (multipart)--> bounty-challenge (:8095) + | + store raw video + pending row + | + worker: ffmpeg compress + fingerprint + | + OpenRouter DeepSeek V4 Flash (similar-24h) + | | + duplicate → rejected novel → pending_admin + | + admin approve / reject + | + epoch emitter → D24 leaves → gateway +``` + +| Process | Host | Holds `bounty_sk`? | Holds OpenRouter key? | +|---------|------|--------------------|------------------------| +| `bounty-challenge` | **master only** | **yes** (file mount) | **yes** (similar-24h; optional Sim) | +| gateway | master | no | no | +| validator | validators | no | no — **no challenge exec**; fetch sealed weights only | + +Evaluation (compress, agentic similarity, admin approve, leaf emit) is +**master-only**. Validators never run `bounty-challenge`. + +Admin routes are **not exposed via gateway** (`/challenge/bounty/v1/admin/*` +→ `403`; gateway `is_admin_path` blocks all `v1/admin/*`). Use the master-local +challenge port (compose expose `8095`, local overlay `28095`). + +--- + +## 2. Identifiers and versions + +| Field | Value | +|-------|-------| +| `challenge_id` | `bounty` | +| `challenge_scoring_version` | **u16 = 1** | +| `SCORE_MAX` | `1_000_000` | +| `TARGET_BUGS` | `50` (approved bugs = full bounty share this epoch) | +| Listen port | `8095` (local overlay `28095`) | +| Gateway proxy prefix | `/challenge/bounty/*` | +| Bundle `protocol_version` | `1` ([`BUNDLE_SPEC.md`](./BUNDLE_SPEC.md)) | +| `emission_share_bps` | **2500** (with design `3000` / prism `4500`; sum `10000`) | +| Policy | `all_metagraph_hotkeys` | +| Domain bug id | `base-bounty-bug-id-v1` | +| Domain submission | `base-bounty-submission-v1` | +| Raw-weight domain | `base-rawweight-v1` (via bundle) | +| Default OpenRouter model | `deepseek/deepseek-v4-flash` (`BOUNTY_OPENROUTER_MODEL`) | + +Emission posture: design **3000** / prism **4500** / bounty **2500** bps. +Rebalance via the owner ceremony in +[`runbooks/bounty-enable-and-emission.md`](./runbooks/bounty-enable-and-emission.md) +and [`config/challenges.toml`](../config/challenges.toml). + +--- + +## 3. Miner submit contract + +### `POST /v1/bugs` + +`multipart/form-data` fields: + +| Field | Required | Notes | +|-------|----------|-------| +| `video` | yes | `mp4` / `webm` / `mov`; raw size cap ~100 MiB | +| `title` | yes | Short human title | +| `description` | yes | Bug narrative | +| `app_id` | yes | Target app slug | +| `steps` | no | Repro steps text | + +Headers: `X-Miner-Hotkey` (64 lowercase hex). Shared metagraph / 1-max gating +via `submission-gating` (same family as design/prism). + +Gateway path: `POST /challenge/bounty/v1/bugs`. + +### Read APIs + +| Route | Notes | +|-------|-------| +| `GET /v1/bugs/{id}` | Status, similarity verdict, metadata; compressed video URL | +| `GET /v1/bugs/{id}/video` | Stream compressed `video.mp4` | +| `GET /v1/bugs?status=&mine=1` | List (filter by status / own hotkey) | +| `GET /health` | Liveness | +| `GET /v1/status` | Epoch, approved/pending counts, burn preview | + +--- + +## 4. Processing pipeline + +1. **Intake** — validate MIME/size; write staging artifact; row `uploaded`. +2. **Compress** — `ffmpeg` in the bounty container (`libx264`/`libx265`, max + 720p, CRF ~28, aac) → `video.mp4` on volume `bounty-artifacts`; delete raw; + store sha256 + bytes. +3. **Corpus 24h** — bugs in `approved|pending_admin|rejected` with + `created_at >= now-24h` (exclude same hotkey/coldkey). +4. **Agentic similar-24h** — `OpenRouterAgent` with + `BOUNTY_OPENROUTER_MODEL` (default DeepSeek V4 Flash). Structured verdict + `novel | duplicate` + `nearest_id` + `similarity_bps` + rationale. + Fail-closed on infra errors (retry / `NoScore` path — never invent `novel`). +5. **Duplicate** → terminal `rejected` (0 points). **Novel** → `pending_admin`. +6. **Admin approve** → +1 epoch point for the miner hotkey. **Reject** → + terminal with reason. + +CI / local: `BOUNTY_FORCE_SIM=1` → deterministic `SimAgent` (no OpenRouter / +host ffmpeg required when fixture is pre-compressed). **Never** enable Sim on +staging/prod droplets. + +--- + +## 5. Admin API (master-local) + +Bearer hashes from `deploy/secrets/bounty/admin_tokens` (one token per line; +hashed at boot). Empty file → all admin routes reject. + +| Route | Body / notes | +|-------|----------------| +| `GET /v1/admin/bugs?status=pending_admin` | Queue | +| `GET /v1/admin/bugs/{id}` | Detail + video + nearest_duplicate | +| `POST /v1/admin/bugs/{id}/approve` | Award +1 point | +| `POST /v1/admin/bugs/{id}/reject` | `{ "reason": "…" }` | + +--- + +## 6. Epoch scoring + burn sink + +Aggregator renormalizes positive scores inside a challenge — a lone +`Score(n)` does **not** burn the remainder. Bounty uses an explicit UID-0 +burn leaf (compatible with [`BUNDLE_SPEC`](./BUNDLE_SPEC.md) §6.5): + +```text +TARGET = 50 +approved_points[m] = # bugs approved for miner m this epoch +total = sum(approved_points) +capped = min(total, TARGET) +miner_pool = SCORE_MAX * capped / TARGET # integer +burn_units = SCORE_MAX - miner_pool # 0 when total >= TARGET + +if total > 0: + each miner: Score(floor(miner_pool * points_m / total)) +else: + participants Score(0) / NoScore(NotAttempted) + +if burn_units > 0: + Score(burn_units) on metagraph hotkey with uid == 0 + # dropped at aggregate → burns into bounty frac +``` + +- **Under 50** approved: missing mass → burn UID 0. +- **At/above 50**: `burn_units = 0`; proportional share among miners (dilution). + +Leaves are D24-complete for the exact epoch (`exact-E`) and posted to +`POST /v1/weights/raw` with `bounty_sk` matching the trust-root pubkey. + +--- + +## 7. Persistence + +Migration `0017_bounty_challenge.sql`: + +- `bounty_bug` — identity, miner keys, app/title/description/steps, status, + agentic verdict, nearest_id, video metadata/path, epoch, timestamps +- `bounty_stage_event` — append-only journal +- `bounty_epoch_score` — points / final score per `(epoch, hotkey)` + +Video bytes live on volume `bounty-artifacts` (not base64 in Postgres). + +--- + +## 8. Deploy knobs + +| Env / mount | Role | +|-------------|------| +| `BASE_CHALLENGE_BIND` | default `0.0.0.0:8095` | +| `BASE_CHALLENGE_SK_FILE` | `/run/base/challenge_sk` ← `deploy/secrets/bounty_sk` | +| `BOUNTY_ADMIN_TOKENS_FILE` | `/run/base/bounty/admin_tokens` | +| `OPENROUTER_API_KEY_FILE` | `/run/base/openrouter/api_key` | +| `BOUNTY_ARTIFACTS_ROOT` | `/var/lib/bounty` | +| `BOUNTY_FORCE_SIM` | CI/local only; staging/prod `false` | +| `BOUNTY_OPENROUTER_MODEL` | default `deepseek/deepseek-v4-flash` | +| `BASE_DATABASE_URL` | required in compose (`deploy/env/bounty-challenge.env`) | + +Compose service: `bounty-challenge`. Dockerfile target: `bounty-challenge` +(ffmpeg in runtime). Backend registration: +`deploy/scripts/register-challenge-backends.sh`. + +--- + +## 9. Out of scope + +- Public miner GitHub repo (follow-up ops; monorepo mirror is + [`external-miner/bounty.md`](./external-miner/bounty.md)). +- Product bug **fixes** — the challenge scores **approved reports**, not remediations. +- Aggregation algorithm changes / `algorithm_version` bumps. diff --git a/docs/COMPLETENESS.md b/docs/COMPLETENESS.md index 1bcc37196..3e1020b38 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -58,7 +58,7 @@ Removed (replaced by design + prism HTTP paths; no Phala/CVM miner). | Binary (`bins/design-egress-proxy`) | done | Open egress proxy (internal blocklist) + budgeted LLM path. | | Spec + checklist | done | [`DESIGN_CHALLENGE.md`](DESIGN_CHALLENGE.md) + checklist; `xtask design-check`. | | Compose / images | in progress | deploy-wiring todo (port `28093` local). | -| Emission | **5000 bps** | Equal split with prism (50/50; sum `10000`). | +| Emission | **3000 bps** | With prism `4500` / bounty `2500` (sum `10000`). | ## prism-challenge @@ -69,7 +69,18 @@ Removed (replaced by design + prism HTTP paths; no Phala/CVM miner). | Compose service | done | Added to `docker-compose.yml` on `:8092`. | | Dockerfile target | done | `deploy/Dockerfile` target `prism-challenge`. | | GHCR image | done | Added to `images.yml` matrix and `ghcr-public.yml`. | -| Emission | **5000 bps** | Equal split with design (50/50; sum `10000`). | +| Emission | **4500 bps** | With design `3000` / bounty `2500` (sum `10000`). | + +## bounty-challenge + +| Component | Status | Notes | +|-----------|--------|-------| +| Crates (`crates/bounty-*`) | done | task, store (+pg), http, challenge orchestrator + scoring. | +| Binary (`bins/bounty-challenge`) | done | HTTP on `:8095`; compress → similar-24h → admin → D24 emit. | +| Spec | done | [`BOUNTY_CHALLENGE.md`](BOUNTY_CHALLENGE.md) + [`external-miner/bounty.md`](external-miner/bounty.md). | +| Compose / Dockerfile | done | Service `:8095`, volume `bounty-artifacts`, target `bounty-challenge` (+ ffmpeg). | +| Emission | **2500 bps** | Trust root re-signed (design/prism/bounty 3000/4500/2500). | +| GHCR pin ladder | wired | `images.yml` / `PIN_SERVICES` / `promote.sh` include `bounty-challenge`; first digest lands on next `main` image build. | ## Infrastructure @@ -117,7 +128,8 @@ Agent/operator contracts: root [`AGENTS.md`](../AGENTS.md), [`deploy/AGENTS.md`] | DCAP verify holds the attest mutex | A cold Intel PCS fetch (up to 20 s) serialises attestation submissions. | | DCAP error classification | Matches on `anyhow` message text; re-run `cargo test -p attest-policy --features dcap` after any `dcap-qvl` bump. | | Design compose/images | deploy-wiring in progress; local port `28093` documented. | -| Design emission ceremony | Emission live at 5000/5000 with design; optional prod `design_sk` / owner key rotation still pending. | +| Design / prism / bounty emission ceremony | Dev throwaway trust root at 3000/4500/2500 with bounty pubkey; optional prod challenge/owner key rotation still pending. | +| Bounty GHCR digest | Pin ladder wired; `deploy/pins/staging.json` lacks `bounty-challenge` until first post-merge `images.yml` run — registry deploy fails closed until then. | | Mainnet (netuid 100) | Owner wallet not yet on this machine, so prod runs with `BASE_GATEWAY_REQUIRE_OWNER=0`. | | Prod pin placeholders | `deploy/pins/prod.json` still ships zero-digests until the first successful promote; registry mode rejects placeholders. | | Spaces backup secrets | First prod promote is fail-closed without `BASE_BACKUP_ENDPOINT` + `SPACES_ACCESS_KEY_ID` / `SPACES_SECRET_ACCESS_KEY` (or AWS_* fallbacks) in GitHub. | diff --git a/docs/DESIGN_CHALLENGE.md b/docs/DESIGN_CHALLENGE.md index 87540305a..acd142f01 100644 --- a/docs/DESIGN_CHALLENGE.md +++ b/docs/DESIGN_CHALLENGE.md @@ -69,7 +69,7 @@ Sandbox containers attach only to the internal Docker network | Listen port | `8093` (local overlay `28093`) | | Gateway proxy prefix | `/challenge/design/*` | | Bundle `protocol_version` | `1` ([`BUNDLE_SPEC.md`](./BUNDLE_SPEC.md)) | -| `emission_share_bps` | **5000** (equal split with prism; sum `10000`) | +| `emission_share_bps` | **3000** (with prism `4500` / bounty `2500`; sum `10000`) | | Policy | `all_metagraph_hotkeys` | | Round length | `ROUND_SECS = 8_640` (10 rounds / UTC day) | | Agent run timeout | `AGENT_RUN_TIMEOUT_SECS = 1_800` (30 min; distinct from round length) | @@ -86,8 +86,8 @@ Staging knobs (defaults above are the prod pins and never change implicitly): override round length / run timeout / prompts per round (staging uses ~15-minute rounds; `env-staging.yml`). -Emission posture: `emission_share_bps = 5000` for design and `5000` for prism -(50/50; sum `10000`). Rebalance via the owner ceremony in +Emission posture: design `3000` / prism `4500` / bounty `2500` (sum `10000`). +Rebalance via the owner ceremony in [`runbooks/design-enable-and-emission.md`](./runbooks/design-enable-and-emission.md) and [`config/challenges.toml`](../config/challenges.toml). diff --git a/docs/PRISM.md b/docs/PRISM.md index eb3b8e7d3..f1cfe7c98 100644 --- a/docs/PRISM.md +++ b/docs/PRISM.md @@ -4,7 +4,7 @@ **scoring_version:** `2` (bpb-only; v1 blended a 0.3 LLM quality vote; the architecture competition below reallocates credits *inside* this same lattice — no chain-facing version change) **recipe_version:** `1.2.0` (telemetry hooks 1.1.0 + architecture registry / training-only submissions 1.2.0) **port:** `8092` -**emission_share_bps:** `5000` (equal split with `design`; sum `10000`) +**emission_share_bps:** `4500` (with design `3000` / bounty `2500`; sum `10000`) **GPU path:** master-centralized **Lium** (no Phala CVM) ## What it is diff --git a/docs/external-miner/README.md b/docs/external-miner/README.md index 2e73508c5..b1aacfecd 100644 --- a/docs/external-miner/README.md +++ b/docs/external-miner/README.md @@ -15,6 +15,7 @@ over HTTP to the live challenges: |-----------|----------------|---------|-------|-------------------| | Design | `design` | `challenge_scoring_version` **2** (daily share ≥2 wins + agentic) | [design.md](./design.md) | [BaseIntelligence/design-challenge](https://github.com/BaseIntelligence/design-challenge) | | Prism | `prism` | `challenge_scoring_version` **2** (bpb-only) | [prism.md](./prism.md) | [BaseIntelligence/prism](https://github.com/BaseIntelligence/prism) (miner docs forthcoming) | +| Bounty | `bounty` | `challenge_scoring_version` **1** (TARGET_BUGS=50 + UID-0 burn) | [bounty.md](./bounty.md) | *(public repo follow-up; monorepo mirror only for now)* | Do **not** conflate version axes: @@ -23,18 +24,21 @@ Do **not** conflate version axes: | Bundle `protocol_version` | **1** | Leaf / merkle / weight bytes ([`BUNDLE_SPEC.md`](../BUNDLE_SPEC.md)) | | Design scoring | **1** | Agentic anti-cheat + admin winners 1\|2 ([`DESIGN_CHALLENGE.md`](../DESIGN_CHALLENGE.md)) | | Prism scoring | **2** | Pure bpb + agentic/AST/metrics anti-cheat ([`PRISM.md`](../PRISM.md)) | +| Bounty scoring | **1** | Approved bug points + TARGET_BUGS burn sink ([`BOUNTY_CHALLENGE.md`](../BOUNTY_CHALLENGE.md)) | | Page | Topic | |------|-------| | [design.md](./design.md) | Design harness (`agent.py` + `pyproject.toml`) HTTP submit | | [examples/design-baseline/](./examples/design-baseline/) | Reference design miner (`llm.chat` → required HTML pages) | | [prism.md](./prism.md) | Prism recipe scripts HTTP submit | +| [bounty.md](./bounty.md) | Bounty video bug-report HTTP submit | | [troubleshoot.md](./troubleshoot.md) | Common HTTP / quota / scoring failures | Normative contracts: - Design freeze: [`../DESIGN_CHALLENGE.md`](../DESIGN_CHALLENGE.md) - Prism: [`../PRISM.md`](../PRISM.md) + [`../PRISM_RECIPE.md`](../PRISM_RECIPE.md) +- Bounty: [`../BOUNTY_CHALLENGE.md`](../BOUNTY_CHALLENGE.md) - Bundle bytes: [`../BUNDLE_SPEC.md`](../BUNDLE_SPEC.md) - Threat claim (D19): [`../THREAT_MODEL.md`](../THREAT_MODEL.md) §1 @@ -45,6 +49,7 @@ Production/staging miners call the **gateway** reverse proxy: ```text https:///challenge/design/... https:///challenge/prism/... +https:///challenge/bounty/... ``` Local smoke (host ports from `env-local.yml`): @@ -52,6 +57,7 @@ Local smoke (host ports from `env-local.yml`): ```bash curl -sS http://127.0.0.1:28093/health # design-challenge curl -sS http://127.0.0.1:28092/health # prism-challenge +curl -sS http://127.0.0.1:28095/health # bounty-challenge ``` Never paste mnemonics or challenge signing keys into miner clients. Hotkeys are diff --git a/docs/external-miner/bounty.md b/docs/external-miner/bounty.md new file mode 100644 index 000000000..7a09284f9 --- /dev/null +++ b/docs/external-miner/bounty.md @@ -0,0 +1,83 @@ + + +# Bounty challenge — HTTP video bug reports + +**challenge_id:** `bounty` +**scoring_version:** `1` (TARGET_BUGS=50 + UID-0 burn sink) +**Path:** HTTP only — **no Phala/CVM** + +Normative docs: [`../BOUNTY_CHALLENGE.md`](../BOUNTY_CHALLENGE.md). + +## What you submit + +A **multipart** bug report with a short video showing the issue: + +| Field | Required | Notes | +|-------|----------|-------| +| `video` | yes | `mp4` / `webm` / `mov` (raw ≤ ~100 MiB) | +| `title` | yes | Short title | +| `description` | yes | What went wrong | +| `app_id` | yes | Target app slug | +| `steps` | no | Repro steps | + +Header: `X-Miner-Hotkey` (64 lowercase hex). Your hotkey must be **registered +on the subnet** (metagraph). Duplicate reports of the same bug within 24h are +rejected by an automated similarity check; novel reports wait for operator +admin approve before earning epoch points. + +You do **not** deploy a miner CVM. Scoring awards points for **approved +reports**, not for shipping a product fix. + +## Submit + +```bash +# Via gateway (preferred) +curl -sS -X POST "$BASE_GATEWAY/challenge/bounty/v1/bugs" \ + -H "X-Miner-Hotkey: <64 lowercase hex>" \ + -F "video=@bug.mp4;type=video/mp4" \ + -F "title=Checkout button does nothing" \ + -F "description=Clicking Pay on /pricing leaves the spinner forever." \ + -F "app_id=example-shop" \ + -F "steps=1) Open /pricing 2) Click Pay 3) Observe spinner" + +# Local / direct (env-local host port) +curl -sS -X POST "http://127.0.0.1:28095/v1/bugs" \ + -H "X-Miner-Hotkey: <64 lowercase hex>" \ + -F "video=@bug.mp4;type=video/mp4" \ + -F "title=…" -F "description=…" -F "app_id=…" +``` + +## Status / video + +```bash +curl -sS "$BASE_GATEWAY/challenge/bounty/v1/bugs/" +curl -sS "$BASE_GATEWAY/challenge/bounty/v1/bugs//video" -o compressed.mp4 +curl -sS "$BASE_GATEWAY/challenge/bounty/v1/bugs?mine=1" +curl -sS "$BASE_GATEWAY/challenge/bounty/v1/status" +``` + +Typical statuses: `uploaded` → processing → `pending_admin` | `rejected` | +`approved`. + +## Scoring (epoch) + +- Target: **50** admin-approved bugs per epoch = 100% of the bounty emission + share (`emission_share_bps = 2500` today). +- Fewer than 50: remaining mass burns (UID 0). +- More than 50: full share is split proportionally among reporters (dilution). +- Your points this epoch = number of **approved** bugs for your hotkey. + +Exact lattice: [`../BOUNTY_CHALLENGE.md`](../BOUNTY_CHALLENGE.md) §6. + +## What not to do + +- Do not paste challenge signing keys or wallet mnemonics into clients. +- Do not expect admin approve routes via the public gateway — those are + master-local only. +- Do not spam near-duplicate videos; similarity rejects within 24h. + +## Troubleshoot + +See [`troubleshoot.md`](./troubleshoot.md) for shared HTTP / metagraph / quota +failures. Bounty-specific: oversized video → `413`; unknown hotkey → +`403 hotkey_not_in_metagraph`; duplicate → `rejected` with nearest id in detail. diff --git a/docs/runbooks/bounty-enable-and-emission.md b/docs/runbooks/bounty-enable-and-emission.md new file mode 100644 index 000000000..2eb9825df --- /dev/null +++ b/docs/runbooks/bounty-enable-and-emission.md @@ -0,0 +1,125 @@ +# Bounty challenge — enable real backends + emission unlock + +Bounty emission is owner-controlled via the trust root. Current committed +shares are **design = 3000 bps**, **prism = 4500 bps**, **bounty = 2500 bps** +(sum `10000`). See [`config/challenges.toml`](../../config/challenges.toml) +and [`BOUNTY_CHALLENGE.md`](../BOUNTY_CHALLENGE.md). + +## A. Bring up bounty without fighting emission (staging / local) + +1. Materialize env: `./deploy/scripts/materialize-env.sh` +2. Ensure secrets (mode **0400**, uid **65532**): + - `deploy/secrets/bounty_sk` — challenge signing mini-secret + - `deploy/secrets/openrouter/api_key` — similar-24h OpenRouter path + (`SimAgent` if absent; **never** `BOUNTY_FORCE_SIM=true` on staging/prod) + - `deploy/secrets/bounty/admin_tokens` — operator approve/reject bearers + (one token per line; hashed at boot) +3. Confirm trust-root pubkey for `bounty` matches `bounty_sk` public key. +4. Start stack. Health: + ```bash + curl -sS http://127.0.0.1:28095/health # local overlay + curl -sS http://127.0.0.1:8095/health # in-container / droplet + ``` +5. Register challenge with gateway after redeploy: + ```json + { "challenge_id": "bounty", "base_url": "http://bounty-challenge:8095", "weight": 1 } + ``` + Or: `./deploy/scripts/register-challenge-backends.sh` (registers prism + + design + bounty and smokes `/challenge/*/health`). +6. Emission share is independent of bringing the service up: leaves still emit + (D24 exact-E) even at `0` bps. Current shares are 3000/4500/2500. + +## B. Keygen (`bounty_sk`) + +Dev throwaway (matches committed trust-root pubkey when using the ceremony +key under `~/.base-secrets/`): + +```bash +RECIPIENT=$(grep 'public key:' ~/.base-secrets/age-identity.txt | awk '{print $4}') +cargo run -p trustroot-bin -- keygen \ + --out-pub ~/.base-secrets/challenge-bounty.pub \ + --out-secret ~/.base-secrets/challenge-bounty.age \ + --age-recipient "$RECIPIENT" +# paste public_key hex into config/challenges.toml [[challenges]] id = "bounty" +age -d -i ~/.base-secrets/age-identity.txt \ + -o deploy/secrets/bounty_sk ~/.base-secrets/challenge-bounty.age +chown 65532:65532 deploy/secrets/bounty_sk && chmod 0400 deploy/secrets/bounty_sk +``` + +Never bake the mini-secret into images or cloud-init. Do **not** mount +`bounty_sk` on gateway, validator, or other challenge services. + +## C. Emission ceremony (owner) + +Goal: keep `design + prism + bounty = 10000` bps after any rebalance. + +1. Choose new shares (all ≥ 0, sum exactly **10000**). +2. Edit `config/challenges.toml` emission lines (plus pubkey if rotating). + Mirror the same body into `config/challenges.staging.toml` when staging + should track prod. +3. Re-sign **both** files with the throwaway (or production) owner key: + ```bash + cargo run -p trustroot-bin -- sign \ + --key ~/.base-secrets/owner-throwaway.age \ + --age-identity ~/.base-secrets/age-identity.txt \ + --input config/challenges.toml --kind challenges + cargo run -p trustroot-bin -- sign \ + --key ~/.base-secrets/owner-throwaway.age \ + --age-identity ~/.base-secrets/age-identity.txt \ + --input config/challenges.staging.toml --kind challenges + cargo run -p trustroot-bin -- verify \ + --owner-pub config/owner.pubkey \ + --input config/challenges.toml --kind challenges + ``` + Full ceremony: [`../../config/CEREMONY.md`](../../config/CEREMONY.md) and + [`trust-root-rotation.md`](./trust-root-rotation.md) (dual-accept if rotating). +4. Roll **all** validators with the new signed trust root before relying on the + new shares. +5. Do **not** change bounty `challenge_scoring_version` in the same ceremony + unless intentionally bumping the scoring contract. + +Current committed default: **design = 3000**, **prism = 4500**, +**bounty = 2500**. + +### Ceremony status (dev throwaway) + +As of bounty enablement on branch `add/bounty-challenge`: + +| Artifact | Status | +|----------|--------| +| `bounty` pubkey in `challenges.toml` | set (`challenge-bounty.age` under `~/.base-secrets/`) | +| `challenges.toml.sig` | re-signed under `config/owner.pubkey` | +| `challenges.staging.toml` + `.sig` | mirrored + re-signed | +| Production owner / prod `bounty_sk` rotation | **still pending** (ops; see CEREMONY.md) | + +## D. Admin tokens + OpenRouter + +- Admin bearers: `deploy/secrets/bounty/admin_tokens` (mode **0400**, uid + **65532**). Optional override: `BOUNTY_ADMIN_TOKENS_FILE`. +- Approve path (master-local only): + `GET /v1/admin/bugs?status=pending_admin` → + `POST /v1/admin/bugs/{id}/approve` (or `/reject` with reason). + See [`BOUNTY_CHALLENGE.md`](../BOUNTY_CHALLENGE.md) §5. +- Without OpenRouter key: similarity uses Sim backends in CI/local only; + staging/prod must mount a real key and keep `BOUNTY_FORCE_SIM=false`. + +## E. Rollback emission + +Re-sign `challenges.toml` with previous bps (e.g. design 5000 / prism 5000 / +bounty 0, or drop the bounty row only after dual-accept planning), roll +validators. Challenge service can keep running; emission share alone changes +aggregation weight. + +## F. Compose / image notes + +| Item | Value | +|------|-------| +| Service | `bounty-challenge` | +| Port | `8095` (local host `28095`) | +| Image / Dockerfile target | `bounty-challenge:0.1.0` / `bounty-challenge` | +| Volume | `bounty-artifacts` → `/var/lib/bounty` | +| Env example | `deploy/env/bounty-challenge.env.example` | + +Registry pin ladder (`deploy/pins/*.json` / `images.yml`) for +`bounty-challenge` is a follow-up once GHCR builds include the new target — +`--build-from source|prebuilt` works without pin rows. diff --git a/docs/runbooks/design-enable-and-emission.md b/docs/runbooks/design-enable-and-emission.md index 90ccf005e..338afc303 100644 --- a/docs/runbooks/design-enable-and-emission.md +++ b/docs/runbooks/design-enable-and-emission.md @@ -1,8 +1,8 @@ # Design challenge — enable real backends + emission unlock Design emission is owner-controlled via the trust root. Current committed -shares are **design = 5000 bps**, **prism = 5000 bps** (50/50). See -[`config/challenges.toml`](../../config/challenges.toml) and +shares are **design = 3000 bps**, **prism = 4500 bps**, **bounty = 2500 bps**. +See [`config/challenges.toml`](../../config/challenges.toml) and [`DESIGN_CHALLENGE.md`](../DESIGN_CHALLENGE.md). ## A. Bring up design without emission (staging / local) @@ -23,7 +23,8 @@ shares are **design = 5000 bps**, **prism = 5000 bps** (50/50). See { "challenge_id": "design", "base_url": "http://design-challenge:8093", "weight": 1 } ``` 6. Emission share is independent of bringing the service up: leaves still emit - (D24 exact-E) even at `0` bps. Current prod/staging shares are 5000/5000. + (D24 exact-E) even at `0` bps. Current prod/staging shares are + 3000/4500/2500 (design/prism/bounty). ## B. Keygen (`design_sk`) @@ -37,9 +38,10 @@ must **not** mount `design_sk`. ## C. Emission ceremony (owner) -Goal: move weight from prism → design (sum must remain exactly **10000** bps). +Goal: rebalance among design / prism / bounty (sum must remain exactly +**10000** bps). -1. Choose new shares, e.g. `design = N`, `prism = 10000 - N` (both ≥ 0). +1. Choose new shares for all three challenges (each ≥ 0, sum **10000**). 2. Edit `config/challenges.toml` emission lines only (plus pubkey if rotating). 3. Re-sign: follow [`../../config/CEREMONY.md`](../../config/CEREMONY.md) and [`trust-root-rotation.md`](./trust-root-rotation.md) (dual-accept if rotating). @@ -48,7 +50,8 @@ Goal: move weight from prism → design (sum must remain exactly **10000** bps). 5. Do **not** change design `challenge_scoring_version` or freeze pins in the same ceremony unless intentionally bumping the scoring contract. -Current committed default: **design = 5000 bps**, **prism = 5000 bps**. +Current committed default: **design = 3000**, **prism = 4500**, +**bounty = 2500**. ## D. Admin winners tokens + OpenRouter diff --git a/docs/runbooks/local-testnet-e2e.md b/docs/runbooks/local-testnet-e2e.md index 8c5290f0d..73382f1b7 100644 --- a/docs/runbooks/local-testnet-e2e.md +++ b/docs/runbooks/local-testnet-e2e.md @@ -106,6 +106,7 @@ staging SSH tunnels on `18080`/`18090`. Override with `LOCAL_*_HOST_PORT`. | validator | `http://127.0.0.1:28080/healthz` | | prism | `http://127.0.0.1:28092/health` | | design | `http://127.0.0.1:28093/health` | +| bounty | `http://127.0.0.1:28095/health` | Look for validator coordination (`Match epoch=`) and, when a signing key is loaded, `Match → submit_intent` / `submit_timelocked ok` (or `set_weights ok` if CR is off): diff --git a/docs/runbooks/prism-enable-lium-and-emission.md b/docs/runbooks/prism-enable-lium-and-emission.md index dff4c6c9f..28e92e01d 100644 --- a/docs/runbooks/prism-enable-lium-and-emission.md +++ b/docs/runbooks/prism-enable-lium-and-emission.md @@ -11,17 +11,20 @@ ## Emission ceremony (shared with design) -Trust root today: **`prism = 5000` bps**, **`design = 5000` bps** (sum must stay -exactly `10000`). See [`config/challenges.toml`](../../config/challenges.toml). +Trust root today: **`design = 3000`**, **`prism = 4500`**, **`bounty = 2500`** +bps (sum must stay exactly `10000`). See +[`config/challenges.toml`](../../config/challenges.toml). To rebalance shares: -1. Choose `emission_share_bps` for `prism` and `design` such that the sum is - **10000**. +1. Choose `emission_share_bps` for `design` / `prism` / `bounty` such that the + sum is **10000**. 2. Edit `config/challenges.toml`, re-sign with owner key ([`trust-root-rotation.md`](./trust-root-rotation.md)). 3. Roll validators with the new trust root (dual-accept if rotating). 4. Prefer the design-facing checklist when changing design emission: [`design-enable-and-emission.md`](./design-enable-and-emission.md). + Bounty: [`bounty-enable-and-emission.md`](./bounty-enable-and-emission.md). -Current committed default: **prism = 5000 bps**, **design = 5000 bps**. +Current committed default: **design = 3000**, **prism = 4500**, +**bounty = 2500**. diff --git a/xtask/src/external_docs_check.rs b/xtask/src/external_docs_check.rs index b1d12aeed..a460d5b45 100644 --- a/xtask/src/external_docs_check.rs +++ b/xtask/src/external_docs_check.rs @@ -1,6 +1,6 @@ //! Fail if external miner docs drift from `bundle` `PROTOCOL_VERSION`, -//! if design/prism HTTP miner paths are missing, or if `docs/THREAT_MODEL.md` -//! D19 claim is not word-for-word vs plan pin. +//! if design/prism/bounty HTTP miner paths are missing, or if +//! `docs/THREAT_MODEL.md` D19 claim is not word-for-word vs plan pin. use std::fs; use std::path::Path; @@ -11,13 +11,15 @@ const D19_VERBATIM: &str = "base guarantees *no equivocation between validators* /// Marker comment required in external miner docs. const BADGE_COMMENT_PREFIX: &str = "