Skip to content

bsc: apply Parlia system-contract upgrades at fork boundaries - #24029

Open
sudeepdino008 wants to merge 21 commits into
bsc_supportfrom
moskud/bsc-contract-upgrades
Open

sudeepdino008 wants to merge 21 commits into
bsc_supportfrom
moskud/bsc-contract-upgrades

Conversation

@sudeepdino008

@sudeepdino008 sudeepdino008 commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

At some forks, BSC replaces the bytecode of its system contracts (0x1000, 0x1001, …). Without that, the state root diverges from the first fork block (e.g. Ramanujan, Chapel block 1,010,000).

  • ParliaConfig.BlockAlloc: upgrade bytecode keyed by block number or timestamp, ported from bsc-erigon core/systemcontracts.
  • bsc/chain/upgrades/chapel/<block-or-time>.json: the 19 Chapel upgrade sets, one file per fork, embedded and loaded into BlockAlloc.
  • Parlia.Initialize applies the matching set via SetCode before the block's txs, so the serial, parallel and trace paths all see the new code.

In StagesOnlyBlocks mode the Execution stage is a no-op and never advances its
progress, so Finish skipped writing HeadBlockHash (it gated on the Execution
frontier) and the block head stayed at genesis. Advance the head to the Senders
frontier in that mode instead. Lets a blocks-only pipeline commit + report a
real head (used by the BSC block-download phase before execution exists).
Sync.TargetBlock stops the block-download driver once the head reaches it
(0 = no bound).
New bsc/sync driver: an execution-module client (Astrid/driver-DB model) that
fetches headers+bodies over devp2p and persists them via execModule InsertBlocks
+ UpdateForkChoice -- no direct rawdb, no execution, no seal verification;
reorg/canonical handled inside UpdateForkChoice. Verify is structural only
(SanityCheck + HashCheck). Resumes from max(FrozenBlocks, CurrentHeader) on
restart; UpdateForkChoice every LoopBlockLimit blocks. Wired into backend's
Parlia dispatch arm (replacing the block printer), which enables StagesOnlyBlocks
for Parlia until execution lands. Verified on Chapel: forward download + resume,
retire to .seg + prune, seg integrity publishable.
Generalized fork of bor's deleted polygon/sync block downloader
(downloadBlocksUsingWaypoints): waypoints → block-number ranges (default 1024,
~1GB/worker at 1MB/block matching bor; RAM-bounded worker count scales with the
range size), generated lazily from a cursor so a full-chain sync doesn't
pre-allocate a ~123k-entry range list. Worker pool min(maxWorkers, peers,
ranges) fetches ranges concurrently across peers; per-range verifyChain (bor's
blocksVerifier plus the inter-range boundary linkage waypoints gave for free);
gap truncation rewinds the cursor to the failed range; peer-poll backoff.
Ordered batches go to the same InsertBlocks+UpdateForkChoice seam. Used for
bounded bulk (target>0); the single-peer sequential loop stays for the tip-less
fallback. Verified on Chapel: multi-peer parallel fetch (workers up to 4),
resume-on-restart, no BadBlock.
@AskAlexSharov

Copy link
Copy Markdown
Collaborator

Reviewed the upgrade commit only, not the branch it is stacked on. One bug, one design point.

A missing parent header applies every past timestamp upgrade at once, in random order. parentTime stays 0 when chain.GetHeader returns nil, and consensuschain.Reader.GetHeader returns nil for a read error as much as for a missing header — it drops the error (h, _ := cr.blockReader.Header(...)). With parentTime == 0 the guard parentTime < numOrTime && header.Time >= numOrTime is true for every timestamp key already passed: all nine of Chapel's today. Thirteen addresses appear in more than one timestamp set — 0x1000 in seven of them — so the surviving bytecode is whichever set Go's randomised map iteration reaches last. That is a wrong state root that differs between two runs of the same node, from a transient read error on one header. parentTime is required input for the timestamp branch, so a nil parent should be an error, the way bsc-erigon takes lastBlockTime as a parameter from a caller that already holds the header. Sorting the keys before applying them removes the order dependence in the tail of this, and with it the only reason two matching entries in one block could resolve either way.

