DEV Community

Daniel
Daniel

Posted on

How Canonical Governance Reads Made WEMIX SPoA Reorg Validation Branch-Dependent

Consensus validation must be deterministic.

The same candidate header, evaluated against the same candidate parent and chain rules, should not switch between valid and invalid merely because a node currently considers another branch canonical.

WEMIX SPoA violated that invariant.

Its validator-authorization path resolved governance through a global ethclient.Client using only BlockNumber.

During side-branch or reorg validation, that number-scoped query returned governance from the node’s local canonical branch. It did not resolve governance from the candidate header’s own parent hash.

The proof validated the same candidate twice:

Same candidate header

Same candidate hash

Same parent hash

Same state root

Same coinbase

Same MinerNodeSig
Enter fullscreen mode Exit fullscreen mode

Only the canonical governance response for block 100 changed.

The result changed with it:

Canonical snapshot A
Validator A authorized
Candidate signed by validator B
Candidate rejected

Canonical snapshot B
Validator B authorized
Same candidate accepted
Enter fullscreen mode Exit fullscreen mode

Nothing inside the candidate changed.

The validation result changed because the node consulted governance from whichever branch was canonical locally.

I submitted this report as Major through the WEMIX bug bounty program hosted on CertiK Skynet.

The program classified it as Low and paid a $100 bounty.

That classification does not match the demonstrated impact.

This was not an informational RPC mismatch or a cache-efficiency concern. The real WEMIX validator-signature path rejected and accepted the same candidate depending only on the governance snapshot returned by the local canonical client.

That is a consensus-determinism and reorg-safety failure.

The complete public report and proof of concept are available in the GitHub Gist.

The invariant that candidate validation must preserve

Candidate validation should be determined by:

The candidate header

The candidate parent

The candidate branch state

The chain configuration
Enter fullscreen mode Exit fullscreen mode

It must not depend on hidden mutable inputs such as:

Which competing branch is currently canonical

What a canonical RPC returns for the same height

Which branch populated a height-only cache first
Enter fullscreen mode Exit fullscreen mode

During a reorg, multiple branches can contain different governance state at the same block number.

A node validating branch B must ask:

Is this candidate authorized by branch B’s parent state?

It must not ask:

Is this candidate authorized by whichever branch I currently consider canonical?

Those questions can produce different answers.

The PoC showed that WEMIX did exactly that.

Where WEMIX lost the branch identity

The primary problem appeared in getRegGovEnvContracts:

func (
    ma *wemixAdmin
) getRegGovEnvContracts(
    ctx context.Context,
    height *big.Int,
) (
    *gov.GovContracts,
    error,
) {
    if ctx == nil {
        var cancel func()

        ctx, cancel =
            context.WithCancel(
                context.Background(),
            )

        defer cancel()
    }

    opts :=
        &bind.CallOpts{
            Context: ctx,
            BlockNumber:
                height,
        }

    return gov
        .GetGovContractsByOwner(
            opts,
            ma.cli,
            ma.bootAccount,
        )
}
Enter fullscreen mode Exit fullscreen mode

The resolver received a block number.

It did not receive:

Candidate parent hash

Candidate state root

A branch-scoped state reader
Enter fullscreen mode Exit fullscreen mode

The call used ma.cli, a global client.

A contract call through that client with BlockNumber = 100 asks for canonical state at height 100.

That behavior is normal for a canonical query.

It is unsafe when the result determines whether a side-branch validator is authorized.

Header validation did not propagate the candidate parent

The SPoA header path called:

if (
    !wemixminer.IsPoW()
        &&
    !wemixminer.VerifyBlockSig(
        header.Number,
        header.Coinbase,
        header.MinerNodeId,
        header.Root,
        header.MinerNodeSig,
        chain.Config()
            .IsPangyo(
                header.Number,
            ),
    )
) {
    return consensus
        .ErrUnauthorized
}
Enter fullscreen mode Exit fullscreen mode

The verifier received:

Header number

Coinbase

Miner node ID

State root

Validator signature

Miner-limit flag
Enter fullscreen mode Exit fullscreen mode

The candidate parent hash was absent.

Once that branch identity was discarded, downstream governance reads could not reliably select the state belonging to the candidate’s ancestry.

The verifier queried height minus one by number

Inside verifyBlockSig, WEMIX calculated:

num :=
    new(big.Int)
        .Sub(
            height,
            common.Big1,
        )

contracts, err :=
    admin
        .getRegGovEnvContracts(
            ctx,
            num,
        )
Enter fullscreen mode Exit fullscreen mode

For a candidate at height 101, the verifier requested governance at height 100.

Using the parent height is conceptually correct.

