Service discovery and round authentication for shielded voting infrastructure. This repo is published through GitHub Pages so wallets can find vote servers, PIR endpoints, and authenticated round metadata without running their own chain node.
This repo serves environment-scoped configuration documents:
| URL | Schema | Status |
|---|---|---|
voting-config.json |
v1, unsigned | Frozen for backwards compatibility with installed wallets that predate the v2 schema. |
dynamic-voting-config.json |
Production dynamic config, per-round signed registry | Active. Production wallet releases consume this through their hash-pinned production static config. |
static-voting-config.json |
Production static config | Active. Production wallet releases hash-pin this file's bytes; any change requires a coordinated wallet release. See Hash-pinning and wallet releases. |
prod/dynamic-voting-config.json |
Production dynamic config, per-round signed registry | Active compatibility path for clients and tools pinned to the prod/ layout. Keep this in sync with the root production dynamic config while both paths are supported. |
prod/static-voting-config.json |
Production static config | Active compatibility path for clients and tools pinned to the prod/ layout. Its dynamic_config_url points at prod/dynamic-voting-config.json. |
stage/dynamic-voting-config.json |
Staging dynamic config, per-round signed registry | Active for staging wallets and test workflows. |
stage/static-voting-config.json |
Staging static config | Active for staging wallets and test workflows. |
The dynamic and static schemas implement draft ZIP 1244 "Shielded Voting Wallet API". The v1 file has no chain of trust; the dynamic config signs each round's election authority public key with an Ed25519 admin key whose public counterpart is fetched through the wallet's hash-pinned static config.
{
"config_version": 1,
"vote_servers": [
{ "url": "https://vote-chain-primary.valargroup.org", "label": "valarg-genesis" }
],
"pir_endpoints": [
{ "url": "https://pir.valargroup.org", "label": "PIR primary" }
],
"supported_versions": {
"pir": ["v0"],
"vote_protocol": "v0",
"tally": "v0",
"vote_server": "v1"
},
"rounds": {
"<lowercase hex round id, 64 chars>": {
"auth_version": 1,
"ea_pk": "<base64, 32 bytes>",
"signatures": [
{ "key_id": "valar-2026-q2", "alg": "ed25519", "sig": "<base64, 64 bytes>" }
]
}
}
}vote_servers, pir_endpoints, and supported_versions are wrapper metadata and are not signed in v1. The signature scope is each round entry's ea_pk; for auth_version: 1, the signed bytes are exactly the raw 32-byte ea_pk. Round identity, title, description, and proposals live on chain.
Wallet releases embed a hash-pinned URL to the published static configuration object in the signed application binary. The published file has this shape:
{
"static_config_version": 1,
"dynamic_config_url": "https://raw.githubusercontent.com/valargroup/token-holder-voting-config/main/dynamic-voting-config.json",
"trusted_keys": [
{
"key_id": "valar-2026-q2",
"alg": "ed25519",
"pubkey": "<base64, 32 bytes>"
}
]
}trusted_keys lists the admin Ed25519 public keys that may authenticate
round entries in the matching environment's dynamic-voting-config.json.
Production config lives at the repository root. The prod/ directory is a
production compatibility path for clients and tools that already pin
prod/static-voting-config.json; keep the root and prod/ production
dynamic configs in sync while both paths are active. Staging config lives under
stage/.
Wallets bind to a specific byte-for-byte copy by embedding a
cosmovisor-style URL?checksum=sha256:HEX pin in the signed wallet
binary. Replace the current development key before shipping a production
wallet release.
For testing alternative config URLs, we publish duplicate static configs under
test/. The production duplicate lives at
https://voting.valargroup.org/test/prod-static-voting-config-duplicate.json,
and the staging duplicate lives at
https://voting.valargroup.org/test/static-voting-config-duplicate.json.
Each file is intentionally a byte-for-byte duplicate of its matching
environment static config. The separate "Deploy duplicate static configs"
workflow verifies each duplicate against the matching dynamic config and
publishes .sha256 files beside them.
Wallets carry a hash-pinned URL string in their signed application binary
(URL?checksum=sha256:HEX). On boot, the wallet fetches the URL, verifies
the response's SHA-256 matches the embedded hash, and only then trusts the
file's dynamic_config_url and trusted_keys. The dynamic config is
fetched and validated as before; each round is authenticated by checking:
- The round exists in
rounds. - The round entry uses a supported
auth_version. - At least one Ed25519 signature verifies against a bundled trusted key.
- The signed
ea_pkmatches theea_pkreturned by the chain for that round.
A signature failure or ea_pk mismatch is scoped to that round. Other authenticated rounds remain usable, and an on-chain round missing from rounds should be surfaced as unauthenticated.
Any byte-level change to an environment's static-voting-config.json
invalidates every wallet binary pinned to that file. Coordinate every
production static-config change with a wallet release: first merge and deploy
this repo, then copy the new production pin string into the wallet release
branch.
Compute a local pin with:
HASH=$(sha256sum static-voting-config.json | awk '{print $1}')
echo "https://voting.valargroup.org/static-voting-config.json?checksum=sha256:${HASH}"
PROD_HASH=$(sha256sum prod/static-voting-config.json | awk '{print $1}')
echo "https://voting.valargroup.org/prod/static-voting-config.json?checksum=sha256:${PROD_HASH}"The deploy workflow also writes the canonical pin string to the GitHub
Actions step summary and publishes
static-voting-config.json.sha256 files beside the root production, prod/
production, and staging config for human verification. Wallets must trust the
hash embedded in their signed binary, not the sidecar file.
Bringing a vote server or PIR operator into rotation does not require a chain deploy or wallet release:
- Operator joins the chain, using
vote-sdktooling. - Operator opens a PR adding their entry to the environment's
dynamic-voting-config.json(root andprod/for production while both paths are active,stage/for staging). They may also updatevoting-config.jsonif they need visibility to v1 wallets. - CI verifies every dynamic-config round signature against the matching environment's
static-voting-config.jsontrusted_keys. - Maintainer reviews and merges.
- The deploy workflow verifies the config again before publishing GitHub Pages.
Removing an operator or changing an operator URL follows the same PR flow.
A new round entry requires the vote manager to sign the round's public ea_pk
with an admin Ed25519 key. For production, that Ed25519 key is derived from
the vote manager's Keplr account so there is no separate long-lived admin seed
to copy between machines.
Preferred path:
- Create the round on chain.
- Open the
vote-sdkadmin UI and use the "Sign config entry" page. - Pick the round. The UI requests canonical payload bytes and a transparency hash from
/api/sign-config-entry. - Connect the vote manager's Keplr wallet, click Derive signing key, and
confirm Keplr's fixed-purpose
signArbitraryprompt. - Sign the round with the derived key and paste the generated JSON into
the matching environment's
dynamic-voting-config.json#/rounds/<round_id>. - Include the displayed
signed_payload_hashin the PR description for reviewer cross-check.
Offline path:
curl -fsSL \
https://github.com/valargroup/vote-sdk/releases/download/v0.5.52/voting-config-linux-amd64 \
-o voting-config
echo "730173e20fdd84258516f7741ecbe9456db9ea9962483b9c3aa402a31b313ab8 voting-config" | sha256sum -c -
chmod +x voting-config
./voting-config sign \
--round-id <64-char-lowercase-hex-round-id> \
--ea-pk <base64-32-byte-ea-pk> \
--signer-id <key_id from static-voting-config.json#/trusted_keys> \
--privkey-file test/valar-test.seed.b64 \
--merge dynamic-voting-config.jsonThe CLI preserves existing signatures when merging. Do not stage placeholder ea_pk values; wait until the chain has the round id and final ea_pk.
Keplr-compatible offline recovery path:
# Produces the same Ed25519 public key as the admin UI would derive from the
# same Keplr mnemonic, chain id, bech32 prefix, and BIP44 path.
printf '%s\n' '<vote-manager-keplr-mnemonic>' | ./voting-config config-attestation-keygen \
--chain-id <shielded-vote-chain-id> \
--mnemonic-stdin \
--out ./keplr-derived.seed.b64
./voting-config sign \
--round-id <64-char-lowercase-hex-round-id> \
--ea-pk <base64-32-byte-ea-pk> \
--signer-id keplr:<derived_address_from_config-attestation-keygen> \
--privkey-file ./keplr-derived.seed.b64 \
--merge dynamic-voting-config.jsonconfig-attestation-keygen intentionally requires --chain-id; using the wrong chain id
derives a different Ed25519 key. The default path is Keplr's Cosmos account
path, m/44'/118'/0'/0/0, with sv bech32 addresses.
Each environment's static-voting-config.json lists every public key that wallets trust under its trusted_keys array. Each entry is the public side of an admin key that may sign round entries in that environment's dynamic-voting-config.json.
The current static config includes a development key, valar-test. Replace it with a vote-manager-held production key before shipping the pinned static config URL in a wallet release.
Production handover:
- Open the
vote-sdkadmin UI and connect the vote manager's Keplr wallet. - Click Derive signing key and copy the displayed trusted key entry.
- Open a PR replacing
static-voting-config.json'strusted_keyswith the derived public key and re-signing every production entry under the newkey_id. - Coordinate with the wallet team so the wallet release pins the deployed
static-voting-config.jsonhash. - Use
voting-config config-attestation-keygen --chain-id <chain> --mnemonic-stdinonly as the offline recovery path; it derives the same public key from the same Keplr mnemonic. - Remove retired keys only after a wallet release has dropped them from its bundled trust anchor.
voting-config keygen remains available for non-Keplr manager wallets or local
development, but it is no longer the recommended production handover path.
Steady-state rotation follows the same shape: derive the new Keplr-backed key, sign new or updated rounds with it, ship a wallet trust-anchor update, then remove the retired key after the wallet release.
Install the pinned verifier from vote-sdk, then verify the checked-in dynamic config against the checked-in static config:
curl -fsSL \
https://github.com/valargroup/vote-sdk/releases/download/v0.5.52/voting-config-linux-amd64 \
-o voting-config
echo "730173e20fdd84258516f7741ecbe9456db9ea9962483b9c3aa402a31b313ab8 voting-config" | sha256sum -c -
chmod +x voting-config
./voting-config verify --config dynamic-voting-config.json --static-config static-voting-config.json
./voting-config verify --config prod/dynamic-voting-config.json --static-config prod/static-voting-config.json
./voting-config verify --config stage/dynamic-voting-config.json --static-config stage/static-voting-config.json
echo "<hex from wallet binary> static-voting-config.json" | sha256sum -c -Two workflows guard the dynamic-config path:
verify-config.ymlruns on pull requests and pushes that touch the dynamic config, static config, or workflow files. It downloads the pinnedvoting-configbinary from thevote-sdkGitHub release and runsvoting-config verify.deploy-pages.ymlruns the same checks before publishing to GitHub Pages. A bad signature blocks deployment.