# v2.5.0 Release Note ## Summary v2.5.0 is a **required** release for both networks. It introduces the **Zanzibar** hardfork, which activates IIP-59 on-chain voter reward distribution. | Network | Zanzibar | Beta | Gamma | Approx. date | |---|---|---|---|---| | TestNet | 46880641 | 47141281 | 47468161 | activated 2026-08-21 / 08-29 / 09-07 | | MainNet | **53533081** | **53533081** | **53533081** | estimated 2026-10-19 02:00 UTC | **MainNet takes all three at one block.** Beta and Gamma exist to carry corrections to behaviour their predecessor turned on, and splitting them is only forced on a chain that has already committed blocks under the pre-correction behaviour — which is why TestNet had to run Beta 260,640 blocks after Zanzibar. A chain activating them together skips that cost and gets every correction from the first block IIP-59 is live. > **MainNet activation was rescheduled.** Block 53533081 replaces the previous > block 53155801 schedule, moving the estimated activation date from October 8 > to October 19 while keeping the activation on the established epoch grid. > **MainNet operators: `genesis_mainnet.yaml` does not change.** MainNet carries its fork heights and the two IIP-59 contract addresses in the binary, not in the genesis file, so there is nothing to re-download. Pull `v2.5.0`, restart. `config_mainnet.yaml` is unchanged too. This is the opposite of the TestNet rollout, where the genesis had to be replaced and the ordering mattered. > **TestNet operators: the genesis file *did* change in `rc1`.** Re-download `genesis_testnet.yaml`; keeping the old one forks your node at 47141281. Upgrade the binary **first** — a `v2.5.0-rc0` node cannot parse the new file, rejecting the two keys it does not know (`zanzibarBetaHeight` and `account.testnetGrants`) and refusing to start. ### What MainNet delegates should do before 53533081 At that block `migrateHermesRewardOptIn` runs **once and never again**. It auto-opts-in every candidate whose reward address is one of the two Hermes vaults — but only if that candidate's DelegateProfile carries both reward-portion fields. Measured against the live candidate set at block 52,004,121: | | count | at activation | |---|---|---| | Hermes vault + complete portions | 88 | migrated onto on-chain distribution | | Hermes vault + incomplete portions | 9 | left on the Hermes path | | not a vault reward address | 26 | unaffected; opt in with `ioctl stake2 voterrewardoptin` | Being left on the Hermes path breaks nothing — rewards keep flowing the way they do today — but the delegate does not get on-chain distribution, and there is no second chance at the migration. **If you distribute through Hermes, check that your DelegateProfile has both `blockRewardPortion` and `epochRewardPortion` set before the fork.** ## Hardfork: Zanzibar | Network | Height | Approx. date | |---------|--------|--------------| | TestNet | 46880641 | 2026-08-21 ~02:00 UTC | | MainNet | 53533081 | estimated 2026-10-19 02:00 UTC | Zanzibar activates six changes at once: 1. **IIP-59 on-chain voter reward distribution** — voter rewards are computed and credited by the protocol at era boundaries instead of by the off-chain Hermes service. 2. **BLS proof-of-possession at candidate register/update** — closes a rogue-key aggregate-forgery window ahead of IIP-52's signature aggregation. 3. **In-contract transfer log topic fix.** 4. **`GetCommittedState` prestate fix** — storage slots absent from the pre-transaction trie no longer report a post-mutation value, correcting EIP-2200 SSTORE gas accounting. 5. **Blacklist removal** — 13 of the 29 blacklisted accounts stop being treated as blacklisted. No effect on TestNet, whose `config_testnet.yaml` sets an empty `blackList`. 6. **Candidate BLS key becomes optional** — `candidateRegister` and `candidateUpdate` no longer require `blsPubKey`. ### New genesis fields `genesis_testnet.yaml` gains three entries: ```yaml blockchain: zanzibarHeight: 46880641 autoDepositContractAddress: io1pvlpc02xft2va4f38ae2nvgqglcxtfytez75c4 poll: delegateProfileContractAddress: io19l8qpk08rw0jr4vwguyufva9w4t6aq75q2kt90 ``` Both contracts were deployed on TestNet for this release by replaying the MainNet creation transactions, so each runtime is byte-identical to MainNet's — which matters for AutoDepositRegister, whose storage slots IIP-59 reads directly — while the constructors still ran, leaving the owner set and pause/unpause usable. | | address | |---|---| | AutoDepositRegister | `io1pvlpc02xft2va4f38ae2nvgqglcxtfytez75c4` / `0x0b3e1C3d464AD4CED5313f72A9b10047f065A48B` | | DelegateProfile | `io19l8qpk08rw0jr4vwguyufva9w4t6aq75q2kt90` / `0x2fcE00d9E71B9f21D58e4709c4B3A57557ae83D4` | `autoDepositContractAddress` is where IIP-59 reads per-voter compound preferences from; it is the same contract iotex-hub writes registrations to, so a preference set through the hub is the one the protocol acts on. `delegateProfileContractAddress` is where it reads each delegate's voter-take portions when it freezes the era snapshot; its three reward-portion fields are registered against the existing PermyriadVerifier. Two IIP-59 parameters are left at their built-in defaults and do not appear in the YAML: `epochsPerRewardEra` (24) and `voterBudgetPerBlock` (256). ## Hardfork: Zanzibar Beta | Network | Height | Approx. date | |---------|--------|--------------| | TestNet | 47141281 | 2026-08-28 ~22:00 UTC | | MainNet | 53533081 | estimated 2026-10-19 02:00 UTC — same block as Zanzibar | Zanzibar Beta exists because Zanzibar is already live on TestNet. Three corrections to behaviour Zanzibar activated cannot simply be folded into Zanzibar's own height — that would rewrite the semantics of blocks TestNet has already committed — so they get a height of their own. 1. **Fail closed on non-settleable epoch-settlement faults.** An epoch reward grant that fails for a reason not every node derives from committed state was still settled as a Failure receipt, so one validator could commit "this epoch paid nobody" while the rest committed the full grant — two receipt roots for one block. Only verdicts classified as settleable now keep the receipt; anything else surfaces as an error. The same gate makes the IIP-59 auto-deposit bucket lookup fail closed rather than silently routing a voter's reward to the wrong destination. 2. **Emit era-freeze logs.** The per-candidate poll snapshot taken at an era freeze now emits reward logs, so indexers can observe the frozen set directly instead of inferring it from the payouts that follow. 3. **Require a DelegateProfile entry for the Hermes opt-in migration.** > **Correction 3 is inert on TestNet.** The Hermes opt-in migration runs in > exactly one block — the one at Zanzibar's height — and never again. TestNet > passed that block on 2026-08-21, so the migration has already run unguarded > and no later height brings the guard back. It is listed here for completeness > and for chains that have not yet activated Zanzibar, which should set > `zanzibarBetaHeight` **equal to** `zanzibarHeight` and get all three. ### TestNet balance grant `rc1` also adds a scheduled balance credit, applied at the Zanzibar Beta height to keep TestNet faucets funded: ```yaml account: testnetGrants: - height: 47141281 recipients: - address: io1ycl9fzdve2l5yqwxywrtpvz2wtwlzfc349r7fc amount: "1000000000000000000000000000" ``` The grant **adds to** the recipient's existing balance rather than overwriting it, and is applied through the state manager like any other credit, so it is part of consensus: a node whose genesis omits it will diverge at 47141281. This is a TestNet-only mechanism — `genesis_mainnet.yaml` carries no `testnetGrants` and the field is validated to be empty on any chain that is not TestNet. ## Hardfork: Zanzibar Gamma | Network | Height | Approx. date | |---------|--------|--------------| | TestNet | 47468161 | activated 2026-09-07 10:00 UTC | | MainNet | 53533081 | estimated 2026-10-19 02:00 UTC — same block as Zanzibar | Gamma carries four further corrections, gated together: 1. **`ValidateHeaderGasUsed`** — the block header's `gasUsed` is validated rather than trusted. 2. **`CorrectStakeMigrationGas`** — corrects the gas accounted to a stake migration. 3. **`CheckedBlockGasDeduction`** — the block gas deduction is checked. 4. **`RevertStakingStateOnFailedReceipt`** — staking state written before a failure is reverted rather than left behind. The same equal-height rule applies as for Beta, and there is a concrete cost to letting Gamma trail. `EnforceBLSPoP` rides Zanzibar, and candidate register writes its self-stake bucket *before* it verifies the proof — so on a chain where Gamma trails Zanzibar, a rejected proof leaves that bucket behind until Gamma activates. MainNet avoids that window entirely by taking both at 53533081. > **TestNet activated Gamma separately.** It had already activated Zanzibar and Beta on builds that predated Gamma, so `zanzibarGammaHeight` was set to 47468161 and the four corrections activated on 2026-09-07. ## Changes ### Feature - **IIP-59 on-chain voter reward distribution** (#4953) — moves voter reward computation and payout into the protocol. Rewards accrue per delegate and are drained at era boundaries (24 epochs), chunked across blocks with a per-block voter budget so a single block never carries the whole payout. Adds the `SetVoterRewardOptIn` staking action, per-candidate poll snapshots frozen at the era freeze height, an owner-index over contract-staking buckets, and a `DelegateVoterRewardsDistributed` event for off-chain indexers. - **Export the distributed-log decoding API** (#4968) — exposes `Unpack`, `ABI`, and `Topic0` from the `distributedlog` package so indexers can decode `DelegateVoterRewardsDistributed` logs without vendoring the ABI. - **`-stop-at-height` to cap startup replay** — bounds indexer catch-up at startup, which makes it practical to bring a node up at a specific height for debugging. - **Dump the state write queue on a delta-state digest mismatch** — turns an opaque digest mismatch into an actionable diff. ### Security - **BLS proof-of-possession at candidate register/update** (#4854) — the staking handler previously validated `blsPubKey` only for format and subgroup membership. Without a possession proof, IIP-52's planned `FastAggregateVerify` path is open to a rogue-key attack: a candidate could register `pk_rogue = g^x − Σ(other pubkeys)` and, once aggregation goes live, forge a 2/3+ quorum certificate with a single signature. Requiring the proof **before** aggregation activates closes the window in which un-attested pubkeys could be collected. Adds `ioctl account blssignpop` and threads the proof through `stake2register` / `stake2update`. - **Serialize tracer `Stop` against `GetResult`** — the `debug_trace*` timeout watchdog called `tracer.Stop` from its own goroutine while the tracing goroutine called `GetResult`, with no synchronisation on the interruption reason. Fixed on MainNet-affecting code paths as well; no fork gate. ### Fix - **`GetCommittedState` must not return a post-mutation value for prestate-absent keys** (#4869) — the contract-level committed-state cache was populated with post-mutation values for storage slots that did not exist in the pre-transaction trie, so EIP-2200 SSTORE dynamic gas misclassified dirty in-place writes as `SSTORE_RESET` and overcharged 2900 instead of 100 gas per hit. Gated on Zanzibar. - **Restore fund-before-sentinel write order in `GrantEpochReward`.** - **Rename the voter reward distribution event** (#4969). - **Candidate BLS public key is optional from Zanzibar** — Xingu made `blsPubKey` mandatory on candidate register and update. Nothing consumes it until IIP-52 signature aggregation activates, so it is optional again. A registration that supplies a key keeps using the value-carrying `candidateRegisterWithBLSAndPoP` ABI; one that omits it goes back through the legacy `candidateRegister` entry, whose amount travels in the ABI parameter rather than `msg.value`. An update that omits the key leaves any previously registered key untouched. Supplying a key without its proof-of-possession is still rejected — by the handler, as `ErrUnauthorizedOperator` on the receipt. - **Correct the SELFDESTRUCT transaction-log amount** (#4910) — `generateSelfDestructTransferLog` stored the mutable `lastAddBalanceAmount` pointer directly into the `IN_CONTRACT_TRANSFER` log. That pointer is rewritten in place by the gas-deposit refund performed right after the EVM returns, so the recorded self-destruct log was retroactively overwritten with the refund amount. Balances, receipts and state roots are unaffected — transaction logs are not part of any root — but anything reconstructing balances from transaction logs (indexers, wallets, exchanges) was misled. Also ships `txlogpatch --correct-amount` to repair already-recorded logs. - **Evict the sender when an action panics during minting** (#4921) — a panic in a single pending action unwound past the sender-eviction logic and was only caught by the mint goroutine's top-level recover, so the whole draft was discarded without removing the offending action from the pool and the same action was picked again on every subsequent attempt. Now recovered around the single-action call and routed through normal error handling, turning a run of failed drafts into one lost draft. - **Populate block-level `gasUsed` from fetched receipts** (#4937) — `eth_getBlockByNumber` and friends returned `0x0` for historical and archive reads, because the block loaded from the DAO carries no receipts and the aggregate fell back to that empty list. - **Retire a drain whose copy-on-write window was superseded** — a drain frozen against a window that a later freeze replaced can never finish, but the cursor was left merely incomplete, so a chunk grant was emitted on every subsequent block and failed identically until the next era boundary. The cursor now reaches a terminal `abandoned` state and emits one `DRAIN_ABANDONED` reward log, so an indexer can tell "gave up" from "still retrying" instead of seeing an unbounded run of failure receipts. - **Activate the actpool blacklist removal at Zanzibar** (#4861) — the removal list added in #4861 shipped with its height defaulting to "never", so it has not been in effect. It is now driven by the genesis fork height rather than a node-config field: the blacklist predicate is wired into the execution protocol and consulted by the EIP-7702 authorization check, so an operator-settable height was a way for two nodes to validate the same block differently. `blackListRemovalHeight` is no longer read from `config.yaml`. ### ioctl - `ioctl stake2 voterrewardoptin` — opt a candidate into on-chain voter reward distribution. - `ioctl action voterrewarddestination [ADDR] [--reset]` — route a voter's rewards to another address. - `ioctl node reward pending|delegates|distribution|snapshot|payout|destination` — read the IIP-59 state. `snapshot` and `payout` call out the two configurations that silently pay nothing. - `ioctl stake2 register --no-bls` — register a candidate without a BLS key, now that the key is optional. ### Dependencies - `iotex-proto` moves to v0.6.12, which carries the `blsPop` field on `CandidateBasicInfo`. ## Upgrade ### MainNet A binary roll. Nothing else changes — the fork heights and both IIP-59 contract addresses live in the binary, so `genesis_mainnet.yaml` and `config_mainnet.yaml` are both untouched. ```bash docker pull iotex/iotex-core:v2.5.0 # restart your node against the new image ``` Or non-interactively: ```bash bash setup_fullnode.sh --auto --home=$IOTEX_HOME --version=v2.5.0 ``` Confirm the node reports `v2.5.0` before block 53533081. There is no genesis file to verify, which removes the ordering trap the TestNet rollout had. ### TestNet The TestNet rollout runs the **`v2.5.0-rc2`** pre-release. Nodes on `rc1` must upgrade before using the current genesis because `rc1` does not know about Zanzibar Gamma. ```bash export IOTEX_HOME=$HOME/iotex-var curl https://raw.githubusercontent.com/iotexproject/iotex-bootstrap/v2.5.0-rc2/config_testnet.yaml > $IOTEX_HOME/etc/config.yaml curl https://raw.githubusercontent.com/iotexproject/iotex-bootstrap/v2.5.0-rc2/genesis_testnet.yaml > $IOTEX_HOME/etc/genesis.yaml docker pull iotex/iotex-core:v2.5.0-rc2 ``` Then restart your node against the `iotex/iotex-core:v2.5.0-rc2` image. Or non-interactively: ```bash bash setup_fullnode.sh --auto --home=$IOTEX_HOME --version=v2.5.0-rc2 ``` Pull the image and restart **before** replacing the genesis: `rc1` cannot parse a genesis containing `zanzibarGammaHeight`, and will fail to start if it is swapped in first with: ``` failed to unmarshal yaml genesis to struct: yaml: unmarshal errors: field zanzibarGammaHeight not found in type genesis.Blockchain ``` Confirm the node picked up the current genesis. Check that it contains `zanzibarGammaHeight: 47468161` together with `zanzibarBetaHeight: 47141281` and the `account.testnetGrants` entry above.