The problem was the identifier.

The code asked for:

Canonical governance at block number 100
Enter fullscreen mode Exit fullscreen mode

It needed:

Governance at the candidate parent hash at height 100
Enter fullscreen mode Exit fullscreen mode

A block number does not uniquely identify state while competing branches exist.

Validator lookup inherited the same number-scoped state

The coinbase-to-enode mapping also used:

opts :=
    &bind.CallOpts{
        Context: ctx,
        BlockNumber:
            height,
    }
Enter fullscreen mode Exit fullscreen mode

Its cache was keyed by the modified governance block number.

It was not keyed by:

Candidate parent hash

Governance state root

Branch identity
Enter fullscreen mode Exit fullscreen mode

If branches A and B both report a governance modification at block 100, a key containing only 100 cannot distinguish their validator maps.

The PoC’s direct reject-versus-accept result came from validator authorization through the real governance path. The cache design is an additional branch-scoping weakness visible in the same codebase.

Previous-miner history used canonical headers by number

Miner-limit logic included:

block, err :=
    cli.HeaderByNumber(
        ctx,
        height,
    )
Enter fullscreen mode Exit fullscreen mode

HeaderByNumber follows the canonical chain.

It does not follow the ancestry of a side-branch candidate.

The associated cache was also keyed only by height.

This can produce another mismatch:

Candidate belongs to branch B

Previous-miner history is read from branch A
Enter fullscreen mode Exit fullscreen mode

The published PoC did not separately reproduce an opposite decision through this helper. It demonstrated the validator-governance path directly.

The helper matters because the same missing branch identity appears in another consensus-relevant decision.

Block-build parameters showed the same design pattern

WEMIX also loaded governance-controlled block parameters by BlockNumber, including values related to:

Block interval

Gas limit

Maximum base fee

Base-fee change rate

Gas target percentage
Enter fullscreen mode Exit fullscreen mode

Non-PoW base-fee calculation requested those parameters using the parent number without passing the parent hash.

Again, the report’s end-to-end proof focused on validator eligibility.

The broader audit lesson is that every consensus-critical governance read must be branch-scoped, not merely height-scoped.

The proof used two governance snapshots at the same height

The PoC created two deterministic governance snapshots for block 100.

Snapshot A authorized validator A:

0x8fd379246834eac74B8419FfdA202CF8051F7A03
Enter fullscreen mode Exit fullscreen mode

Snapshot B authorized validator B:

0x88f9B82462f6C4bf4a0Fb15e5c3971559a316e7f
Enter fullscreen mode Exit fullscreen mode

The unchanged candidate was:

Candidate parent number
100

Candidate child number
101

Candidate signer
Validator B
Enter fullscreen mode Exit fullscreen mode

Validator B’s real signature was attached to the candidate.

Snapshot B recognized that signer.

Snapshot A did not.

Validation under canonical snapshot A

In the first run, the canonical governance response for block 100 was snapshot A.

WEMIX queried governance using BlockNumber.

The generated bindings returned validator A’s governance state.

The candidate was signed by validator B.

The real validation path returned:

Unauthorized block
Enter fullscreen mode Exit fullscreen mode

The candidate was rejected against governance that did not belong to its intended branch snapshot.

Validation under canonical snapshot B

The PoC then changed only the canonical governance response.

It did not modify:

Candidate header

Candidate hash

Candidate parent hash

Candidate root

Candidate coinbase

Candidate MinerNodeSig
Enter fullscreen mode Exit fullscreen mode

The same BlockNumber = 100 query now returned snapshot B.

Snapshot B authorized validator B.

The same candidate was accepted.

The proof therefore produced:

Same candidate

Canonical governance A
REJECTED

Canonical governance B
ACCEPTED
Enter fullscreen mode Exit fullscreen mode

That is branch-dependent consensus validation.

The exact observed result

The PoC reported:

Candidate parent number
100

Candidate child number
101

Candidate signer
Validator B

Candidate header changed
NO

Candidate parent hash changed
NO

Candidate root changed
NO

Candidate coinbase changed
NO

Candidate MinerNodeSig changed
NO

Governance read used BlockNumber
YES

Governance read used candidate parent hash
NO

Validation under canonical A
REJECTED

Validation under canonical B
ACCEPTED

Direct balance movement demonstrated
NO

Consensus branch determinism affected
YES
Enter fullscreen mode Exit fullscreen mode

The absence of direct balance movement is important.

The report did not turn a reorg-safety flaw into an unsupported theft claim.

Its impact was consensus determinism and branch validation.

Why the in-process RPC did not decide the result

The PoC used an in-process RPC boundary to return deterministic ABI-encoded governance contract responses.