BlockAlloc map[string]any pushes both key parsing and alloc decoding to execution time. A bad key or a bad alloc in the chainspec surfaces as a block-execution error mid-sync rather than at startup, and DecodeGenesisAlloc re-marshals and re-parses the bytecode on every matching block. A typed field parsed once when the config loads turns both into a startup failure.

balance is 0x0 in all 83 entries, so applying only Code matches the data.

@sudeepdino008
sudeepdino008 force-pushed the moskud/bsc-contract-upgrades branch from 54ff174 to 2c68743 Compare September 16, 2026 05:46
… fee model)

Turns on real execution for BSC/Parlia (was download-only). Blocks 1-89 execute
with state roots matching the live Chapel chain; the next divergence (block 90)
needs the BSC light-client precompiles, which are not part of this change.

Two BSC-specific rules are required for a correct state root:

- System transactions (the validator's gas-price-zero calls to system contracts)
  run as free consensus calls, outside the block gas pool: bsc/parlia adds the
  IsSystemTransaction predicate + system-contract set, and execution/exec routes
  them to executeSystemTx, which bumps the validator nonce and mirrors
  distributeToSystem/distributeToValidator's direct SystemAddress->validator move
  before forwarding the value on-chain.
- User-tx fees are credited to SystemAddress, not the coinbase (execution/protocol);
  Finalize later splits them to the SystemReward/Validator contracts.

execution/stagedsync keeps Parlia on the serial executor for now, since system-tx
handling relies on serial ordering.
The executor no longer knows BSC consensus rules. It recognizes a system tx via
the new rules.SystemTxEngine interface and provides a run closure (nonce bump +
free EVM call); Parlia.ApplySystemTx owns the state effect (the SystemAddress ->
validator reward move) and invokes run. No churn on the other engines - the
interface is optional and type-asserted.
Parlia embeds system transactions in the block body and routes the tx tip to
SystemAddress (which those system txs then read). Make the parallel executor
handle both:

- tag body system txs (chain.Rules.IsParlia + engine.IsSystemTransaction) and
  skip the block-gas-budget inclusion check for them (their gas limit is a
  sentinel).
- apply fees inline for Parlia (no fee delaying) and skip the deferred coinbase
  fee credit, so the in-block system transactions read the up-to-date
  SystemAddress balance through the version map.

Serial and parallel both execute Chapel with matching state roots.
…eipt bugs

- drop Rules.IsParlia; the deferred-fee skip keys on the task's shouldDelayFeeCalc,
  which already means "fees not delayed".
- drop asSystemTxEngine and SystemTxRun: executeSystemTx takes rules.Engine and
  does the nonce bump + call itself; the engine's ApplySystemTx only performs the
  SystemAddress -> validator reward move.
- a reverted system tx is now an invalid block (matches BSC), not a failed receipt.
- credit the blob fee to SystemAddress after Cancun (was burnt), matching BSC.
- prepare the access list for system-tx calls after Cancun so warm/cold gas matches.
…avlMerkleProofValidate)

Ports BSC's Tendermint light-client + IAVL merkle-proof precompiles so Chapel
gets past block 90 (its LightClient/CrossChain txs invoke them). Execution now
runs from genesis to ~block 12k before the next (unrelated) divergence.

- vendor core/vm/lightclient/{v1,iavl} from bsc-erigon verbatim (import paths
  rewritten only); pull the BNB tendermint fork + go-amino + ics23; exclude the
  vendored surface from lint/gofmt.
- register PrecompiledContractsIstanbulForBSC (standard Istanbul + 0x64/0x65),
  gated on chain.Rules.IsParlia (re-added; here it's the canonical precompile
  gate). Later BSC-fork precompile sets are follow-ups.
- executeSystemTx: convert a block-STM ErrDependency panic into a retriable
  abort (as the metered path does), so system txs compose with parallel exec
  past the first heavily-contended block.
…3 dep

