Skip to content

feat(vm): implement TIP-2935 serve historical block hashes from state#6686

Merged
CodeNinjaEvan merged 3 commits intotronprotocol:developfrom
yanghang8612:feat/tip-2935-historical-blockhash
May 8, 2026
Merged

feat(vm): implement TIP-2935 serve historical block hashes from state#6686
CodeNinjaEvan merged 3 commits intotronprotocol:developfrom
yanghang8612:feat/tip-2935-historical-blockhash

Conversation

@yanghang8612
Copy link
Copy Markdown
Collaborator

@yanghang8612 yanghang8612 commented Apr 16, 2026

What does this PR do?

Implements TIP-2935 (port of EIP-2935 "Serve Historical Block Hashes from State") for java-tron.

  • Adds proposal ALLOW_TVM_PRAGUE (ID 95), fork-gated by VERSION_4_8_2 and additionally requiring ALLOW_TVM_SHANGHAI to already be enacted (the deployed bytecode uses PUSH0, which is itself gated on Shanghai). Activation is proposal-only — no committee.* / ConfigKey / CommonParameter / Args plumbing, mirroring ALLOW_TVM_SELFDESTRUCT_RESTRICTION's shape.
  • On activation, deploys the canonical EIP-2935 runtime bytecode + a minimal SmartContract (version = 0) + CONTRACT-type account at the 20-byte address 0x0000F90827F1C53a10cb7A02335B175320002935 (TRON: 410000F90827F1C53a10cb7A02335B175320002935). The three writes go to CodeStore / ContractStore / AccountStore via normal revoking-store puts — no synthetic transaction, no new actuator.
  • On every block, before the transaction loop, writes the parent block hash to StorageRowStore at slot (block.num - 1) % 8191. The hook fires on both the producer's Manager.generateBlock simulation loop and the validator's Manager.processBlock apply loop, so transactions that STATICCALL HISTORY_STORAGE_ADDRESS see identical state on both sides. The storage-key composition exactly replicates Storage.compose() for contractVersion=0 (sha3(addr)[0:16] || slotKey[16:32]), so the direct-written rows are readable via a normal VM SLOAD.
  • BLOCKHASH opcode semantics are unchanged (still the 256-block window).
  • No backfill on activation. The 8191-slot ring buffer starts empty; writes begin at the activation block. While a queried block is still inside the sliding window, an unwritten slot returns bytes32(0); once queried + 8191 < current the deployed bytecode reverts (same behavior as for any block that has aged out of the buffer). Future and current-block queries (queried >= current) also revert.
  • Bytecode uses PUSH0 (0x5f). PUSH0 is gated on ALLOW_TVM_SHANGHAI at execution time. The Prague validator now refuses to enact unless Shanghai is already active, so an out-of-order activation can't leave a contract whose every STATICCALL hits InvalidOpcode. On mainnet/testnet Shanghai is enacted long before Prague, so the check is a no-op in practice.

Why are these changes required?

The 256-block BLOCKHASH window is too short for many workloads (rollups, stateless clients, multi-block fraud proofs, long-dated oracle signatures). EIP-2935 is the industry-standard solution on Ethereum (Prague, May 2025). Bringing it to TVM gives TRON DApps the same guarantees and — because we keep the same address and bytecode — lets cross-chain contracts hardcode 0x0000F908…2935 and work unchanged on both chains.

