Skip to content

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

Open
yanghang8612 wants to merge 5 commits intotronprotocol:developfrom
yanghang8612:feat/tip-2935-historical-blockhash
Open

feat(vm): implement TIP-2935 serve historical block hashes from state#6686
yanghang8612 wants to merge 5 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.
  • 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 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 when user contracts STATICCALL the deployed bytecode.
  • BLOCKHASH opcode semantics are unchanged (still the 256-block window).

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 (5 cases): storage-key round-trip equivalence with Storage.compose(), deploy populates Code/Contract/Account, deploy idempotency, slot-correctness, ring-buffer modulo wrap.
    • HistoryBlockHashIntegrationTest (4 cases): activation flow, per-block write after activation, gated off before activation, and a full VM round-trip that reads the written hash back through RepositoryImpl.getStorageValue — proving the read path composes the same key as our write path.
    • ProposalUtilTest.validateCheck: new testAllowTvmPragueProposal covering pre-fork rejection, bad-value rejection, already-enabled rejection, and the valid path.
  • 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.

Port EIP-2935 to TRON: store recent block hashes in a system contract's
storage so smart contracts can access up to 8191 blocks of history beyond
the BLOCKHASH opcode's 256-block window.

- New proposal ALLOW_TVM_PRAGUE(95), fork-gated by VERSION_4_8_2.
- On activation, deploy the EIP-2935 runtime bytecode + ContractCapsule
  (version=0) + AccountCapsule(type=CONTRACT) at the canonical address
  0x0000F90827F1C53a10cb7A02335B175320002935 via direct CodeStore /
  ContractStore / AccountStore writes.
- On every block, before the transaction loop, write the parent block hash
  to StorageRowStore at slot (blockNum - 1) % 8191. The storage key layout
  replicates Storage.compose() for contractVersion=0
  (addrHash[0:16] || slotKey[16:32]) so the written rows are readable via
  VM SLOAD.
- BLOCKHASH opcode semantics unchanged; new storage is accessed by user
  contracts via STATICCALL to the deployed bytecode.

Tests: round-trip equivalence (direct-write key = VM compose key),
deploy idempotency, ring buffer modulo, end-to-end activation flow,
and full VM repository read-back.
Proposal activation deploys the HistoryStorage contract via ProposalService,
but a node started with committee.allowTvmPrague=1 would flip the flag on
through the CommonParameter fallback without the contract ever being written
to CodeStore/ContractStore/AccountStore. External STATICCALLs would then hit
an empty account.

- Add HistoryBlockHashUtil.deployIfMissing(Manager), called from Manager.init
  right after updateDynamicStoreByConfig so the bytecode is guaranteed present
  before any block is applied.
- Guard write() against blockNum <= 0 so (0 - 1) % 8191 = -1 in Java can never
  corrupt a slot if the hook is ever reached for genesis.
- Add integration tests covering the proposal-persisted, config-fallback, and
  already-deployed paths, plus block-1 writes genesis hash to slot 0 and the
  genesis no-op.
…hUtil

Collapse the redundant getChainBaseManager() indirection — Manager already
exposes getCodeStore/getContractStore/getAccountStore/getStorageRowStore/
getDynamicPropertiesStore pass-throughs.
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

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. 👍

… half-installs

The previous deploy() skipped any store whose entry already existed at the
canonical address, which had two failure modes:

- Silent merge with foreign state. If any capsule already sat at 0x…2935
  (accidental transfer, pre-existing code), deploy would skip that store
  and proceed, leaving the canonical address with a mix of prior and new
  data. Nothing alerted operators, and the contract could be functionally
  broken (wrong code, wrong account type).
- Unrecoverable half-install. init() runs with revokingStore disabled, so
  the three writes are not atomic. A crash between writes left the DB with
  a subset of entries; on next start deployIfMissing only checked
  CodeStore.has and would skip, leaving the address permanently broken
  (RepositoryImpl returns null storage whenever the account is missing).

The rewritten deploy() validates any pre-existing code / contract / account
against the expected HistoryStorage (bytecode equality, contract name +
address, account type=Contract) and throws IllegalStateException on any
mismatch — activation refuses rather than silently corrupting state.
Whatever is missing after validation is filled in, so a half-install
self-heals on the next start.

deployIfMissing() now gates on the flag alone and delegates to deploy()
so the recovery path is exercised regardless of which of the three stores
was written before the crash. A logger.info fires inside the code-write
branch specifically, so operators can grep for when fresh bytecode landed
on a given node without noise on steady-state restarts.

New tests:
- deployRejectsPreExistingWrongCode
- deployRejectsPreExistingForeignContract
- deployRejectsPreExistingNormalAccount
- deployIfMissingCompletesPartialInstall
…length

The on-chain contract name ships in ContractStore and surfaces in block
explorers, so pick one that reads naturally: "history of block hashes".
Also breaks a 106-char line in the new partial-install test.
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

None yet

Development

Successfully merging this pull request may close these issues.

3 participants