That boundary did not return:

Accept

Reject

Valid block

Invalid block
Enter fullscreen mode Exit fullscreen mode

The decision came from the real code path:

Ethash.VerifyHeader

wemixminer.VerifyBlockSig

wemix.verifyBlockSig

wemixAdmin.getRegGovEnvContracts

Generated governance bindings

coinbaseExists
Enter fullscreen mode Exit fullscreen mode

The proof also used:

Real crypto.Sign

Real crypto.Ecrecover
Enter fullscreen mode Exit fullscreen mode

An invalid-signature control under snapshot B was rejected.

That control confirms the verifier was not configured to accept every candidate.

The unchanged candidate passed only when the number-scoped governance response authorized its real signer.

The test command was:

go test ./wemix \
    -run TestCanon \
    -count=1 \
    -v
Enter fullscreen mode Exit fullscreen mode

The test passed through the real validation path.

The reorg failure scenario

The practical sequence is:

1. Branch A is canonical locally at height N

2. Branch B is a competing branch at height N

3. Governance differs between A and B

4. Candidate B at height N plus one is signed by a validator authorized on branch B

5. A node validates candidate B while A remains canonical locally

6. WEMIX requests governance using BlockNumber N

7. The global client returns branch A governance

8. Candidate B is rejected against branch A’s validator set

9. The same candidate is evaluated after branch B becomes canonical locally

10. The same number-scoped query now returns branch B governance

11. The candidate is accepted
Enter fullscreen mode Exit fullscreen mode

This creates a circular dependency:

A branch must be validated before it can replace the canonical branch

But its candidate may be accepted only after local canonical governance already matches it
Enter fullscreen mode Exit fullscreen mode

The PoC demonstrates the validation primitive behind that failure.

It does not claim that a completed live-network reorg attack was executed.

What the proof establishes

The proof demonstrates:

Governance is read by block number

The candidate parent hash is not used for that governance read

The candidate remains byte-for-byte unchanged between validations

The real validator-signature path rejects it under snapshot A

The same path accepts it under snapshot B

An invalid signature remains rejected

The boundary RPC does not inject the final decision
Enter fullscreen mode Exit fullscreen mode

The proof does not demonstrate:

Direct fund loss

Unauthorized token minting

A permanent chain split

A permanent network shutdown

A completed live-network reorg attack

Permanent rejection of every side branch
Enter fullscreen mode Exit fullscreen mode

Standalone exploitation requires a competing-branch or reorg condition in which governance differs at the same height.

Those limitations are why the report was submitted as Major rather than Critical.

Why this is Major, not Low

A Low classification may fit a local cache inefficiency, an informational RPC discrepancy, or a behavior that cannot change a consensus decision.

This report demonstrated the opposite.

The same candidate received opposite validation results

The candidate header, hash, parent, root, coinbase, and signature remained unchanged.

Only the canonical governance response changed.

One validation rejected the candidate.

The other accepted it.

Validator eligibility came from the wrong branch

Validator authorization is part of the SPoA consensus rule.

A candidate signed by a validator authorized in its branch snapshot can be rejected because the node consulted governance from another branch at the same number.

That is not peripheral application behavior.

The flaw affects reorg safety

Side-branch validation is precisely where number-only canonical reads become unsafe.

A reorg-aware validator must follow candidate ancestry.

A canonical query can obstruct validation of the branch being considered.

The production validation path made the decision

The proof did not stop at code inspection.

It exercised the real header entrypoint, WEMIX signature verifier, governance resolver, generated bindings, and cryptographic signing and recovery.

Local canonical state became a hidden consensus input

Two nodes with different canonical governance views could evaluate the same candidate differently.

The same node could also produce opposite results before and after its canonical view changed.

Consensus decisions should not depend on that undeclared input.

Major is proportionate

The proof did not show permanent consensus failure, direct theft, or total network shutdown.

Critical would overstate the evidence.

Low understates a branch-dependent validator-authorization decision in the production consensus path.

Major matches the demonstrated transient consensus and reorg-safety impact.

Why the $100 outcome understates the evidence

The final Low classification produced a $100 bounty.

That records the program decision.

It does not change the demonstrated state transition:

Same candidate
Same candidate hash
Same parent hash
Same signature

Canonical snapshot A
Unauthorized block

Canonical snapshot B
Accepted
Enter fullscreen mode Exit fullscreen mode

The strongest proven effect was consensus validation using governance from the wrong branch.

That is a Major protocol weakness, not a minor implementation detail.

Why I describe the WEMIX handling as systematic downgrading

Severity disagreements are normal in bug bounty programs.