This PR has been tested by:

  • Unit Tests
    • HistoryBlockHashUtilTest (6 cases): deploy populates Code/Contract/Account (with version=0, consumeUserResourcePercent=100, originAddress= EIP-2935 deployer); pre-deploy state is empty; slot-correctness on a normal block; ring-buffer modulo wrap at slot 0 for block 8192; write() is a no-op before deploy (install marker = 0); write() is a no-op when foreign code blocks deploy.
    • HistoryBlockHashIntegrationTest (10 cases): activation flow flips the proposal flag and deploys; per-block write after activation populates the slot; full VM round-trip through RepositoryImpl.getStorageValue (proves the read path composes the same key as our write path); no write before activation; block 1 stores the genesis hash at slot 0; write is a no-op for block 0 (genesis guard); deploy skips on foreign code; deploy skips on foreign contract metadata; deploy upgrades a pre-existing EOA in place preserving balance; generateBlock writes the parent hash before the tx loop (spies accountStateCallBack.preExecute from inside the revoking session — fails pre-fix when the producer-side hook is missing).
    • HistoryBlockHashVmTest (6 cases): real STATICCALL through the deployed bytecode — within-window read returns the written hash; unwritten slot returns bytes32(0); future block (queried >= current) reverts; out-of-window block (queried + 8191 < current) reverts; non-32-byte calldata reverts; PUSH0 does not raise IllegalOperationException under Shanghai.
    • ProposalUtilTest.validateCheck: new testAllowTvmPragueProposal covering pre-fork rejection, missing-Shanghai rejection, bad-value rejection, and already-enabled rejection.
  • Manual Testing
    • ./gradlew :framework:compileJava :framework:compileTestJava — OK.
    • ./gradlew lint — OK.
    • ./gradlew :framework:test --tests "…HistoryBlockHash…" --tests "…ProposalUtilTest.validateCheck" — all pass.

Follow up

  • Verify on mainnet that the address 410000F90827F1C53a10cb7A02335B175320002935 has never held a contract / balance (deployment would collide otherwise).
  • Testnet DApp check: measure energy cost of a STATICCALL to get(blockNum) from a Solidity contract on Nile.

Extra details

Implementation notes on how this diverges from geth's Prague system-call pattern (direct-write instead of system call, proposal activation instead of genesis pre-alloc, one-block activation gap) are summarized in the TIP issue comment.

alan-eth

This comment was marked as duplicate.

* Called once from ProposalService when ALLOW_TVM_PRAGUE activates.
*/
public static void deploy(Manager manager) {
byte[] addr = HISTORY_STORAGE_ADDRESS;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The idempotent deploy() with per-store has() checks and the genesis guard in write() are really clean.

Minor: would it be helpful to add a log line in deploy() / deployIfMissing() for easier troubleshooting during upgrades?

Copy link
Copy Markdown
Collaborator Author

@yanghang8612 yanghang8612 Apr 17, 2026

Choose a reason for hiding this comment

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

Thanks for the nudge — good point, one info log is cheap and pays off the first time someone has to trace when TIP-2935 actually landed on a given node.

Just pushed it in c4dde34: a single logger.info inside the CodeStore write branch of deploy(), guarded by !has(addr) so it only fires the instant fresh bytecode first lands. Steady-state restarts stay silent.

Format:

TIP-2935: wrote HistoryStorage bytecode at <addr-hex>

Topic is DB, matching the convention in org.tron.core.db.backup.* etc.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for pushing this in — the !has(addr) guard plus @Slf4j(topic = "DB") is spot-on, and having steady-state restarts stay silent is exactly the right trade-off. 🎯

Nice bonus I noticed while reading the commit: you also took the chance to rename the constant to BlockHashHistory (clearer domain naming; the log string carries the new name too) and add validateExistingOrThrow() so deploy() now validates-first-then-writes. That pattern cleanly handles both the "canonical address already occupied by foreign state" case and the "crashed between the three store writes" partial-install case — a solid defensive improvement that goes well beyond the original log-line ask. 👍

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks — and good eye on the rename. BlockHashHistory reads better everywhere it surfaces (proposal name, log topic, store comments), and validateExistingOrThrow() is the right shape for the three-store triplet: a mid-deploy() crash now surfaces as a loud mismatch on restart rather than a silently partial install across CodeStore / ContractStore / AccountStore. Appreciated the careful read across the whole commit, not just the spot I asked about.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Anytime! The simplified deployIfMissing (gate on the flag, delegate to deploy() so the validate-first path runs regardless of which store crashed mid-write) is a particularly tidy way to fold partial-install recovery into the normal deploy path — no separate code path to maintain. 👍 Looking forward to seeing TIP-2935 land.

case ALLOW_TVM_PRAGUE: {
manager.getDynamicPropertiesStore().saveAllowTvmPrague(entry.getValue());
if (entry.getValue() == 1) {
HistoryBlockHashUtil.deploy(manager);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What happens if deploy() throws an exception here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — pushed three commits in response: 344f418 (initial defensive wrap), f60c100 (simplification once we knew this runs at most once), and 34aa2fb after rethinking the exception strategy.

Final shape: ProposalService no longer wraps deploy() at all:

case ALLOW_TVM_PRAGUE: {
  manager.getDynamicPropertiesStore().saveAllowTvmPrague(entry.getValue());
  if (entry.getValue() == 1) {
    HistoryBlockHashUtil.deploy(manager);
  }
  break;
}

deploy() separates the failure shapes by control flow, not exception type:

  1. Foreign code/contract metadata at the canonical address (deterministic — same pre-state on every node, requires a SHA-3 pre-image to ever fire) → logger.warn(...) + return. Flag commits, contract install skipped, STATICCALLs return empty at the user level on every node ⇒ consensus intact.

  2. Pre-existing EOA at the address (someone TRX-transfers to the canonical address before the proposal fires) → upgrade type to Contract in place, preserving balance.

  3. Anything else (RocksDB / IO / OOM / …) → propagate naturally. The maintenance block aborts; the shared revoking session rolls back saveAllowTvmPrague(1) together with any partial store writes. Bad node fails loudly — correct outcome for non-deterministic failure modes.

The intermediate rev had catch(Throwable) which conflated (1) and (3); briefly considered narrowing to catch(IllegalStateException) but it's also raised by SnapshotManager and TxCacheDB, so a narrow catch couldn't cleanly separate the cases. Putting the deterministic skip inside deploy() as if/return separates the cases by position in source rather than by exception type — more robust.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Late update: dropped the if (entry.getValue() == 1) guard in 213a6faProposalUtil.validator already rejects any value other than 1 for ALLOW_TVM_PRAGUE, so the runtime check at the call site was dead code. Current head (ProposalService.java:400-404) is now unconditional HistoryBlockHashUtil.deploy(manager), mirroring the other proposal cases in the switch. Everything above about deploy()'s control-flow separation still applies.

@bladehan1 bladehan1 assigned bladehan1 and unassigned bladehan1 Apr 28, 2026
@yanghang8612 yanghang8612 force-pushed the feat/tip-2935-historical-blockhash branch 3 times, most recently from 27e76b3 to 5806e22 Compare April 29, 2026 04:10
@github-actions github-actions Bot requested a review from CodeNinjaEvan May 6, 2026 04:00
@yanghang8612 yanghang8612 requested review from aiden3885 and alan-eth May 8, 2026 07:53
Activates via ALLOW_TVM_PRAGUE proposal: deploy() installs the
BlockHashHistory bytecode + metadata at the canonical address;
write() propagates parent hashes to a 8191-slot ring buffer.
processBlock writes the parent block hash before iterating
transactions; generateBlock did not, so the producer's simulation
loop saw a stale/empty slot at HISTORY_STORAGE_ADDRESS while
validators saw the live value. Mirror processBlock's call order.
@yanghang8612 yanghang8612 force-pushed the feat/tip-2935-historical-blockhash branch from 29e6359 to b5b8ee5 Compare May 8, 2026 09:06
Copy link
Copy Markdown
Collaborator

@CodeNinjaEvan CodeNinjaEvan left a comment

Choose a reason for hiding this comment

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

LGTM

@CodeNinjaEvan CodeNinjaEvan merged commit 4f88973 into tronprotocol:develop May 8, 2026
12 checks passed
@github-project-automation github-project-automation Bot moved this to Done in java-tron May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

topic:vm VM, smart contract

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

7 participants