Only the Istanbul-era precompiles (0x64 tmHeaderValidate, 0x65
iavlMerkleProofValidate) are registered. Delete the unregistered
Nano/Moran/Planck/Plato variants, their op-verifiers, and the ics23
proof op; each should be re-added in the PR that enables its fork,
setting verifier fields at build time (writing them in Run races across
parallel workers sharing one instance).

Inline basicIavlMerkleProofValidate into iavlMerkleProofValidate.Run
with a local DefaultProofRuntime so the live precompile has no mutable
receiver state. Drop github.com/bnb-chain/ics23 (the tendermint fork
does not require it) and the file-level golangci exclusion for
contracts_lightclient.go.
Parlia routes the user-tx tip (and post-Cancun blob fee) to SystemAddress, which
the in-block system tx reads every block. Crediting it inline in the parallel
workers (the prior approach) made every user tx read-modify-write SystemAddress
and raced the system tx's read, so parallel exec diverged non-deterministically
(wrong trie root at 4243/4629/12191/...).

Defer the Parlia tip through calcFees like the coinbase: shouldDelayFeeCalc is
now always true, recipient+amount are threaded as calcFees params, and the blob
fee is carried in ExecutionResult.FeeBlob. Workers no longer touch SystemAddress,
so tip credits become fresh version cells and the in-block system tx's read of
SystemAddress re-executes normally under block-STM.

Ethereum path unchanged: the recipient/amount are the coinbase/tip verbatim; the
Parlia branch only swaps in SystemAddress and adds the blob fee.

Verified: 17/17 clean fresh parallel runs to blocks 20000/50000 (was
non-deterministic before). Residual same-version-restamp gap under many
fee-crediting txs per block is documented inline for high-contention BSC.
At each BSC fork the system contracts' bytecode is replaced. The upgrade code is
carried in the chain config as parlia.blockAlloc (keyed by block number or unix
timestamp -> genesis-alloc); Parlia.Initialize applies it via SetCode at block
initialisation so the block's transactions observe the new code. Running it in
the engine's Initialize hook covers serial, parallel and historical-trace paths
from one site.

Without this, execution diverged at the Ramanujan fork block (Chapel 1,010,000):
the system contracts kept their genesis code, giving a wrong state root. Verified
Chapel now executes through Ramanujan and the 1,014,369 upgrade set.

Ported from bsc-erigon (core/systemcontracts). The SystemContractCodeLookup
historical-code workaround is not needed on erigon3.
…nt time

Parse BlockAlloc once at engine construction into a sorted, typed slice instead
of re-parsing keys and re-decoding bytecode on every block; a malformed key or
alloc now fails fast at startup rather than mid-sync. Sorting makes overlapping
addresses across forks resolve deterministically (later fork wins).

A timestamp-based fork needs the parent block time to detect the crossing block;
treat a missing parent header as a hard error rather than a silent zero, which
would otherwise apply every past timestamp upgrade at once in map-iteration order
(a non-deterministic wrong root). Keys are classified block-number vs timestamp
by a documented boundary so a timestamp key can never match by block number.
@sudeepdino008
sudeepdino008 force-pushed the moskud/bsc-contract-upgrades branch from 4d12324 to 377660b Compare September 16, 2026 07:03
@sudeepdino008
sudeepdino008 marked this pull request as ready for review October 5, 2026 08:37
@sudeepdino008
sudeepdino008 requested a review from awskii October 5, 2026 08:37
@sudeepdino008
sudeepdino008 removed this pull request from stack #24051 October 6, 2026 09:38
@sudeepdino008
sudeepdino008 changed the base branch from moskud/bsc-parallel-fee-defer to bsc_support October 6, 2026 09:38
@sudeepdino008
sudeepdino008 added this pull request to stack #24611 October 6, 2026 09:40

@domiwei domiwei left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The post-Feynman upgrade sets are applied at the wrong point in the block, and the Chapel Pasteur set is missing. Both are consensus deviations from upstream BSC and need a fix before merge; see the inline comments.

What matches upstream:

  • The 19 upgrade sets match bsc-erigon's blockAlloc, and their contract code matches BSC geth.
  • Block keys activate on the exact block, and time keys on the crossing parent.Time < T <= header.Time.
  • Ramanujan through Plato, Kepler and Feynman are applied at block begin, as geth does.

