katana is a continuously running backend that watches gno.land validators and, based on a computed health score, produces a GovDAO proposal to eject, lower, or raise a validator's voting power — then follows the vote outcome and notifies on the result.
It reads validator health from a running
gnomonitoring instance, is
multi-chain (testnet, betanet, mainnet, …), and is driven entirely by a single
config.yaml.
See prd.md for the full product specification.
katana is a daemon. For every enabled chain it runs two workers on independent tickers:
┌──────────── katana daemon ────────────┐
gnomonitoring API ──────┤ eval-worker(chain) every poll_interval │
/api/reports/validators│ evaluate → gate → proposal → notify │
│ │
gno.land RPC ───────────┤ vote-worker(chain) every vote_poll_... │
/validators, GovDAO │ poll GovDAO status → accepted/rejected │
└──────────────────────────────────────────┘
SIGINT/SIGTERM → graceful shutdown
Evaluation worker (every poll_interval):
- Assert the node still reports the configured
chain_id(/statusnode_info.network); on mismatch — typically a testnet genesis reset — skip the cycle cleanly. - Pull each validator's computed health score (and sub-metrics) per reporting window from gnomonitoring.
- Evaluate each validator against
proposal.action_priority, in order, picking at most one action (eject/lower_vp/raise_vp) — see Score-driven actions. - Skip exceptions and validators blocked by the lifecycle gate (pending / accepted / cooldown / rejection backoff).
- Confirm the target is in the live consensus valset.
- Re-assert the chain id, then sign & broadcast via
gnokey, capture the on-chain proposal id, and send a notification — or, on adry_run: truechain, log + notify the would-be proposal without broadcasting (see Rehearsal / dry-run).
Vote worker (every vote_poll_interval): polls the GovDAO status of each
pending proposal katana broadcast, and on resolution notifies + updates state:
- accepted → validator removed from the valset;
- rejected → validator kept; re-proposal suppressed for
rejected_backoff, and stopped entirely aftermax_rejections.
katana reads a computed per-validator health score from gnomonitoring
(GET /api/reports/validators) instead of raw missed-blocks/alert counts.
Three actions are possible, evaluated in proposal.action_priority order
(default eject, lower_vp, raise_vp) — the first whose threshold matches on
its own configured window wins, so at most one action applies per validator
per cycle:
| Action | Threshold | Effect |
|---|---|---|
eject |
score < score_below |
Power=0 removal (unchanged from the previous ejection mechanism) |
lower_vp |
score < score_below |
reduce voting power by a fixed step |
raise_vp |
score >= score_at_or_above |
increase voting power by a fixed step |
The default thresholds (30 / 60 / 85) mirror gnomonitoring's own tier boundaries (Critical / Watch / Good / Excellent); scores 60-84 ("Good") are an intentional no-action stability zone.
VP step. lower_vp/raise_vp move a validator's voting power by
whichever of two caps has the smaller magnitude: vp_step_percent_of_self (a
% of the validator's own current VP — protects small validators from an
oversized single move) and vp_step_percent_of_total (a % of the chain's
total valset VP — keeps a large validator's step bounded). Both recomputed
fresh from live data every cycle, so repeated triggers don't compound. The
result is clamped to [min_voting_power, max_voting_power_share% of total valset VP] (the reducing direction floors at min_voting_power, the raising
direction caps at max_voting_power_share); a step that would already be a
no-op at a bound produces no proposal that cycle.
Every threshold/window/step is independently configurable per action, and
proposal.actions.* can be overridden per chain like the rest of
proposal: (whole-block replace).
Two different "interval" notions.
poll_intervalis how often katana runs;proposal.actions.*.windowis the gnomonitoring reporting period a score is read over (last_24h|current_week|current_month|current_year).
All thresholds, windows, wallet and timers are configurable in
config.yaml and overridable per chain.
Validators under exceptions: are never proposed, matched by address
(recommended) or moniker. The global list is unioned with each chain's own list.
Because katana runs continuously, a per-(chain, validator) state machine
(backed by SQLite) prevents duplicate proposals:
- a pending proposal blocks new ones until the vote resolves;
- an accepted eject is terminal (the validator is gone); an accepted
lower_vp/raise_vpis not terminal — onceproposal.cooldownelapses, a new action (eject / lower_vp / raise_vp) may be proposed if the validator's score still qualifies; - rejected suppresses re-proposal for
proposal.rejected_backoff, and stops afterproposal.max_rejections(default2, shared across action types); proposal.cooldown(default720h= 30d) is a floor between any two proposals for the same validator.
Because this gate is what stands between katana and proposal spam, katana
refuses to start when any broadcast-capable chain (dry_run: false) is
configured with storage.driver: none — the no-op store keeps no history, so
every cycle would re-broadcast the same ejection. Configure
storage.driver: sqlite. Dry-run-only configurations may run with none
(a startup warning describes the degraded rehearsal dedup).
Set dry_run: true — globally (the default for every chain) or per chain,
overriding the global default — to run katana against a live network without
ever signing or broadcasting. The eval worker runs the full pipeline —
metrics, score-driven action matching, exceptions, lifecycle gate, valset
membership, operator resolution — but instead of signing and broadcasting it:
- logs the complete would-be proposal (validator, operator, matched action,
title, description) with a
[DRY-RUN]prefix; - sends the normal notification with the proposal script attached (eject or
VP-change; Discord/Telegram; Slack falls back to inline code) and a
ready-to-run
gnokeycommand (password piped, never embedded), so an operator can review and broadcast it by hand; - records a
dry-runrow in the proposal history so the same rehearsal is not re-logged/re-notified on every poll (theproposal.cooldownwindow applies).
Dry-run records count toward the real lifecycle gate exactly like a
broadcast attempt would: disabling dry_run does not bypass the cooldown of a
proposal that was already rehearsed and shown to an operator. Use dry_run
both to onboard a new chain and as a standing manual-approval mode. It
defaults to false — a plain enabled chain is broadcast-armed.
On a successful broadcast, and on accepted / rejected outcomes,
katana notifies every enabled channel (Discord, Slack, Telegram). Example
(eject):
A proposal for validator
Sam(g1abc…) onbetanethas been sent to remove it from the valset.
lower_vp/raise_vp notifications instead report the voting-power move, e.g.
"...has been sent to lower its voting power from 500 to 400 (-2.1% of total
valset VP)."
Additionally, on dry_run: true chains, katana sends a notification with the would-be proposal script (eject or VP-change) and ready-to-run gnokey command — see Rehearsal / dry-run.
Notifications are color-coded by action: green for raise_vp, orange for lower_vp, red for eject (Discord embed color / Slack attachment color; Telegram uses a 🟢/🟠/🔴 emoji prefix since it has no native color concept). The title states the lifecycle stage (submitted/accepted/rejected/dry-run).
Nothing is sent on errors.
- Go 1.23+
- A reachable gnomonitoring backend.
- The
gnokeybinary and a keybase (or mnemonic) holding the proposing key — katana always signs and broadcasts.
git clone https://github.com/samouraiworld/katana
cd katana
make build
cp config.yaml.example config.yaml # then edit it
./katana --config config.yaml # runs until Ctrl-C / SIGTERMThe only flag is --config. Chains, wallet, notifications and timers all live
in config.yaml.
docker compose up --build -d # long-running; restart: unless-stopped
docker compose logs -f katanaThe SQLite database persists in the katana-data volume; point
storage.sqlite_path under /app/data.
Everything is in config.yaml (see config.yaml.example).
Secrets/per-deployment values can be overridden from the environment:
| Env var | Overrides |
|---|---|
KATANA_LOG_LEVEL |
log_level |
KATANA_GNOMON_BASE_URL |
gnomonitoring.base_url |
KATANA_GNOMON_API_TOKEN |
gnomonitoring.api_token |
KATANA_GNOKEY_BIN |
gnokey.bin |
KATANA_GNOKEY_KEY |
gnokey.key_name |
KATANA_GNOKEY_ADDRESS |
gnokey.address |
KATANA_GNOKEY_HOME |
gnokey.home |
KATANA_GNOKEY_PASSWORD |
gnokey.password |
KATANA_GNOKEY_MNEMONIC |
gnokey.mnemonic |
KATANA_DISCORD_WEBHOOK |
notifications.discord.webhook_url |
KATANA_SLACK_WEBHOOK |
notifications.slack.webhook_url |
KATANA_TELEGRAM_TOKEN |
notifications.telegram.bot_token |
KATANA_TELEGRAM_CHAT_ID |
notifications.telegram.chat_id |
Ejection realm (call-mode only).
proposal.actions.eject.realm_pkg_path/function/argsare configurable because the valset-ejection entrypoint differs between networks; they only matter whenproposal.submit: call(call submission is eject-only —run, the default, uses the built-in ejection/VP-change scripts and needs no realm path). Verify them against each chain before broadcasting — the local devnet (below) is the place to confirm them.
Enable web: in config.yaml to expose GET /health (200 = alive) for
liveness monitoring / Grafana alerting.
make test # unit tests (offline, no chain/network)
go test ./... -raceAn opt-in end-to-end / preprod suite runs katana against a local gno.land devnet
under gnoland-test/ (copied and adapted from gnomonitoring):
3 validators + tx-indexer + gnoweb + GovDAO, with scenarios to onboard/down a
validator so katana can be exercised through the full detect → broadcast → vote
flow. See gnoland-test/README.md.
For a wired-up stack (Postgres + gnomonitoring + katana) against that devnet, use
docker-compose.e2e.yml — see
deploy/e2e/README.md.
Data volumes are broadcast-only. The SQLite schema no longer has the old dry-run/artifact columns; a
katana-data(ore2e-katana-data) volume created before this refactor must be wiped, not reused.
cmd/katana/ daemon entrypoint (signals, config, store, run)
internal/config/ config.yaml loading, validation, per-chain resolution
internal/daemon/ per-chain eval + vote workers, health server
internal/gnomonitoring/ REST client for validator metrics
internal/gnoland/ gno.land RPC (consensus valset safety check)
internal/govdao/ GovDAO proposal id extraction + status render
internal/rules/ eligibility evaluation
internal/lifecycle/ proposal state-machine gate (cooldown/backoff/cap)
internal/proposal/ proposal rendering + gnokey broadcast
internal/notify/ Discord / Slack / Telegram senders
internal/store/ SQLite history (evaluations, proposals, notifications)
gnoland-test/ local devnet for preprod/e2e
- katana broadcasts by default: every eligible validator on a chain without
dry_run: trueresults in a signed, on-chain GovDAO proposal — usedry_runto rehearse a new chain or as a standing manual-approval mode.gnokeyis embedded in the container image (pinned by theGNO_REFbuild-arg); the signing key is provisioned from a mnemonic at startup (gnokey.mnemonic/KATANA_GNOKEY_MNEMONIC) or from a pre-provisioned keybase. - The lifecycle gate prevents proposal spam in the continuous loop.
- katana verifies valset membership before proposing.
- katana asserts the node's reported chain id against the configured
chain_idat every cycle start and again immediately before any broadcast (dry-run included), and skips cleanly on mismatch — a genesis reset cannot produce signature-failure noise or wrong-chain transactions. - No key material is stored by katana outside the keybase; signing is
delegated to
gnokey. config.yaml,data/and*.dbare git-ignored.
