feat(jsonrpc): implement base eth_simulateV1 JSON-RPC method#6785
Open
APshenkin wants to merge 16 commits into
Open
feat(jsonrpc): implement base eth_simulateV1 JSON-RPC method#6785APshenkin wants to merge 16 commits into
APshenkin wants to merge 16 commits into
Conversation
Re-submitting as COMMENT (AI never APPROVE/REQUEST_CHANGES; reviewer judgment left to humans).
bladehan1
reviewed
May 22, 2026
Author
|
@bladehan1 thank you for review. I returned back from holiday and will address review comments next week |
…teSmartContract helper
Author
|
@bladehan1 All reviews addressed, all fixes by separate commits. Please take a look and let me know if anything left |
Collaborator
|
Great, everything's been fixed. |
Author
|
@bladehan1 done. What do you think also about Should it be separate PR or I can add it here? Also if you have any comments about how API looks for it, please share so that I can incorporate it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Implements geth's
eth_simulateV1on java-tron's JSON-RPC surface for the MVP trading-flow use case: a single round-trip that runs N dependent calls against current head state and returns each call's effect plus synthetic transfer logs.The endpoint is opt-in via existing
eth_call-style flags; no existing behaviour changes.Why are these changes required?
This is first step to resolve #6199
Tron's JSON-RPC exposes
eth_callbut noteth_simulateV1. Two concrete consumers benefit:eth_simulateV1lets the wallet run the unsigned transaction against current head state and decode the resulting transfer logs directly into a human-readable diff.Both consumers simulate against current head state only — they don't need historical-block context or state overrides. The current implementation covers those cases end-to-end and is sufficient for what we expect to be the majority of
eth_simulateV1usage on Tron.Future work needed for the remaining use cases (debugging historical txs, what-if analysis with state overrides) requires changes in the archive node to support simulation on a specific block +
stateOverrides/blockOverrides. That's intentionally out of scope here.JSON-RPC surface
blockStateCalls: [{ calls: [...] }]. Multi-block /blockOverrides/stateOverrides→-32602.blockOverridesandstateOverridesare excluded by design — both consumers in the Motivation section simulate against current head state only, and supporting overrides would require the same archive-node plumbing called out as future work (rewinding to a specific block, applying account/storage patches before VM execution). Rejecting them with a clear error is better than silently ignoring them. Hard cap of 32 calls per block — geth's defaults (5000/block, 10000 total) are tuned for general-purpose use; our concrete cases (trading-flow approval+swap+settle, wallet preview of a single user-signed tx that fans out a few internal calls) realistically stay under ~10. Capping at 32 leaves comfortable headroom while bounding per-request memory: at ~10KB of accumulated state per call, the shared root's in-memory cache stays well under ~1MB worst-case (vs ~50MB at 5000). Anything beyond that should either be a separate request or signal misuse.blockNumOrTag: only"latest"and"pending"accepted; both resolve to the head block (Tron has instant finality — no mempool state distinct from latest).validationpre-check failure, or an unexpected VMRuntimeException— returnsstatus: "0x0"witherrorMessagefor that call only; subsequent calls keep executing against the committed state of the prior successful calls. The shared root is only committed for calls that succeed. Matches geth'seth_simulateV1semantics (a failure does not abort the batch).traceTransfers: truesynthesizes logs at the ERC-7528 native pseudo-address (0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), distinguished bytopic[0]:Transfer(address,address,uint256)for native TRX moves.TRC10Transfer(address,address,uint256,uint256)for TRC-10 moves —topic[3]carriestokenIdso consumers can filter logs by asset via topics;datacarriesamount. The exact keccak hex of this Tron-private signature is a published client contract, pinned by a unit test (see Testing).returnFullTransactions: truereturnsblock.transactionsasTransactionResultobjects (synthetic deterministichash,gasPrice = "0x0",nonce = "0x0",v/r/szero,blockHash = keccak256("sim:" + headHash + ":1")). Defaultfalsereturns hash strings.validation: true— Tron-flavored. Geth's checks (baseFee/gasPrice/nonce) don't apply to Tron, so we pre-check per call: sender account exists and sender balance ≥callValue. Failed pre-check →status: "0x0"witherrorMessage, VM not invoked. Defaultfalsepreserves the existing constant-call permissive behaviour. The divergence from the geth spec (signature/nonce/fee checks are not run) is documented on theSimulateV1Args.validationfield so client authors porting from geth aren't surprised.Tracing
Five hook sites per transfer kind, each firing after the real balance change succeeds:
VMActuator.call()(depth 0)MUtil.transferMUtil.transferTokenVMActuator.create()(depth 0)MUtil.transferMUtil.transferTokenProgram.callToAddress(depth ≥1)addBalancepairaddTokenBalancepairProgram.callToPrecompiledAddress(depth ≥1)addTokenBalancepairProgram.suicide/suicide2MUtil.transfertransferAllTokenWithTracehelper — snapshotassetMapV2beforeMUtil.transferAllToken, emit one entry per non-zero assetDELEGATECALL/CALLCODEare explicitly skipped (no real value transfer even whensenderAddress != contextAddress).A per-frame buffer (
BufferingSimulationTracer) with a unifiedseqcounter interleaves explicitLOGopcodes with both synthetic kinds in emission order. Reverted frames drop their entries;logIndexstill increments through gaps (matches geth'slogtracer.go:128). The buffer enforces a defensive per-call cap (MAX_ENTRIES_PER_CALL = 100_000) — a pathological contract that emits more logs/transfers than that in a single call fails that call with a clearerrorMessage(per-call isolation keeps the rest of the batch running) instead of growing the buffer unboundedly.The
transferAllTokenWithTraceconsensus path (tracer == null) is byte-identical to the originalMUtil.transferAllToken— no logging or extra reads above the early-return guard — so sync-from-genesis is unaffected. This invariant is documented inline and locked down by a test.Implementation
SimulationTracer(enterFrame/exitFrame/revertFrame/onTransfer/onTokenTransfer/onLog). Default implBufferingSimulationTracerowns the frame stack andseqcounter.VMActuatorgets opt-in setters (setInjectedRootRepository,setSimulationTracer). When the injected root is null, the existing fresh-root code path runs unchanged.Programpropagates the tracer into childPrograminstances at every sub-call origination site so nested CALL/CREATE moves are captured.Wallet.simulateConstantContractsis the new entry point. It builds the shared root + per-call child Repositories and shares the per-call execute body with the existingcallConstantContractvia a new privateexecuteOneConstantInternalhelper. Returns the top-levelSimulateOutcome/SimulateCallOutcomecarriers (extracted intojsonrpc/types/, not nested inWallet).stripTronPrefixTron-address conversion lives once inMUtiland is called from bothVMActuatorandProgram(was duplicated). The EVM CREATE-contract convention (consumeUserResourcePercent=100,originEnergyLimit=1) is centralized inWallet.buildEvmCreateSmartContract.SimulateV1Args,SimulateBlock(uses@JsonAnySetterto detect forward-incompatible field names),SimulateCallResult,SimulateBlockResult extends BlockResult.BlockResult's constant "no value" fields are inline field defaults so both the live-block and simulate-block constructions inherit them. ReusesLogFilterElement,CallArguments,TransactionResult(additive raw-fields constructor for synthetic full-tx output).This PR has been tested by:
Unit
21 tests total:
Input-validation (
EthSimulateV1ArgsTest, 10 tests)-32602input-validation surface — null / emptyblockStateCalls, multiple blocks,blockOverrides,stateOverrides, unknown block fields, >32 calls, hex block-number tag,earliesttag — plus JSON round-trip ofSimulateBlockResult. MockedWallet, no chain context.Constant / topic invariants (
TronJsonRpcImplTest, 1 test)trc10TransferTopicHex_isStable— pins the exact keccak hex of the syntheticTRC10Transfer(address,address,uint256,uint256)topic[0]. Clients hard-code this value, so it's a published contract; the test recomputes it from its own copy of the canonical signature string, so any edit to the production signature literal fails CI.Integration (
EthSimulateV1IntegrationTest, 10 tests,BaseTest+ LevelDB)stateSharingAcrossCalls—set(42)→get()returns 42 in one simulate; on-chain slot unchanged.revertIsolatesPerCall—set(99)→setRevert(123)→get()returns 99.validationRejectsUnactivatedSender—validation: truewith a never-seenfrom→"sender account does not exist".validationRejectsInsufficientBalance—validation: truewithvalue > balance→"insufficient balance for value".createPopulatesContractAddress— CREATE call setscontractAddressto the actual deployed address (read from the VM, not re-synthesized).returnFullTransactionsShape— verifies both response shapes, deterministic hash equality across runs.callcodeSkipsSyntheticTransferLog— a contract CALLCODEs itself with value=5; asserts no synthetic ERC-7528 Transfer log is emitted (CALLCODE keeps execution in the caller's context, so logging the move would be a phantom transfer).delegatecallSkipsSyntheticTransferLog— contract A DELEGATECALLs contract B (a no-op STOP); asserts no synthetic Transfer log. DELEGATECALL carries no value, so the transfer-logging branch is unreachable — the test pins that end-state so a future change that started routing DELEGATECALL value through the transfer path is caught.transferAllToken_multiTrc10_byteEquivalence— SELFDESTRUCT consensus path: an account holding three distinct TRC-10 assets is swept to a destination viaMUtil.transferAllToken; asserts the owner map is zeroed and the destination is credited each asset (summed with its pre-state). Guards thetracer == nullearly-return against side-effect drift that would break sync-from-genesis.buffering_dropsTokenTransferOnRevertFrame— direct buffer exercise:revertFramedrops a bufferedonTokenTransfer.Manual Testing
Launched Nile testnet node with changes and run multiple commands with different setups:
Requests and Responses are long, so hide under spoiler