A 3-validator Gno.land devnet, run entirely with Docker, used to exercise every gnomonitoring feature (block participation tracking, alerts, GovDAO watcher, Prometheus metrics, Telegram bots) end-to-end before merging changes to production — in particular for validating the feat/migrate-sqlite-to-postgres branch.
It ships:
- validator / validator2 / validator3 — genesis validators
(
samourai-crew-1/2/3, voting power 10/10/11).validator's consensus signing is externalized to a tmkms sidecar (see below); validator2/3 sign locally. - tmkms — remote signer for
validator(softsign backend over a Unix socket), the reference for the production tmkms migration - validator4 — a 4th identity (
samourai-crew-4) reserved for the Phase 2 "new validator via GovDAO" scenario, not part of the genesis validator set, started on demand via thephase2compose profile - tx-indexer — GraphQL transaction/block indexer
(
http://localhost:8546/graphql/query) - gnoweb — block explorer UI (
http://localhost:8890)
- Docker + Docker Compose
- A local clone of the gno repository
(used to build the
gnoland/gnogenesis/gnokeyimages and to deploy theexamples/gno.landpackage tree into genesis)
GNO_REPO_PATH=/path/to/gno ./bootstrap.shThis builds the gno-validator:local, gno-contribs:local and
gno-gnokey:local images, generates node secrets and P2P identities for all
4 nodes, the dev/v4op gnokey accounts, the shared config.toml, the .env
file (P2P seeds), the rendered init-govdao.gno/propose-validator4.gno
scripts, and genesis.json (3 validators + the full examples/gno.land
package tree + balances + auth.unrestricted_addrs).
Nothing generated is committed to git — keys, genesis, .env, the
.gnokey-dev/ keybase, .devaddrs.mk and the rendered *.gno are all
gitignored. Every fresh setup regenerates them, so the validator / dev /
v4op addresses change between setups. That's intentional and safe: the
genesis, .env, the rendered GovDAO scripts and .devaddrs.mk are all derived
from the same freshly generated keys, so they always stay consistent.
The script is idempotent: re-running it skips any step whose output already
exists. To wipe everything and regenerate from scratch (new keys + addresses),
run make clean-all then make full-reinit.
docker compose up -d # start validator, validator2, validator3, tx-indexer, gnoweb
docker compose down # stop everything (keeps chain state)To also start the 4th identity for the Phase 2 GovDAO scenario:
docker compose --profile phase2 up -d validator4Useful endpoints once running:
| Service | URL |
|---|---|
| validator RPC | http://localhost:26658 |
| validator2 RPC | http://localhost:26659 |
| validator3 RPC | http://localhost:26660 |
| validator4 RPC | http://localhost:26661 (phase2 profile only) |
| tx-indexer GraphQL | http://localhost:8546/graphql/query |
| gnoweb | http://localhost:8890 |
Check progress:
curl -s http://localhost:26658/status | jq '.result.sync_info'validator's consensus key is not used by gnoland directly — signing is
externalized to a tmkms sidecar container, exactly as planned for
production. This is the same-host, simplest variant of the model:
- Backend:
softsign(no HSM) — the consensus key sits intmkms/validator/secrets/consensus.key. - Transport: Unix-domain socket (
unix:///run/gnoland/privval.sock) shared via the./tmkms/runvolume mounted into both containers. Both run under the same UID so the0600socket is reachable; the socket permissions are the only auth boundary (noallowed_kms_pubkeysneeded on UDS). - Wiring: gnoland listens because
validatorsetsTMKMS_LISTEN_ADDR=unix:///run/gnoland/privval.sockandTMKMS_CHAIN_ID=dev(docker-compose.yml); tmkms dials that socket withreconnect = true(tmkms/validator/tmkms.toml), so start order doesn't matter.protocol_version = "v0.34"(gnoland speaks only v0.34).
The signer material under tmkms/validator/secrets/ (consensus.key,
kms-identity.key, consensus_state.json) is gitignored and (re)generated
by bootstrap.sh: it reslices the node's priv_validator_key.json into the
softsign base64 format. Because it's derived from the node key, a fresh setup
(new keys) regenerates it in lock-step.
Requires a gnoland image with tmkms support (the tmkms_listener config,
merged in gno master — PR #5718 / commit a870686e4). Verify with:
docker run --rm --entrypoint /usr/bin/gnoland \
gno-validator:local start -h 2>&1 | grep -c skip-genesis-sig # must be 1Revert validator to local signing (no sidecar): unset TMKMS_CHAIN_ID
and TMKMS_LISTEN_ADDR on the validator service and stop the tmkms
container. gnoland then signs with its own priv_validator_key.json.
For the 2-VM TCP variant of this setup (signer on a separate host, with the
allowed_kms_pubkeyspin that TCP requires), see../tmkms-lab/.
Two levels of reset, both wrapped by the Makefile:
make reinit— purges thedev-chain rows from the gnomonitoring DB, then runs./reinit-chain.sh: wipes each node'sconfig,db,walandgenesis.jsonand resetspriv_validator_state.json, while keeping the node identities (secrets/) and thedev/v4opkeybase — so all addresses stay the same. Fast; use it between test scenarios.make clean-all— nukes everything generated (keys, keybase, genesis, config,.env,.devaddrs.mk, rendered*.gno). The nextmake full-reinitregenerates fresh keys and new addresses.
make reinit # keep identities, restart chain from existing genesis
# or, full regeneration:
make clean-all
make full-reinit # bootstrap (new keys+genesis) + purge dev DB + start
⚠️ Restart the backend after any reset.gnomonitoring-backendkeeps the last-seen block height per chain in memory. When you reset the chain under a running backend, it waits for the new chain to climb back past the old height before ingesting — sodaily_participationsfordevstays empty until you restart it:docker restart gnomonitoring-backendThis also reconnects the GovDAO websocket, which is what fires "New Proposal" alerts (see the GovDAO note under Scenario 1).
bootstrap.sh generates two throwaway gnokey accounts into .gnokey-dev/
(gitignored, regenerated with new addresses on every fresh setup). Both are
funded in genesis; dev is also set as auth.unrestricted_addrs so it can
register valopers or submit GovDAO proposals without paying realm storage
deposits.
| Key | Role |
|---|---|
dev |
GovDAO member, proposal submitter |
v4op |
validator4 operator (must sign Register) |
-
Passphrase (both accounts):
devpassword123 -
The current addresses are written to
.devaddrs.mkafter each setup (DEV_ADDR,VALIDATOR4_OP_ADDR,VALIDATOR4_ADDR,VALIDATOR4_PUB) and can also be listed from the keybase:cat .devaddrs.mk gnokey list -home "$PWD/.gnokey-dev"
Use them with the local gnokey binary (a plain docker run of
gno-gnokey:local can't reach localhost:26658 unless the container joins the
devnet's docker network):
gnokey query bank/balances/$(awk '/DEV_ADDR/{print $3}' .devaddrs.mk) \
-home "$PWD/.gnokey-dev" -remote http://localhost:26658There are two ways to run gnomonitoring against this devnet, depending on whether the backend itself runs on the host or in a container.
Copy the chains.local entry from
config.devnet.yaml.example into the chains:
section of backend/config.yaml, then either set default_chain: "local"
or query the API/Telegram bot with ?chain=local. The devnet's RPC/GraphQL/
gnoweb ports are published on 127.0.0.1, so localhost:26658 etc. are
reachable directly from the host.
The gnomonitoring-backend container can't reach 127.0.0.1:<port> on the
host, so it needs to join the devnet's Docker network and address the devnet
services by their compose service name instead of localhost.
-
Start the devnet first (so the
gnoland-test_defaultnetwork exists):cd devnet && docker compose up -d
-
backend/docker-compose.ymlalready attaches thedetect-proposalservice to the externalgnoland-test_defaultnetwork (in addition to its owndefaultnetwork for Postgres). -
Configure
backend/config_docker.yaml(gitignored — copy frombackend/config.yaml.templateif it doesn't exist yet) with:default_chain: "local" chains: local: rpc_endpoints: - "http://validator:26657" graphqls: - "http://tx-indexer:8546/graphql/query" gnowebs: - "http://gnoweb:8888" enabled: true
-
Start the backend:
cd backend && docker compose up -d --build
-
Check it picked up the devnet:
docker logs gnomonitoring-backend --tail=20 curl -s http://localhost:8989/api/validators?chain=local | jq
Troubleshooting: if gnomonitoring-backend logs
lookup postgres on 127.0.0.11:53: no such host, the gnomonitoring-postgres
container was started without joining the compose network (e.g. left over
from a previous run). Fix with:
docker compose up -d --force-recreate postgres
docker restart gnomonitoring-backendThe pgdata volume is untouched, so no data is lost.
These scenarios exercise gnomonitoring end-to-end against this devnet (block
participation tracking, alerts, GovDAO watcher, Prometheus metrics). They
assume gnomonitoring is running against chain=local as described above and
the chain is healthy (see "Resetting the chain" if not).
Genesis validator set and voting power: samourai-crew-1/2/3 = 10/10/11 (total 31). A strict majority above 2/3 of 31 is 21, so in theory any 2 of the 3 genesis validators can keep the chain alive without the third.
A Makefile in this directory wraps the chain-lifecycle and scenario commands
— run make help for the full list. Available scenarios:
| Target | What it exercises | How to assert |
|---|---|---|
make scenario1 |
New validator via GovDAO (onboard validator4) | v3.IsValidator = true, voting_power 0→1 |
make scenario2 / scenario2-restart |
Validator downtime: WARNING→CRITICAL→RESOLVED (stops validator4) | alert_logs rows for chain='dev', addr=VALIDATOR4_ADDR |
make scenario3 / scenario3-restart |
Chain halt → stagnation alert (stops validator2+3) | alert_logs addr='all', log Blockchain stuck, height frozen |
make scenario4 / scenario4-restart |
Total RPC outage (stops ALL nodes) → log-only alert | grep 'Error when querying latest block height' (no DB row) |
make scenario5 |
GovDAO proposal voted NO (fresh chain) | live "New Proposal" alert + render shows NO PERCENT: 100% |
make scenario-mute |
Resend dedup (keeps validator4 down) | exactly one alert_logs row/level + dedup: skipping log |
make scenario2 stops validator4, so run make scenario1 first to put it
in the valset. The detailed walkthrough below documents what make scenario1
does under the hood.
GovDAO alerts (scenario1 / scenario5): the "New Proposal" alert only fires for proposals seen live over the websocket, so the backend must already be watching
devwhen the proposal is created (reset →docker restart gnomonitoring-backend→ create proposal). On a fresh devnet the alert is delivered via the GovDAO Telegram bot only (nowebhook_gov_daosrow fordev). For scenario5, voting NO does not reject the proposal immediately — it stays "open for votes" (NO PERCENT: 100%) until its voting deadline elapses. Note that gnomonitoring currently emits no alert for a REJECTED/expired proposal and never updatesgovdaos.statustoREJECTED:CheckProposalStatusonly handles theACCEPTEDtransition (see issue #112). So scenario5's only observable is the "New Proposal" creation alert + the on-chainNO PERCENT: 100%render.
Exercises GovDAO proposal detection/alerts (internal/govdao), moniker
resolution via valopers.Render(), and the appearance of a 4th validator in
daily_participations / gnoland_chain_active_validators.
TL;DR: just run
make scenario1— it performs every step below using the live addresses from.devaddrs.mk. The manual walkthrough is kept for reference. Allg1.../gpub1...values shown below are examples: keys are regenerated on every setup, so use your own from.devaddrs.mk/gnokey list -home "$PWD/.gnokey-dev"(theMakefiledoes this automatically).
docker compose --profile phase2 up -d validator4(ormake up-validator4)- The
devgnokey account (see "Local dev accounts" above) — funded in genesis and the onlyauth.unrestricted_addrsentry. - The
v4opgnokey account — validator4's operator key;Register()requires the caller to be the registered operator address (anti-squat guard).
Both accounts live in .gnokey-dev/ with passphrase devpassword123. All
gnokey commands below use the local gnokey binary with
-home "$PWD/.gnokey-dev". The dockerized gno-gnokey:local image does not
work for these commands out of the box: a plain docker run (without
--network) can't reach localhost:26658, since the validator's RPC port is
only published on the host.
-
Initialize GovDAO (one-time on this devnet — genesis deploys the GovDAO v3 packages, and
r/gov/dao/v3/loader's automaticinit()sets up empty T1/T2/T3 tiers with 0 members, so no proposal could ever reach the 66.66% supermajority quorum until a member is seeded):InitWithUsers(addrs ...address)has nocur realmparameter (it's a "non-crossing" function), so it can't be called withmaketx call(MsgCall) — it needsmaketx run(MsgRun) with a small script. The import alias must not beinit(reserved for thefunc init()builtin);init-govdao.gno(rendered bybootstrap.shfrominit-govdao.gno.tmpl, with your livedevaddress injected) usesgovdaoinitinstead:// init-govdao.gno (rendered from init-govdao.gno.tmpl; <DEV_ADDR> is your // live `dev` address, substituted by bootstrap.sh) package main import ( govdaoinit "gno.land/r/gov/dao/v3/init" ) func main(cur realm) { govdaoinit.InitWithUsers(cross(cur), address("<DEV_ADDR>")) }
gnokey maketx run \ -gas-fee 1000000ugnot -gas-wanted 50000000 -broadcast \ -chainid dev -remote http://localhost:26658 \ -home "$PWD/.gnokey-dev" dev init-govdao.gnoThis only works because the genesis
chain_idis"dev"(InitWithUserscallsassertIsDevChain()). It resets the memberstore and makesdevthe sole GovDAO "T1" member — any proposaldevvotes YES on reaches the 66.66% supermajority alone. -
Get validator4's bech32 pubkey from its secrets:
docker run --rm --entrypoint gnoland \ -v "$PWD/validator4/gnoland-data:/gnoroot/gnoland-data" \ gno-validator:local secrets get -data-dir /gnoroot/gnoland-data/secrets validator_keyThis returns validator4's address + pubkey (example — yours will differ; they are also written to
.devaddrs.mkasVALIDATOR4_ADDR/VALIDATOR4_PUB):- address:
g132qwl9... - pub_key:
gpub1pgg...
- address:
-
Register validator4 as a valoper (pays the 20 GNOT
minFee):gnokey maketx call \ -pkgpath gno.land/r/gnops/valopers -func Register \ -args "samourai-crew-4" \ -args "Phase 2 test validator for gnomonitoring" \ -args "on-prem" \ -args "$(awk '/VALIDATOR4_OP_ADDR/{print $3}' .devaddrs.mk)" \ -args "$(awk '/VALIDATOR4_PUB/{print $3}' .devaddrs.mk)" \ -send 20000000ugnot -gas-fee 1000000ugnot -gas-wanted 50000000 -broadcast \ -chainid dev -remote http://localhost:26658 \ -home "$PWD/.gnokey-dev" v4op # Register must be signed by the operator key
(The 4th arg is the operator address
VALIDATOR4_OP_ADDR= thev4opkey, which must also be the signer; the 5th is validator4's BFT pubkey.) -
Create the GovDAO proposal to add validator4 to the valset. This chains two realm calls (
proposal.NewValidatorProposalRequest→dao.MustCreateProposal), so it needsmaketx runwith a small script:// propose-validator4.gno (rendered from propose-validator4.gno.tmpl; // <V4OP_ADDR> is your live `v4op` operator address) package main import ( "gno.land/r/gov/dao" "gno.land/r/gnops/valopers/proposal" ) func main(cur realm) { pr := proposal.NewValidatorProposalRequest(cross(cur), address("<V4OP_ADDR>")) dao.MustCreateProposal(cross(cur), pr) }
propose-validator4.gnois rendered bybootstrap.shfrompropose-validator4.gno.tmpl(with your livev4opaddress), so:gnokey maketx run \ -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast \ -chainid dev -remote http://localhost:26658 \ -home "$PWD/.gnokey-dev" dev propose-validator4.gnoThis creates proposal
#0(first proposal on this devnet, assuming GovDAO was just reinitialized in step 1). -
Vote YES (as the sole T1 member, 100% ≥ 66.66% supermajority):
gnokey maketx call \ -pkgpath gno.land/r/gov/dao -func MustVoteOnProposalSimple \ -args 0 -args YES \ -gas-fee 1000000ugnot -gas-wanted 50000000 -broadcast \ -chainid dev -remote http://localhost:26658 \ -home "$PWD/.gnokey-dev" dev -
Execute the proposal — adds validator4 to
r/sys/validators/v3:gnokey maketx call \ -pkgpath gno.land/r/gov/dao -func ExecuteProposal \ -args 0 \ -gas-fee 1000000ugnot -gas-wanted 50000000 -broadcast \ -chainid dev -remote http://localhost:26658 \ -home "$PWD/.gnokey-dev" dev -
Verify validator4 joined the valset and started signing:
gnokey query vm/qeval \ -data "gno.land/r/sys/validators/v3.IsValidator(\"$(awk '/VALIDATOR4_ADDR/{print $3}' .devaddrs.mk)\")" \ -remote http://localhost:26658 curl -s http://localhost:26661/status | jq '.result.validator_info'
- a GovDAO "New Proposal" alert fired when the proposal was created (step 4) —
internal/govdao gnoland_chain_active_validators{chain="local"}increments from 3 to 4- validator4's address (
VALIDATOR4_ADDRin.devaddrs.mk) appears indaily_participationsforchain="local" - the moniker for that address resolves to
samourai-crew-4(fromvalopers.Render()via the MonikerMap refresh)
GovDAO alert delivery — important timing. The "New Proposal" alert is only sent for proposals seen live over the GovDAO websocket (
who == "socket"inProcessProposal). A proposal created while the backend isn't live-watchingdev(e.g. right after a chain reset, before you restarted the backend) is picked up by the startup catch-up scan → inserted into thegovdaostable without an alert. So the correct order is: reset chain → restart the backend (websocket reconnects) → then create the proposal.Delivery channels: the alert goes to every
webhook_gov_daosrow (Discord/ Slack) and to the GovDAO Telegram bot (token_telegram_govdao) for all subscribed chats. On a fresh devnet there is usually nowebhook_gov_daosrow fordev, so the alert only reaches Telegram — check the GovDAO bot, not a Discord/Slack channel. Either way the proposal is tracked ingovdaos.
Exercises the WARNING → CRITICAL → RESOLVED alert flow and webhook delivery
(metric.go, alert_logs).
This targets validator4 (run make scenario1 first so it's in the valset
with voting power 1). Its power is tiny, so stopping it lowers its own
participation rate without halting the chain — the clean way to trigger a
per-validator missed-block alert. (Stopping a genesis validator with power
10/11 instead risks halting consensus; that's Scenario 3.)
-
Stop validator4:
make scenario2 # docker compose stop validator4 -
Wait until validator4 has missed ≥5 blocks → WARNING, then ≥30 → CRITICAL.
-
Restart it:
make scenario2-restart # docker compose start validator4 -
Once validator4 participates again at
end_height + 1, expect a RESOLVED alert. -
Check
alert_logsfor the WARNING/CRITICAL/RESOLVED rows:docker exec gnomonitoring-postgres psql -U gnomonitoring -d gnomonitoring \ -c "SELECT level, start_height, end_height FROM alert_logs \ WHERE chain_id='dev' AND addr='$(awk '/VALIDATOR4_ADDR/{print $3}' .devaddrs.mk)' ORDER BY id;"
Exercises gnomonitoring's behavior when the chain stops producing blocks
entirely — the global stagnation/RPC-error alert path described in
CLAUDE.md under "Known Limitations".
With voting power 10/10/11 (total 31), more than 2/3 is 21. Stopping any 2 of the 3 genesis validators leaves at most 11/31 ≈ 35%, well below the 66.67% supermajority CometBFT needs to commit blocks — the chain halts deterministically:
docker compose stop validator2 validator3 # leaves only validator (power 10/31)gnoland_chain_current_height{chain="local"}stops increasing- the global stagnation/RPC-error alert fires
- no new
daily_participationsrows are inserted for any validator while halted
make scenario3-restart # docker compose start validator2 validator3Known issue: restarting a stopped node can crash-loop with:
panic: Cannot make VoteSet for height == 0, doesn't make sense.
This happens when priv_validator_state.json (the last attempted vote) ends
up one height ahead of the node's committed blockstore/state height. bootstrap.sh
now resets that state file on every run (so a fresh full-reinit no longer
hits it), but if a running chain gets stuck and scenario3-restart doesn't
recover it within a minute or two, do a reset:
make reinit # wipe chain state, keep identities, restart
# or, if it's still wedged:
make clean-all && make full-reinitThis wipes chain state while keeping (reinit) or regenerating (clean-all) the node identities. Acceptable for a devnet — there is no real data to preserve.