Also inline: a nil-reader panic on an RPC path, and a stale note in bsc/PROGRESS.md.

Non-blocking, but please track it: the Prague EIP-2935 setup is missing for Parlia. Upstream sets the HistoryStorageAddress code and nonce 1 at the Prague block (geth core/systemcontracts/upgrade.go:1116-1121), and from that block on it writes the parent hash on every block (core/state_processor.go:113). This is outside the blockAlloc mechanism, but on Chapel it diverges at time 1740452880 (block 48,576,786), the same block as the Pascal set.

Non-blocking, but important: bsc/parlia has no tests for parseSystemContractUpgrades / upgradeSystemContracts. The only new test pins the key list and a digest of the data. Please add:

  • a table test for block keys (block before, at and after), time keys (parent before, at and after T), one block that crosses two time keys, and the missing-parent error;
  • an execution-level test for the post-Feynman application point relative to user and system txs.

The failing race-tests job (p2p/dnsdisc TestIteratorLinkUpdates) is in code that this diff does not touch.

Comment thread bsc/parlia/parlia.go
func (p *Parlia) Initialize(config *chain.Config, chain rules.ChainHeaderReader, header *types.Header,
state *state.IntraBlockState, syscall rules.SysCallCustom, logger log.Logger, tracer *tracing.Hooks) error {
ibs *state.IntraBlockState, syscall rules.SysCallCustom, logger log.Logger, tracer *tracing.Hooks) error {
return p.upgradeSystemContracts(chain, header, ibs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: post-Feynman upgrade sets are applied before the user transactions. Upstream applies them after the user transactions and before the system transactions.

BSC geth applies upgrades at block begin only while the parent is pre-Feynman: TryUpdateBuildInSystemContract(..., atBlockBegin=true) checks !config.IsFeynman(blockNumber, lastBlockTime) (core/systemcontracts/upgrade.go:1111-1126). Otherwise the upgrade runs from Parlia.Finalize (consensus/parlia/parlia.go:1423), after all user txs and before the system txs. bsc-erigon applies the later sets the same way, before the first system tx (execution/consensus/parlia/parlia.go:1023-1026, execution/exec3/state.go:250). It checks the block's own time instead of the parent time, so it differs from geth only at the Feynman block itself.

Affected Chapel keys and their fork blocks:

  • 1711342800: block 38,876,951
  • 1719986788: block 41,756,117
  • 1724116996: block 43,132,201
  • 1740452880: block 48,576,786
  • 1744097580: block 49,791,365
  • 1748243100: block 52,552,978
  • 1762741500: block 71,859,053

Failing scenario: a user tx in one of these blocks calls, delegatecalls, or reads the code (EXTCODEHASH/EXTCODESIZE) of an upgraded contract, for example StakeHub 0x2002. Here it runs the new code; upstream runs the old code. Gas, receipts and the state root can then differ. The Feynman block itself and all block-keyed sets are correct, because their parent is pre-Feynman.

Context: I checked these seven Chapel blocks over public RPC. No user tx in them targets an upgraded address directly, and none emits logs from one. I could not check internal calls without trace access. So the existing Chapel history may not diverge, but the rule is still different from upstream, and any block with such a tx will diverge.

Suggested fix: keep block-begin application for sets whose parent is pre-Feynman. Apply the later sets after the last user tx and before the first system tx, or at block end when the block has no system tx. Finalize alone is too late, because here the system txs run as ordinary body txs.

Comment thread bsc/chain/config_test.go
assert.Equal(t, []string{
"1010000", "1014369", "5582500", "13837000", "19203503", "22800220", "23603940",
"28196022", "29295050", "29861024", "1702972800", "1710136800", "1711342800",
"1719986788", "1724116996", "1740452880", "1744097580", "1748243100", "1762741500",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: the Chapel Pasteur set is missing.

chapel.json sets pasteurTime: 1784601000 (2026-07-21, already active). At that fork BSC geth replaces the code of StakeHub 0x2002 and Governor 0x2004:

  • pasteurUpgrade[chapelNet] in core/systemcontracts/upgrade.go:1078-1092;
  • applied at :1228-1229;
  • code in core/systemcontracts/pasteur/chapel/.

This data is ported from bsc-erigon's blockAlloc, which predates Pasteur.

Failing scenario: at the first block with parent.Time < 1784601000 <= header.Time, upstream changes both code hashes and this branch does not. The state root diverges at that block, whatever transactions it contains.

Suggested fix: add bsc/chain/upgrades/chapel/1784601000.json with the two contracts from geth, and extend this key list. The parent of that block is post-Feynman, so it also falls under the timing point in bsc/parlia/parlia.go.

Comment thread bsc/parlia/parlia.go
return nil
}
if number > 0 {
parent := chain.GetHeader(header.ParentHash, number-1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the first time key (1702972800) on, every call reaches this line, not only calls at fork boundaries.

Stateless witness verification passes a nil reader: execBlockStatelessly at rpc/jsonrpc/debug_execution_witness.go:2023. For a post-Kepler block that reaches this verification, the line calls a method on a nil interface and panics. The RPC server recovers the panic (rpc/service.go:229), so debug_executionWitness and eth_getWitness fail with "method handler crashed". Before this PR, Initialize did not use the reader.

Suggested fix: return an error instead of panicking when chain is nil. Then let the witness path supply the parent: result.headerByNumber already includes the parent header. Another option is to take the parent time from the caller, as bsc-erigon does with lastBlockTime.

A related point: eth_simulateV1 passes a DB-only reader (rpc/jsonrpc/eth_simulation.go:578), so from the second simulated block on it would hit "missing parent header". This is not reachable today, because Parlia's FinalizeAndAssemble still returns errNotSupported.

Comment thread bsc/PROGRESS.md
| 35,682,300 | HertzFix | |

Timestamp-activated forks (Shanghai/Kepler/Feynman/Cancun/…, unix ts ≥ ~1.7e9 ≈ block 30M+)
follow. All 19 `blockAlloc` code-upgrade sets (block- and time-keyed) are present in `chapel.json`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this paragraph is out of date:

It would also help to list the missing Prague EIP-2935 setup here as a known gap.

Comment thread bsc/parlia/parlia.go
func (p *Parlia) Initialize(config *chain.Config, chain rules.ChainHeaderReader, header *types.Header,
state *state.IntraBlockState, syscall rules.SysCallCustom, logger log.Logger, tracer *tracing.Hooks) error {
ibs *state.IntraBlockState, syscall rules.SysCallCustom, logger log.Logger, tracer *tracing.Hooks) error {
return p.upgradeSystemContracts(chain, header, ibs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-Feynman, upstream applies these upgrades in Finalize after user txs; here a user tx on a fork block runs the new code.

Comment thread bsc/parlia/parlia.go
return nil
}
if number > 0 {
parent := chain.GetHeader(header.ParentHash, number-1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

debug_executionWitness passes a nil chain reader and eth_simulateV1 can't resolve simulated parents, so both fail here.

Comment thread bsc/chain/config_test.go
assert.Equal(t, []string{
"1010000", "1014369", "5582500", "13837000", "19203503", "22800220", "23603940",
"28196022", "29295050", "29861024", "1702972800", "1710136800", "1711342800",
"1719986788", "1724116996", "1740452880", "1744097580", "1748243100", "1762741500",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chapel Pasteur (StakeHub, Governor at 1784601000) has no upgrade set, and this key list locks the gap in.

@AskAlexSharov

Copy link
Copy Markdown
Collaborator

bsc/parlia/parlia.go:L211-241: shrink: the ensureParentTime closure and the parentTime/haveParentTime pair can be one lazy parent:

var parent *types.Header
...
if u.byTime {
	if header.Time < u.numOrTime {
		continue
	}
	if parent == nil {
		if parent = chain.GetHeader(header.ParentHash, number-1); parent == nil {
			return fmt.Errorf("parlia: missing parent header for block %d, cannot evaluate timestamp system-contract upgrade", number)
		}
	}
	if parent.Time >= u.numOrTime {
		continue
	}
}

net: -13 lines possible.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants