Heimdall v0.9.0 — Mainnet (Zurich hardfork)
Summary
Heimdall v0.9.0 is a mandatory release for all mainnet node operators. It activates the Zurich hardfork at Heimdall block 47,880,000 (estimated Thu June 25th, ~2PM UTC), and ships a large set of features, consensus hardening, and fixes.
All mainnet nodes must be running v0.9.0 before block 47,880,000. Nodes on older versions will fall out of consensus at the activation height.
Zurich hardfork (block 47,880,000)
Consensus-affecting changes, all gated by the Zurich activation height:
- Deterministic state-sync processing (
x/clerk) — event visibility moves from wall-clock-based to block-height-based. Events recorded in blockHbecome visible atH+1, with visibility heights assigned deterministically inPreBlocker. Out-of-order events at the upgrade boundary are handled explicitly, and any error on the visibility path now aborts block processing instead of being logged and skipped. New gRPC/REST queries expose record lists by height and by time. - Symmetric side-transaction caps —
PrepareProposalstops including side transactions beyond the per-block cap (50), andProcessProposaldeterministically rejects proposals that exceed it. - Wall-clock budgets for proposal and vote-extension construction —
PrepareProposaloperates under a 500 ms budget andExtendVoteunder an 800 ms budget, returning partial results instead of overrunning CometBFT timeouts (milestone proposition is skipped when the budget is exhausted). - Commit-only checkpoint signatures — checkpoint signature aggregation includes only votes flagged as
Commit. - Deterministic milestone proposition — the majority parent-hash evaluation now binds explicitly to the last end-block hash instead of iterating candidate parent hashes, guaranteeing identical results across validators.
- Producer votes restricted to the active validator set —
MsgVoteProducersrequires the voter to be in the active set, and voting-power resolution uses active-set power consistently on both sides of the majority threshold. - Bank-transfer output cap — a new ante decorator caps the aggregate number of bank-transfer outputs per transaction at 16 (
MsgMultiSendcountslen(outputs),MsgSendcounts 1), keeping per-transaction work proportional under the flat-fee model (via cosmos-sdk forkv0.2.11-polygon). MsgCheckpointaccount root hash validation — an ante decorator rejects checkpoints whoseAccountRootHashis not exactly 32 bytes.- Bor availability tolerance —
ProcessProposalandVerifyVoteExtensiontreat "block not found" responses from Bor as transient (same handling as query failures), avoiding spurious proposal rejections while a local Bor is catching up.
Features
- Full Heimdall↔Bor gRPC transport (opt-in) — every Bor-facing call now has a gRPC path, selectable per node via
bor_grpc_flaginapp.toml(withbor_grpc_urlandbor_grpc_token). Includes a startup hash-parity check across both transports and a single-round-trip batch call for milestone proposition (≈4.4× faster than the HTTP path on devnet benchmarks). HTTP remains the default; no behavior change unless enabled. Requires Borv2.8.3or newer (the full Bor gRPC server, #2194) on the connected Bor node when enabled. - Bor endpoint failover —
eth_rpc_urlandbor_grpc_urlaccept comma-separated endpoints with automatic failover, health probing, and metrics (bor_healthcheck_*). The primary endpoint is authoritative and reclaims after recovery. Failover is refused at startup on block-producing validators (safety guard). - EIP-1559 L1 transactions — bridge transactions to Ethereum use dynamic fees, configurable via
main_chain_gas_fee_cap(default 500 gwei) andmain_chain_gas_tip_cap(default 10 gwei) inapp.toml. - Bridge self-heal extensions — new recovery methods reconstruct checkpoint and state-sync data from authoritative L1/Bor sources when local records are incomplete.
tx_index-free bridge + pruning defaults — the bridge checkpoint flow no longer depends on CometBFT's tx indexer, so it works on pruned nodes; default pruning settings updated accordingly.- Targeted producer replacement —
MsgSetProducerDowntimeaccepts an optionaltarget_producer_idto designate a specific replacement producer during planned downtime (default remains round-robin). - Millisecond-precision log timestamps — Heimdall logs now match Bor's ISO-8601/ms format for cross-service correlation.
Fixes
- Producer-downtime span off-by-one and a divide-by-zero panic on empty producer sets (
x/bor). - Checkpoint transaction lookup off-by-one in the bridge after the
tx_indexremoval. - Bridge memory-safety fixes, context-aware retry sleeps, and clean shutdown of bridge goroutines.
HeimdallListenerno longer gets stuck on nodes restored from pruned snapshots.- Data race on the shared contracts-caller instance.
GetBorTxReceiptand milestone governance-parameter loading fixes.- Better gRPC client logging and a startup warning on Bor client misconfiguration;
bor_rpc_timeoutis clamped to 3 s to fit ABCI budgets. - Node home directory is no longer wiped when
/tmpis not writable. - Packaging:
postrmscript cleanup, Dockerfile and docker-compose fixes, refreshed seeds and persistent peers.
Dependencies
0xPolygon/cometbft→v0.3.8-polygon0xPolygon/cosmos-sdk→v0.2.11-polygon0xPolygon/polyproto→v0.0.8- Bor dependency rebased onto a go-ethereum
v1.17base golang.org/x/{crypto,net,sys}andquic-gobumped to clear all govulncheck advisories
Required change
In app.toml (default location ~/.heimdalld/config/app.toml, /var/lib/heimdall/config/app.toml on packaged installs), replace:
#### gas limits ####
main_chain_gas_limit = "5000000"
#### gas price ####
main_chain_max_gas_price = "400000000000"with:
#### gas price configs (EIP-1559) ####
main_chain_gas_fee_cap = "500000000000" # max fee per gas, wei (default 500 gwei)
main_chain_gas_tip_cap = "10000000000" # max priority fee per gas, wei (default 10 gwei)Apply this before restarting on the new version.
Configuration changes (app.toml)
| Field | Default | Purpose |
|---|---|---|
bor_grpc_flag |
false |
Enable gRPC transport to Bor |
bor_grpc_url |
localhost:3131 |
Bor gRPC endpoint(s), comma-separated for failover |
bor_grpc_token |
empty | Bearer token for authenticated Bor gRPC |
main_chain_gas_fee_cap |
500000000000 |
Max fee per gas for L1 txs (wei) |
main_chain_gas_tip_cap |
10000000000 |
Max priority fee for L1 txs (wei) |
eth_rpc_url |
unchanged | Now accepts comma-separated endpoints (failover) |
Enabling gRPC to Bor (optional)
gRPC is opt-in; HTTP JSON-RPC stays the default, so existing operators see no change. Enable it Bor first, then Heimdall — Bor stays HTTP-compatible so bringing the server up first is safe, and Heimdall's startup parity check needs Bor already serving gRPC. Requires Bor v2.8.3 or newer (the full gRPC server #2194, plus its [grpc] loopback-default prerequisite #2078) paired with this Heimdall release (the client side).
Step 1 — Bor (config.toml): opt into the gRPC server on an address Heimdall can reach.
[grpc]
addr = "127.0.0.1:3131" # same-host validator pair; loopback is the access control, no TLS needed
# addr = "0.0.0.0:3131" # cross-host; must pair with TLS / a firewall
token = "" # bearer token; leave empty on loopback, set it for any non-loopback bindFor authenticated (non-loopback) deployments, prefer the env var over the flag/file (the flag leaks into ps/shell history):
export BOR_GRPC_TOKEN="$(openssl rand -hex 32)"
Bor's gRPC exposes only read-only public chain data, so on a same-host loopback bind a token isn't required; it matters cross-host, on shared multi-tenant hosts, and as defense-in-depth if the bind is later widened. (Equivalent flags: --grpc.addr, --grpc.token.)
Step 2 — Heimdall (app.toml): point the client at Bor and match the token.
bor_grpc_flag = "true"
bor_grpc_url = "http://127.0.0.1:3131" # same-host
# bor_grpc_url = "https://bor.example.net:3131" # cross-host (TLS)
bor_grpc_token = "<match Bor; empty if Bor's token is empty>"Restart Heimdall; at startup it runs one HeaderByNumber over both transports and logs a warning/fatal if the hashes diverge (guards against a stale Bor that doesn't populate the full proto header). Put credentials in bor_grpc_token, never in the URL; bor_grpc_url also accepts a comma-separated list for failover.
Layouts.
- Same host / same container / same pod — loopback (
http://127.0.0.1:3131), no token. Simplest; matches the defaults. - Cross-host, or separate Docker/compose containers (different network namespaces, so not loopback) — Bor on
0.0.0.0:3131+ a token; Heimdall onhttps://<host-or-service>:3131with the matching token. A remote endpoint needs an explicithttp:///https://scheme, and Heimdall refuses to send a token over plaintext to a non-loopback peer — so a cross-host token means TLS (terminate it in front of Bor's plaintext gRPC, or keep the link on a private network/firewall).
Downgrade: set bor_grpc_flag = "false" in Heimdall and restart — Bor doesn't need changing (it keeps serving both transports).
No store migration or resync is required; the upgrade is a binary swap + restart. Pre-activation behavior is unchanged, so mixed-version operation is safe only until the activation height.
Upgrade instructions
- Stop
heimdalld. - Install
v0.9.0(packages attached, or build from source). - Review the new
app.tomlfields above (all optional; defaults preserve current behavior). - Restart and confirm the node resumes signing/syncing.
Deadline: before block 37,750,000 (estimated June 17th 2026, ~14:00–15:00 UTC — track the live height as the date approaches).
What's Changed
- helper(tx): add EIP-1559 dynamic gas pricing for L1 transactions by @kamuikatsurgi in #532
- backport main by @marcello33 in #536
- Updating seeds in the amoy packaging by @sanketsaagar in #515
- Switch to GCR by @adamdossa in #537
- deps: bump quic-go by @marcello33 in #538
- ci: load kurtosis images from gcr instead of docker hub by @leovct in #540
- Add Claude Code GitHub Workflow by @adamdossa in #541
- fix(ci): test-state-sync by @kamuikatsurgi in #542
- refactor: ci by @kamuikatsurgi in #543
- chore: ci improvements by @kamuikatsurgi in #544
- feat: add network diagnostics and state dump action by @kamuikatsurgi in #546
- x/bor: fix producer-downtime panic when dividing by zero by @marcello33 in #547
- helper, x/checkpoint: fix grpc client / add better logs / warn on bor client misconfig by @marcello33 in #549
- chore: remove pumba compatibility step from stateless e2e by @kamuikatsurgi in #550
- app, x/bor: address issue 58 by @marcello33 in #551
- fix: avoid mirror.gcr.io images by @kamuikatsurgi in #555
- Allow comments for claude reviews by @marcello33 in #554
- Adding workflow to publish the docker image on GHCR by @0xsajal in #559
- claude: port fixes from bor to solve claude CI issues by @marcello33 in #563
- fix: HeimdallListener stuck on pruned snapshot nodes by @marcello33 in #561
- bump deps to fix dependabot/govuln issues by @marcello33 in #564
- chore: add Claude Code security review rules for AI-assisted code review by @mt-polygon-technology in #557
- fix: bridge memory safety fixes by @marcello33 in #560
- fix docker-compose by @marcello33 in #565
- bridge: context-aware sleeps and deterministic test by @marcello33 in #569
- Fix for postrm scripts by @djpolygon in #571
- fix(logs): enable millisecond-precision timestamps in heimdall logs by @lucca30 in #570
- backport: main to develop by @kamuikatsurgi in #580
- Fixing mainnet seeds and persistent peers by @sanketsaagar in #581
- ci: fix kurtosis actions by @marcello33 in #588
- chore: pos workspace setup by @marcello33 in #585
- chore: bump comet by @marcello33 in #589
- pruning defaults + bridge tx_index-free refactor by @lucca30 in #587
- chore: bump cosmos by @marcello33 in #591
- Implement prepareProposalBudget by @marcello33 in #586
- ABCI layer tests by @avalkov in #539
- Deterministic state syncs by @marcello33 in #572
- bridge: improvements by @kamuikatsurgi in #575
- bridge: implement additional methods for self_heal by @marcello33 in #584
- app, helper, x/bor: full grpc implementation by @marcello33 in #576
- docs, .claude: add height-gated rollout review guidance by @pratikspatil024 in #590
- x/bor: add target producer id in producer planned downtime by @kamuikatsurgi in #567
- x/bor: fix off-by-one in producer downtime span by @kamuikatsurgi in #573
- bridge: fix checkpoint tx height (off-by-one) post tx_index removal by @lucca30 in #593
- chore: use Claude Opus 4.7 1M context in workflows by @kamuikatsurgi in #594
- ci: remove claude github actions by @adamdossa in #596
- fix: module api docs by @kamuikatsurgi in #600
- No more nuking default node home when /tmp is not writeable by @n8wb in #595
- CI/CD consolidation on heimdall-v2 by @sanketsaagar in #599
- backport: release 0.8.1 by @kamuikatsurgi in #603
- refactor: remove redundant variable declarations in for loops by @solunolab in #606
- chore(deps): bump cometbft to v0.3.8-polygon by @pratikspatil024 in #607
- bor, helper, bridge, metrics: add Bor endpoint failover by @pratikspatil024 in #605
- backmerge v0.8.2 by @marcello33 in #609
- deps: bump golang.org/x/{crypto,net,sys} to clear govulncheck advisories by @lucca30 in #597
- x/clerk: fix archive sync around ss by @marcello33 in #612
- app, helper, bridge, x/*: v0.9.0 candidate (Zurich hardfork) by @marcello33 in #610
New Contributors
- @adamdossa made their first contribution in #537
- @mt-polygon-technology made their first contribution in #557
- @n8wb made their first contribution in #595
- @solunolab made their first contribution in #606
Full Changelog: v0.8.2...v0.9.0