My concern is the recurring outcome across my own valid WEMIX submissions.

Reports supported by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.

This report was submitted as Major.

The proof showed that the same candidate was rejected and accepted depending only on the local canonical governance snapshot.

It was classified as Low.

I cannot infer intent from that decision.

I can document the repeated result and compare the final classification with the technical evidence.

Calling it systematic downgrading describes that recurring pattern across my WEMIX submissions without claiming a motive.

A technically convincing Low justification would need to explain why these effects are minor:

Validator eligibility read from the wrong branch

The same candidate producing opposite validation results

Candidate parent hash absent from governance reads

Height-only caches mixing branch-specific state

Reorg validation depending on the local canonical branch

The real consensus path making the final decision
Enter fullscreen mode Exit fullscreen mode

Without addressing those facts, the Low classification does not match the proof.

The correct fix

Every consensus-critical governance read must be scoped to the candidate branch.

The primary validator path should propagate the candidate parent hash through:

verifyHeader

VerifyBlockSig

verifyBlockSig

Governance contract resolution

Validator lookup
Enter fullscreen mode Exit fullscreen mode

Governance contract calls should execute against the candidate parent hash or against a state reader derived from that parent.

A global canonical call using only BlockNumber must not decide validator eligibility for a side-branch candidate.

Branch identity must also reach caches and history

Cache keys based only on height or governance modification number are insufficient.

A branch-safe key should include:

Candidate parent hash

Governance state root

Parent hash plus governance version
Enter fullscreen mode Exit fullscreen mode

Previous-miner history must follow the candidate ancestry through parent hashes rather than canonical HeaderByNumber calls.

The same rule applies to other consensus-controlled parameters read from governance.

The migration should be fork-safe

Changing consensus-state resolution can affect historical behavior.

The reference correction therefore places the branch-scoped path behind a future fork height when compatibility requires it.

Before activation:

Historical blocks retain the legacy number-scoped rule
Enter fullscreen mode Exit fullscreen mode

After activation:

Candidate validation resolves governance from candidate parent state
Enter fullscreen mode Exit fullscreen mode

Historical blocks and known reorg cases should be tested before activation.

Regression tests

A complete correction should verify:

  1. The same candidate always returns the same result for the same candidate parent state

  2. Governance is resolved by candidate parent hash rather than canonical block number

  3. A branch B validator is accepted when branch B parent governance authorizes it

  4. The validator is rejected when the candidate’s own parent governance does not authorize it

  5. Changing the local canonical head does not change validation of an unchanged candidate

  6. Invalid signatures remain rejected

  7. Validator caches cannot collide across branches at the same height

  8. Previous-miner lookup follows candidate ancestry

  9. Governance-controlled block parameters are branch-scoped

  10. Historical pre-fork validation remains compatible

  11. Reorg validation works when governance differs across competing branches

  12. No consensus-critical helper silently falls back to canonical HeaderByNumber

The central invariant is:

Candidate validation must depend on the candidate branch, not on whichever branch is canonical locally.

Broader lessons

A block number does not uniquely identify state during a reorg

Multiple branches can contain different state at the same height.

Consensus code needs a block hash or an explicit branch-state reference.

Canonical RPCs are unsafe inside side-branch validation

A global client naturally answers canonical questions.

Validating a competing branch is not a canonical query.

Cache keys are part of consensus correctness

A cache keyed only by height can merge state from different branches.

Caching can preserve a branch-scoping error long after the original lookup.

Smart-contract governance can become consensus state

When contract calls determine validator eligibility, miner limits, gas rules, rewards, or block parameters, those calls are no longer ordinary application reads.

They must follow consensus ancestry exactly.

Determinism requires eliminating hidden inputs

The local canonical head was an undeclared input to candidate validation.

The PoC made that dependency observable.

Conclusion

WEMIX SPoA resolved candidate governance through a global client using only BlockNumber.

For a candidate at height 101, it read canonical governance at height 100 instead of resolving governance from the candidate parent hash.

The proof kept the candidate unchanged:

Same header

Same hash

Same parent hash

Same root

Same coinbase

Same MinerNodeSig
Enter fullscreen mode Exit fullscreen mode

Under canonical snapshot A, the candidate was rejected.

Under canonical snapshot B, the same candidate was accepted.

The program classified the report as Low and paid $100.

The demonstrated impact was Major.

The report did not claim direct theft, permanent chain divergence, or a completed live-network reorg attack.

It demonstrated a narrower but fundamental failure:

The real validation path changed its answer because local canonical governance changed, even though the candidate did not.

A consensus validation result that changes without the candidate or its parent changing is not a Low-severity issue.

Top comments (0)