feat(vm): implement TIP-2935 serve historical block hashes from state#6686
feat(vm): implement TIP-2935 serve historical block hashes from state#6686yanghang8612 wants to merge 5 commits intotronprotocol:developfrom
Conversation
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.
| * Called once from ProposalService when ALLOW_TVM_PRAGUE activates. | ||
| */ | ||
| public static void deploy(Manager manager) { | ||
| byte[] addr = HISTORY_STORAGE_ADDRESS; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
What does this PR do?
Implements TIP-2935 (port of EIP-2935 "Serve Historical Block Hashes from State") for java-tron.
ALLOW_TVM_PRAGUE(ID95), fork-gated byVERSION_4_8_2.SmartContract(version = 0) +CONTRACT-type account at the 20-byte address0x0000F90827F1C53a10cb7A02335B175320002935(TRON:410000F90827F1C53a10cb7A02335B175320002935). The three writes go toCodeStore/ContractStore/AccountStorevia normal revoking-store puts — no synthetic transaction, no new actuator.StorageRowStoreat slot(block.num - 1) % 8191. The storage-key composition exactly replicatesStorage.compose()forcontractVersion=0(sha3(addr)[0:16] || slotKey[16:32]), so the direct-written rows are readable via a normal VMSLOADwhen user contractsSTATICCALLthe deployed bytecode.BLOCKHASHopcode semantics are unchanged (still the 256-block window).Why are these changes required?
The 256-block
BLOCKHASHwindow 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 hardcode0x0000F908…2935and work unchanged on both chains.This PR has been tested by:
HistoryBlockHashUtilTest(5 cases): storage-key round-trip equivalence withStorage.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 throughRepositoryImpl.getStorageValue— proving the read path composes the same key as our write path.ProposalUtilTest.validateCheck: newtestAllowTvmPragueProposalcovering pre-fork rejection, bad-value rejection, already-enabled rejection, and the valid path../gradlew :framework:compileJava :framework:compileTestJava— OK../gradlew lint— OK../gradlew :framework:test --tests "…HistoryBlockHash…" --tests "…ProposalUtilTest.validateCheck"— all pass.Follow up
410000F90827F1C53a10cb7A02335B175320002935has never held a contract / balance (deployment would collide otherwise).STATICCALLtoget(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.