A Cortex challenge is a Docker image that scores miners. The master runs it, reads its weights once per completed epoch, signs one leaf per expected hotkey and seals the epoch. Validators only verify the sealed bundle and submit it.
owner (offline) challenges.toml v>=3: id, leaf key, emission bps, policy
master operator challenge-registry.toml: image, channel, resources, env
challenge-supervisor pulls, verifies, canaries, runs and updates containers
challenge container public routes + GET /internal/v1/get_weights
master polls weights, signs leaves, seals, proxies /challenge/<id>/
validator GET /v1/weights/latest + /v1/bundle/<epoch>, recompute, submit
The owner-signed trust root decides which challenges earn emission and how
much. The unsigned registry only decides what runs. A registered challenge that
is absent from the trust root runs without emission (burn-in). A trusted
challenge that is not registered or cannot score emits
NoScore(ChallengeInternal) for every expected hotkey and its share burns.
The container serves plain HTTP on port 8000 of the private
cortex-challenges network. It never publishes a host port. It runs as UID
65532 with a read-only root filesystem, no capabilities, a /tmp tmpfs and one
writable named volume at /data. The image must create /data owned by
65532:65532, because Docker copies that ownership into a new named volume.
| Variable | Value |
|---|---|
CHALLENGE_SLUG |
the challenge id, for example bounty |
CHALLENGE_STATE_DIR |
/data |
CHALLENGE_INTERNAL_TOKEN_FILE |
/run/secrets/internal.token, the master bearer |
CHALLENGE_ADMIN_TOKEN_FILE |
/run/secrets/admin.token, the operator bearer, optional |
CHALLENGE_MASTER_URL |
http://cortex-master:8080, for /v1/metagraph/latest |
registry env |
challenge-specific settings |
Every file under /run/secrets/ comes from the host directory
<BASE_CHALLENGE_SECRETS_DIR>/<id>/, mounted read-only. A container never
receives a leaf-signing seed.
| Route | Contract |
|---|---|
GET /health |
200 {"ok": true} when the container can score; 503 otherwise. Readiness only: an outage of an external dependency must not restart the container |
GET /version |
200 {"slug", "version", "contract": 1, "capabilities": [...]} |
GET /internal/v1/get_weights?epoch=<u64> |
weights for a completed epoch, described below |
| any other path | public, proxied when the capabilities include proxy_routes |
capabilities is a subset of get_weights and proxy_routes.
The master also sends epoch_at=<positive integer Unix seconds> when available:
Timestamp.Now at the completed epoch's pinned end-block hash, validated as
integer milliseconds and floored to seconds. There is no wall-clock or estimated
block-time fallback. Algorithm 3 epochs with trusted OpenType require this time;
missing or invalid time postpones emission before any leaves are replaced or the
epoch is sealed, rather than signing NoScore. Other challenge configurations
and legacy client calls remain compatible without epoch_at. Containers that do
not use epoch time may ignore this optional query parameter. Persisted epoch
responses remain immutable regardless of subsequent request parameters.
get_weights requires Authorization: Bearer <internal token> (401
otherwise) and X-Platform-Challenge-Slug: <slug> (403 on mismatch). It
returns:
{
"challenge_slug": "bounty",
"epoch": 25316,
"weights": {"5F...": 3.0, "5G...": 1.0},
"full_share_mass": 10.0,
"metadata": {},
"computed_at": "2026-09-24T12:00:00Z"
}weightsmaps an SS58 or 64-hex hotkey to a finite non-negative number. It holds at most 65,536 entries.full_share_massis optional. When it is present, the challenge pays its full share only once the total weight of expected hotkeys reaches it, and the shortfall burns. When it is omitted ornull, the expected weights are normalized to the full share.- The first successful answer for an epoch is final. The container persists it and returns the same body for every later call with that epoch.
503means the container cannot score. The master then burns the share for that epoch.- The call must answer within 60 seconds.
Let E be the owner-policy participant set of the sealed metagraph, w_i the
returned weight (0 when absent), W = sum(w_i for i in E) and
D = max(W, full_share_mass or 0). A hotkey outside E is ignored and never
changes D.
| Algorithm | Leaf score for i in E with w_i > 0 |
Challenge payout |
|---|---|---|
| 3 (trust root version >= 3) | floor(10^12 * w_i / D) |
share * sum(leaves) / 10^12 |
| 2 (bounty/proof 3000/7000) | w_i, which must be an integer below 2^64 |
Bounty: share * min(N, 10) / 10; Proof: share |
| 1 (legacy bounty/proof 2000/8000) | not scored: every hotkey in E gets NoScore(ChallengeInternal) |
nothing; the share burns |
Every other hotkey in E receives NoScore(NotAttempted). A failed or invalid
call gives NoScore(ChallengeInternal) to every hotkey in E: one invalid
weight invalidates the whole answer, so an answer is never partially paid.
Algorithm 1 signed Bounty's legacy champion score, which no container computes,
so containers are not scored under it; trust watermarks never return to it. Unpaid mass
always burns to UID0 and never moves to another challenge. Under algorithm 3,
Bounty with full_share_mass = 10 pays exactly what algorithm 2 pays:
share * min(N, 10) / 10 in total and share * n_i / max(10, N) per author.
The master forwards ANY /challenge/<id>/<path>?<query> to
http://cortex-challenge-<id>:8000/<path>?<query>. It refuses the following
with 404:
- a path under
internal/; - an empty,
.or..segment; - a backslash or a percent sign in the path.
It forwards the request body, up to the registry proxy_body_limit (default 1
MiB, 413 beyond it). Only the content-type, accept and authorization
request headers pass through. The master sets X-Forwarded-For itself,
overwriting any client value. It returns the status,
the content-type and at most 8 MiB of the response body. The request times out
after the registry proxy_timeout_seconds (default 30). A failure returns
502, and an unknown challenge returns 404.
GET /v1/metagraph/latest on the master returns
{"epoch", "block", "netuid", "hotkeys": {"<ss58>": uid}} from the latest
sealed bundle, or 503 when no seal exists. Challenges use it to admit only
registered hotkeys.
A challenge repository publishes ghcr.io/<owner>/<repo>:
| Tag | Moves | Built by |
|---|---|---|
sha-<commit> |
never | every push to main, after tests |
edge |
every push to main |
alias of the tested sha- digest |
vX.Y.Z |
never | annotated tag; aliases the existing sha- digest, no rebuild |
stable |
every release | alias of the release digest |
Every image carries these labels: org.opencontainers.image.source,
org.opencontainers.image.version, org.opencontainers.image.revision,
io.cortex.challenge.slug=<id> and io.cortex.challenge.contract=1.
For each registry entry, the supervisor runs this loop every poll_seconds:
- Pull
image:channel, orimage@pinwhen a pin is set, and read the digest. An unchanged digest only ensures the container is running. - Refuse the image when the slug label differs from the id, the contract label
is not
1, or the source label differs from the registrysource. The current container keeps running. - Unless
attestation = false, require a GitHub build-provenance attestation for the digest in thesourcerepository. - Start a canary with the same image, no secrets and a tmpfs
/data. It must answer/versionwith the expected slug and contract within 60 seconds. - Replace the container: stop the old one and keep it aside, then start the new
one on the same volume. Then wait until
/versionreturns the expected slug and contract./healthis readiness and is only reported, because an external outage must not trigger a rollback. - If the new container cannot be created, started or reached, remove it and restart the previous container unchanged. The refused digest and configuration are not retried until the channel or the registry entry moves.
A registry change that keeps the same digest, such as env, source, resources
or limits, redeploys through steps 2, 3, 5 and 6: labels and provenance are always
re-checked against the current entry, and only the canary is skipped. A supervisor
restart in the middle of step 5 restores the set-aside container before anything
else. The container carries a
fingerprint of its full specification for this purpose.
A managed container whose id is no longer in the registry is stopped and removed. Its volume is kept.