feat(bounty): score only from the backend public feed, fail closed - #204
Conversation
Greptile SummaryThe bounty emitter now sources scoring data from the backend public feed, fails closed when the feed cannot be read, and sends signed participant results through the gateway. However, it can sign a leaderboard from one backend publication together with reports from another, producing incorrect tally or ordering decisions. Confidence Score: 4/5Do not merge until backend leaderboard and report responses are pinned to the same publication before scores are signed. One confirmed scoring-correctness failure remains: repeated reads can accept a stable combination of data from different backend publications. Files Needing Attention: crates/bounty-challenge/src/backend.rs
What T-Rex did
|
0c5cd84 to
63fd5c3
Compare
Co-authored-by: Mathis <echobt@users.noreply.github.com>
Co-authored-by: Mathis <echobt@users.noreply.github.com>
Co-authored-by: Mathis <echobt@users.noreply.github.com>
Co-authored-by: Mathis <echobt@users.noreply.github.com>
Co-authored-by: Mathis <echobt@users.noreply.github.com>
Co-authored-by: Mathis <echobt@users.noreply.github.com>
63fd5c3 to
8388e0e
Compare
|
Rebased onto
Greptile P1 (torn |
| if next == prev { | ||
| return Ok(next); |
There was a problem hiding this comment.
Stable torn snapshots pass the consecutive-composite check
Two equal reads only establish that each route stayed unchanged between attempts; they do not establish that /leaderboard and /reports came from the same backend publication. A backend that persistently exposes leaderboard revision A while reports remains on revision B produces the same A+B composite twice, so this returns it and signs results from a state the backend never published atomically. Require a shared revision token that is checked across both responses, or retrieve an atomic snapshot endpoint and reject mismatched revisions.
Artifacts
Stable torn snapshot reproduction harness source
- The captured temporary Rust integration harness starts a live backend with a revision-A leaderboard route and a distinct revision-B reports route, then calls fetch_public_snapshot; it defines the stable torn condition that must be rejected.
Stable torn snapshot harness command output
- The captured cargo test output shows the live route harness completed four requests and fetch_public_snapshot accepted the mixed A-plus-B snapshot; the P1 claim is reproduced.
There was a problem hiding this comment.
Fixed in 29da80b9. Consecutive equal pair-reads were not enough: a host that always served leaderboard revision A beside reports revision B would agree with itself.
fetch_public_snapshot now also requires /leaderboard valid_count to match the valid rows on /reports (snapshot_halves_agree). A stable torn pair is BackendError::Mismatched and burns. Covered by a_stable_torn_pair_is_never_signed_as_one_snapshot (live mock) and a_stable_torn_pair_does_not_agree.
No shared backend revision token is required; if both routes later grow an ETag, the moving-feed re-read can collapse to one round.
There was a problem hiding this comment.
Follow-up is #206 (this PR already merged). Consecutive equals are no longer enough: valid_count must match the valid reports, and when both routes carry revision/snapshot_id/etag (or HTTP ETag) those tokens must be equal. Covered by a_stable_torn_pair_is_never_signed_as_one_snapshot and mismatched_publication_tokens_are_never_signed_as_one_snapshot.
Summary
Bounty goes live against the CortexLM/backend public API, and the last way it
could pay miners without one is removed. Stacked on
cursor/four-live-challenges-relock-16b4(PR #202); no challenge is retuned —bountystays at 3000 bps,relearn-mmstays off.The hole this closes.
resolve_scoring_backend()had a third answer,Sim, selected byBOUNTY_FORCE_SIM, andtry_fetch_public_snapshot()turned a missing
BOUNTY_BACKEND_PUBLIC_URLintoOk(None)— a skip. So ahost with no feed could accept bug reports and score them locally, on
adjudications no validator can reproduce.
ScoringBackendis nowbackend_public | unconfigured, the skip is gone, and every read failure(unset, blank, transport, non-2xx, unparseable, mid-publish) is an error.
Live wiring.
crates/bounty-challenge/src/emit.rsaddsBountyEmitter:fetch the two public routes → derive
Efrom the metagraph atlast_epoch_block→ sign one leaf per hotkey inEfor the current subnetepoch →
POST /v1/weights/raw. The binary wires it fromBASE_CHAIN_ENDPOINT(S),BASE_NETUID,BASE_CHALLENGE_GATEWAY_ENDPOINT,and
BOUNTY_EMIT_POLL_SECS. Validators are unchanged: they fetch the sealedbundle and never read the bounty feed.
What fail-closed means here (and what it must not mean)
A tick that cannot read the feed pays nobody: no score is invented and no
local scorer is consulted. It still covers
E, withNoScore(ChallengeInternal)— the codeBUNDLE_SPEC§3.3.1 defines as"challenge-side fault; still must cover the participant". Both halves matter:
NoScoreset burns the 3000 bps to uid 0, thehonest outcome when nothing was adjudicated.
E. I first implemented this as "emit nothing", thenchecked
bundle::seal::assert_participant_completeness: D24 requires a leafper participant for every challenge with
emission_share_bps > 0, so anuncovered bounty row makes
POST /v1/admin/sealanswer 409incomplete_participant_set for the whole bundle. Silence would have let an
unconfigured bounty host leave relearn's weights unsealed. The emitter
therefore runs even with no feed configured; only a missing
BASE_CHALLENGE_SK_FILEstops it, since a leaf the trust root rejects is notweight.
Ingest keeps the stricter answer:
POST /v1/reportsis 503 with no feed,storing nothing.
A failed tick also avoids taking back a score: once the process has scored an
epoch, an outage inside that same epoch holds rather than superseding a
champion's leaf with a burn. That watermark is in-process (the gateway exposes
POST /v1/weights/rawwith no GET side, so there is nothing to consult), so arestart during an outage can still burn an epoch that had scores; the next
successful tick supersedes it back. The bias is deliberate — burning pays
nobody who was not already paid, staying silent would 409 everyone.
Anti-farm behaviour from this branch is untouched: title==body and token-thin
bodies are still 400, and a repeated fingerprint (including one already
closed) is still
duplicate.A mixed pair is not a snapshot (Greptile P1, fixed in
66fb79db)Greptile caught that the first revision read
/leaderboardand/reportsonceapiece and signed whatever came back. Those are separate GETs, so a publish
landing between them gets combined into one scoring input, and the failure is
not cosmetic: every tally comes from
/reports(
score_plan_from_snapshot→holdouts_from_reports), so a stale half canunder-count a miner's valid rows or drop it to
NotAttempted, while/leaderboardsets the champion walk order thatjudge_challengerconsumes.Either way the epoch carries a verdict the backend never published.
fetch_public_snapshotnow re-reads the pair until two consecutive readsagree, bounded at four reads. Two matching reads bracket the in-between read on
both routes, so a publish anywhere in that window shows up as a mismatch. Three
details worth reviewing:
PublicSnapshot, not the raw bodies, so a fieldthe public DTO does not model (a
generated_atstamp, say) cannot make astill feed look like a moving one and burn the epoch forever.
buys, and
a_feed_that_settles_is_scored_at_the_revision_it_settled_onpins it.
BackendError::Inconsistent, which fallsinto the existing burn/hold path rather than signing a guess.
This needs nothing from the backend. If it later publishes a revision or ETag
on both routes, the check collapses to a single round.
Deploy artifacts
docker-compose.yml:BOUNTY_EMIT_POLL_SECS+ gateway endpoint on theservice;
BASE_NETUIDstays in the env file and chain endpoints in theoverlays (no value baked over
env_file).env-staging.yml/env-prod.yml: testnet / mainnet chain failover listsfor the emitter;
BOUNTY_BACKEND_PUBLIC_URLstill host-set and empty in git.env-local.yml/docker-compose.e2e.yml: sim knob dropped — a laptop withno feed now 503s on ingest and emits burn leaves only.
deploy/env/bounty-challenge.env.example: feed + emitter knobs documented,names only.
assert-compose-matrix.sh: noBOUNTY_FORCE_SIMin any compose file, and nobaked backend host in staging/prod.
local-e2e.sh --smoke:probe_bounty_fail_closedPOSTs real ingest andasserts 503 (no feed) or 401
invalid_session(feed configured), ratherthan trusting
/health.No Modal, no secrets, no backend hostnames in git.
Known gap this PR does not close (out of scope)
relearn,relearn-image, andrelearn-agentstill haveemit_epoch()as alibrary function with no emitter loop and no gateway client, so the
four-challenge bundle cannot seal on
mainuntil they emit too — bounty is nowthe only one of the four that covers its own trust-root row. Not touched here
per "do not retune other challenges", but it is the next thing needed for a
sealed four-challenge bundle.
Greptile
@greptileai reviewGreptile reviewed
23606f11and raised one P1 (torn feed snapshot), fixed in66fb79dbwith a regression test — see the section above. Its T-Rex runtimereproducer could not run ("execution service unavailable"), so the property is
pinned by
a_feed_that_never_holds_still_is_never_signed_as_one_snapshotinstead: themock feed publishes a new revision on every request, and the emitter must burn
rather than score. That test fails against the pre-fix read.
Test plan
Every step of
.github/workflows/ci.yml'scijob was run locally on66fb79db, using the versions CI pins (Rust 1.96.0, cargo-deny 0.20.2 ascargo-deny-action@v2installs):cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspace— 299 suites, 1851 passed, 0 failedcargo deny check—advisories ok, bans ok, licenses ok, sources okcargo run -p xtask -- loc-cap/consensus-lint/spec-check/design-check/external-docs-checkcargo clippy -p validator-bin --features dcap --all-targets -- -D warningsbash deploy/scripts/assert-compose-matrix.sh— all checks passed,including the new bounty assertions. This was the one gate the first
revision could not run;
docker compose configneeds no daemon, so thestandalone compose plugin renders the whole role × env matrix.
bash -non both edited shell scriptsGitHub Actions does not report on this PR, and that is the trigger config
rather than a failure:
ci.ymlfires onpull_request: branches: [main], andthis PR targets
cursor/four-live-challenges-relock-16b4(#202). Retargetingmainto make the checks appear would swell the diff to ~199 files of #202 plusits own parent and duplicate two open PRs, so the base stays put and the gate
matrix above is the evidence. CI will run for real when this lands on a
main-targeting PR after the stack merges.New tests, stated as the property they defend:
emit_fail_closed::mock_backend_rows_become_scored_leaves_for_metagraph_hotkeysScore > 0, fabricatorInvalidResponse, silent hotkeyNotAttempted…::an_unset_backend_url_burns_without_paying_anyone/a_blank_backend_url_…EwithChallengeInternaland pays nobody — noscorefield on any leaf,scored_epochstill 0…::a_failing_backend_burns_until_it_recovers…::an_outage_after_a_scored_epoch_holds_instead_of_burning_it…::a_feed_that_never_holds_still_is_never_signed_as_one_snapshotInconsistentand burns, rather than signing a mixed pair…::a_feed_that_settles_is_scored_at_the_revision_it_settled_onbackend.rs::an_unset_url_is_an_error_not_a_skip,an_unreachable_backend_is_a_fetch_errorbounty-challenge-task::the_retired_sim_opt_in_cannot_turn_scoring_back_onBOUNTY_FORCE_SIM=1/true/yesstill resolvesUnconfiguredbounty-http::an_unconfigured_host_refuses_reports_instead_of_collecting_thembounty-http::status_publishes_the_scorer_and_the_off_score_gate/v1/statusno longer advertisesforce_simbins/bounty-challengeboot testsEstays covered), and not wired without a challenge keyRisk
Emission-shaped but bounded.
bountystays at 3000 bps and no otherchallenge is retuned. Three things an operator should know:
BOUNTY_FORCE_SIM=1now refuses ingest andpays nobody until
BOUNTY_BACKEND_PUBLIC_URLis set. Intended — the simpath was paying on unverifiable numbers — but it is a live-behaviour change
on any host that set it. The boot log says so explicitly.
bounty_skpublic key mustmatch
config/challenges.toml(d2ffbe70…) or the gateway rejects them;local-e2e.shalready aligns that key.bounty scores (documented above). It errs toward paying nobody, never
toward paying the wrong hotkey, and self-heals on the next good tick.
Feed load doubles on the happy path (two reads of each route per tick instead
of one) — four small GETs per
BOUNTY_EMIT_POLL_SECS, 120s by default.GET /v1/statusdrops theforce_simfield andscoring_backendcan nolonger be
sim. Nothing in-repo consumes either.Naming
I did not rename
BASE_*environment variables, deployed host paths(
/opt/base,/run/base, …), GHCRbaseintelligence/basepackage names, orbase-*-v1cryptographic domain tags, unless this PR’s purpose is a coordinatedcutover documented in
docs/NAMING.md.