diff --git a/evmrpc/AGENTS.md b/evmrpc/AGENTS.md index 47e7e28c41..aab3df8e2d 100644 --- a/evmrpc/AGENTS.md +++ b/evmrpc/AGENTS.md @@ -30,6 +30,12 @@ EVM RPCs prefixed by `eth_` and `debug_` on Sei generally follows [Ethereum's sp - **No Trie** - Sei does not store states in a trie, so any endpoint relevant to the trie data structure is not supported. - **No PoW** - Sei has never used proof-of-work, so endpoints like `eth_mining` and `eth_hashrate` are not supported. - **No Blobs** - Sei does not support EIP-4844 blob transactions. `eth_blobBaseFee` returns JSON-RPC error code `-32000` with message `blobs not supported on this chain`. +- **`milliTimestamp`** — BEP-520-compatible block-header field carrying the header time as a hex quantity of **Unix milliseconds**, alongside the standard `timestamp`, which stays in **whole seconds** because every Ethereum client reads it that way. Sei block intervals are shorter than a second, so `timestamp` repeats across consecutive blocks. `milliTimestamp` is **not** unique per block either: under Autobahn a proposal spaces consecutive blocks by 1µs (`minTimestampDiff`), so blocks less than a millisecond apart tie. Unrelated to the seconds-based values feeding fork rules and the `TIMESTAMP` opcode (`vm.BlockContext.Time`, `MakeSigner`, `ethtypes.Header.Time`) — those must stay in seconds. +- **Four separate encoders build block headers.** A field added to one is silently absent from the other three, so a new header field has to be added to all four: + - `EncodeTmBlock` — `eth_getBlockByNumber` / `eth_getBlockByHash` + - `encodeGenesisBlock` — the synthetic genesis block; both of the above return it early, before `EncodeTmBlock` is reached + - `encodeCommittedBlock` — `eth_subscribe("newHeads")` under Autobahn + - `encodeTmHeader` — `eth_subscribe("newHeads")` under CometBFT - **Explicitly unsupported RPCs (same `-32000` pattern)** — Methods are registered so clients get a clear error instead of `-32601` method not found: - `debug_getRawBlock`, `debug_getRawHeader`, `debug_getRawReceipts`, `debug_getRawTransaction` - `eth_newPendingTransactionFilter` diff --git a/evmrpc/block.go b/evmrpc/block.go index 83bc2b91f8..bab26c9a56 100644 --- a/evmrpc/block.go +++ b/evmrpc/block.go @@ -56,6 +56,7 @@ func encodeGenesisBlock() map[string]any { "gasLimit": hexutil.Uint64(0), "gasUsed": hexutil.Uint64(0), "timestamp": hexutil.Uint64(0), + "milliTimestamp": hexutil.Uint64(0), // BEP-520-compatible, see EncodeTmBlock "transactionsRoot": common.Hash{}, "receiptsRoot": common.Hash{}, "size": hexutil.Uint64(0), @@ -419,6 +420,10 @@ func EncodeTmBlock( if cp := ctx.ConsensusParams(); cp != nil && cp.Block != nil { gasLimit = cp.Block.MaxGas } + // "timestamp" stays in whole seconds because every Ethereum client reads it + // that way, and Sei block intervals are shorter than a second, so it repeats + // across consecutive blocks. "milliTimestamp" is the BEP-520-compatible + // companion exposing the sub-second precision the Tendermint header already carries. result := map[string]any{ "number": (*hexutil.Big)(number), "hash": blockhash, @@ -429,11 +434,12 @@ func EncodeTmBlock( "logsBloom": ethtypes.BytesToBloom(blockBloom), "stateRoot": appHash, "miner": miner, - "difficulty": (*hexutil.Big)(big.NewInt(0)), // inapplicable to Sei - "extraData": hexutil.Bytes{}, // inapplicable to Sei - "gasLimit": hexutil.Uint64(gasLimit), //nolint:gosec - "gasUsed": hexutil.Uint64(blockGasUsed), //nolint:gosec - "timestamp": hexutil.Uint64(block.Block.Time.Unix()), //nolint:gosec + "difficulty": (*hexutil.Big)(big.NewInt(0)), // inapplicable to Sei + "extraData": hexutil.Bytes{}, // inapplicable to Sei + "gasLimit": hexutil.Uint64(gasLimit), //nolint:gosec + "gasUsed": hexutil.Uint64(blockGasUsed), //nolint:gosec + "timestamp": hexutil.Uint64(block.Block.Time.Unix()), //nolint:gosec + "milliTimestamp": hexutil.Uint64(block.Block.Time.UnixMilli()), //nolint:gosec "transactionsRoot": txHash, "receiptsRoot": resultHash, "size": hexutil.Uint64(block.Block.Size()), //nolint:gosec diff --git a/evmrpc/block_test.go b/evmrpc/block_test.go index 9c939fe383..097c7221f4 100644 --- a/evmrpc/block_test.go +++ b/evmrpc/block_test.go @@ -46,6 +46,32 @@ func TestEncodeTmBlock_EmptyTransactions(t *testing.T) { require.Equal(t, ethtypes.EmptyTxsHash, result["transactionsRoot"]) } +// Sei commits blocks more often than once a second, so the seconds-only +// Ethereum "timestamp" repeats across consecutive blocks. milliTimestamp carries +// the sub-second part the Tendermint header already holds. +func TestEncodeTmBlockMilliTimestamp(t *testing.T) { + k := &testkeeper.EVMTestApp.EvmKeeper + ctx := testkeeper.EVMTestApp.GetContextForDeliverTx([]byte{}).WithBlockTime(time.Now()) + header := mockBlockHeader(MockHeight8) + header.Time = time.Unix(1696941649, 125_000_000).UTC() + block := &coretypes.ResultBlock{ + BlockID: MockBlockID, + Block: &tmtypes.Block{ + Header: header, + Data: tmtypes.Data{}, + LastCommit: &tmtypes.Commit{ + Height: MockHeight8 - 1, + }, + }, + } + + result, err := evmrpc.EncodeTmBlock(func(i int64) sdk.Context { return ctx }, func(i int64) client.TxConfig { return TxConfig }, block, k, true, false, evmrpc.NewBlockCache(3000), &sync.Mutex{}) + require.Nil(t, err) + + require.Equal(t, hexutil.Uint64(1696941649), result["timestamp"]) + require.Equal(t, hexutil.Uint64(1696941649125), result["milliTimestamp"]) +} + func TestEncodeBankMsg(t *testing.T) { k := &testkeeper.EVMTestApp.EvmKeeper ctx := testkeeper.EVMTestApp.GetContextForDeliverTx([]byte{}).WithBlockTime(time.Now()) diff --git a/evmrpc/notifier_internal_test.go b/evmrpc/notifier_internal_test.go index ef6681d6aa..66bdb5e97f 100644 --- a/evmrpc/notifier_internal_test.go +++ b/evmrpc/notifier_internal_test.go @@ -144,7 +144,10 @@ func TestEncodeCommittedBlock(t *testing.T) { hash := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111").Bytes() proposer := common.HexToAddress("0x2222222222222222222222222222222222222222").Bytes() appHash := common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333").Bytes() - ts := time.Unix(1_700_000_000, 0).UTC() + // Fractional second on purpose: it is the part "timestamp" drops and + // "milliTimestamp" keeps, so a whole-second fixture would pass even if + // milliTimestamp were derived as timestamp*1000. + ts := time.Unix(1_700_000_000, 750_000_000).UTC() evt := blockHeaderEvent{ hash: hash, header: &tmproto.Header{ @@ -168,6 +171,7 @@ func TestEncodeCommittedBlock(t *testing.T) { require.Equal(t, common.BytesToAddress(proposer), out["miner"]) require.Equal(t, common.BytesToHash(appHash), out["stateRoot"]) require.Equal(t, hexutil.Uint64(ts.Unix()), out["timestamp"]) + require.Equal(t, hexutil.Uint64(ts.UnixMilli()), out["milliTimestamp"]) require.Equal(t, hexutil.Uint64(121000), out["gasUsed"]) require.Equal(t, hexutil.Uint64(10_000_000), out["gasLimit"]) require.Equal(t, (*hexutil.Big)(big.NewInt(42)), out["baseFeePerGas"]) diff --git a/evmrpc/subscribe.go b/evmrpc/subscribe.go index 7506c89530..70f8ff2956 100644 --- a/evmrpc/subscribe.go +++ b/evmrpc/subscribe.go @@ -456,11 +456,12 @@ func encodeCommittedBlock(evt blockHeaderEvent, baseFee *big.Int, gasLimit int64 "receiptsRoot": common.Hash{}, // see function doc "sha3Uncles": common.Hash{}, // inapplicable to Sei "stateRoot": appHash, - "timestamp": hexutil.Uint64(evt.header.Time.Unix()), //nolint:gosec - "transactionsRoot": common.Hash{}, // see function doc - "mixHash": common.Hash{}, // inapplicable to Sei - "excessBlobGas": hexutil.Uint64(0), // inapplicable to Sei - "parentBeaconBlockRoot": common.Hash{}, // inapplicable to Sei + "timestamp": hexutil.Uint64(evt.header.Time.Unix()), //nolint:gosec + "milliTimestamp": hexutil.Uint64(evt.header.Time.UnixMilli()), //nolint:gosec + "transactionsRoot": common.Hash{}, // see function doc + "mixHash": common.Hash{}, // inapplicable to Sei + "excessBlobGas": hexutil.Uint64(0), // inapplicable to Sei + "parentBeaconBlockRoot": common.Hash{}, // inapplicable to Sei "hash": blockHash, "baseFeePerGas": (*hexutil.Big)(baseFee), "withdrawalsRoot": common.Hash{}, // inapplicable to Sei @@ -497,7 +498,8 @@ func encodeTmHeader( "receiptsRoot": resultHash, "sha3Uncles": common.Hash{}, // inapplicable to Sei "stateRoot": appHash, - "timestamp": hexutil.Uint64(header.Header.Time.Unix()), //nolint:gosec + "timestamp": hexutil.Uint64(header.Header.Time.Unix()), //nolint:gosec + "milliTimestamp": hexutil.Uint64(header.Header.Time.UnixMilli()), //nolint:gosec "transactionsRoot": txHash, "mixHash": common.Hash{}, // inapplicable to Sei "excessBlobGas": hexutil.Uint64(0), // inapplicable to Sei diff --git a/evmrpc/subscribe_test.go b/evmrpc/subscribe_test.go index e3a229c561..3af01bd161 100644 --- a/evmrpc/subscribe_test.go +++ b/evmrpc/subscribe_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/sei-protocol/sei-chain/evmrpc" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" @@ -25,7 +26,7 @@ func TestSubscribeNewHeads(t *testing.T) { expectedKeys := []string{ "parentHash", "sha3Uncles", "miner", "stateRoot", "transactionsRoot", "receiptsRoot", "logsBloom", "difficulty", "number", "gasLimit", - "gasUsed", "timestamp", "extraData", "mixHash", "nonce", + "gasUsed", "timestamp", "milliTimestamp", "extraData", "mixHash", "nonce", "baseFeePerGas", "withdrawalsRoot", "blobGasUsed", "excessBlobGas", "parentBeaconBlockRoot", "hash", } @@ -76,6 +77,13 @@ func TestSubscribeNewHeads(t *testing.T) { } } } + // This path's header time is time.Now(), so only the relationship + // between the two timestamp fields is assertable here. + secs, err := hexutil.DecodeUint64(resultMap["timestamp"].(string)) + require.NoError(t, err) + millis, err := hexutil.DecodeUint64(resultMap["milliTimestamp"].(string)) + require.NoError(t, err) + require.Equal(t, secs, millis/1000, "milliTimestamp and timestamp describe the same instant") // Event validated successfully, no need to wait further return case <-timer.C: @@ -97,12 +105,13 @@ func TestSubscribeNewHeadsAutobahn(t *testing.T) { hash := common.HexToHash("0x4242424242424242424242424242424242424242424242424242424242424242").Bytes() appHash := common.HexToHash("0x3131313131313131313131313131313131313131313131313131313131313131").Bytes() proposer := common.HexToAddress("0x9999999999999999999999999999999999999999").Bytes() - ts := time.Unix(1_700_000_500, 0).UTC() + // Fractional second on purpose; see TestEncodeCommittedBlock. + ts := time.Unix(1_700_000_500, 250_000_000).UTC() expectedKeys := []string{ "parentHash", "sha3Uncles", "miner", "stateRoot", "transactionsRoot", "receiptsRoot", "logsBloom", "difficulty", "number", "gasLimit", - "gasUsed", "timestamp", "extraData", "mixHash", "nonce", + "gasUsed", "timestamp", "milliTimestamp", "extraData", "mixHash", "nonce", "baseFeePerGas", "withdrawalsRoot", "blobGasUsed", "excessBlobGas", "parentBeaconBlockRoot", "hash", } @@ -149,6 +158,7 @@ func TestSubscribeNewHeadsAutobahn(t *testing.T) { require.Equal(t, common.BytesToAddress(proposer).Hex(), resultMap["miner"]) require.Equal(t, common.BytesToHash(appHash).Hex(), resultMap["stateRoot"]) require.Equal(t, fmt.Sprintf("0x%x", ts.Unix()), resultMap["timestamp"]) + require.Equal(t, fmt.Sprintf("0x%x", ts.UnixMilli()), resultMap["milliTimestamp"]) require.Equal(t, fmt.Sprintf("0x%x", 21000+50000), resultMap["gasUsed"]) // gasLimit comes from the SDK ConsensusParams that the test // runtime sets; just assert it's a non-zero hex string. diff --git a/evmrpc/tests/block_test.go b/evmrpc/tests/block_test.go index ed31b3b5f4..c93fedf352 100644 --- a/evmrpc/tests/block_test.go +++ b/evmrpc/tests/block_test.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/bitutil" + "github.com/ethereum/go-ethereum/common/hexutil" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/sei-protocol/sei-chain/app" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -52,6 +53,33 @@ func TestGetBlockByNumber(t *testing.T) { ) } +// Covers the BEP-520-compatible milliTimestamp over the real JSON-RPC transport, including the +// synthetic genesis block, which is encoded by a different function than the rest. +// +// This pins encoding and presence at second granularity only: mockBlockHeader builds +// its time with time.Unix(_, 0), so UnixMilli() here is just Unix()*1000, and its +// nanoseconds cannot be changed without moving the block hashes the rest of this +// package asserts against. The millisecond value is proven where a fractional-second +// header is available: TestEncodeTmBlockMilliTimestamp for the encoder, and +// TestSubscribeNewHeadsAutobahn over the WS transport. +func TestGetBlockMilliTimestamp(t *testing.T) { + txBz := signAndEncodeTx(send(0), mnemonic1) + SetupTestServer(t, [][][]byte{{txBz}}, mnemonicInitializer(mnemonic1)).Run( + func(port int) { + res := sendRequestWithNamespace("eth", port, "getBlockByNumber", "0x2", false) + block := res["result"].(map[string]interface{}) + blockTime := mockBlockHeader(2).Time + require.Equal(t, hexutil.EncodeUint64(uint64(blockTime.Unix())), block["timestamp"]) + require.Equal(t, hexutil.EncodeUint64(uint64(blockTime.UnixMilli())), block["milliTimestamp"]) + + res = sendRequestWithNamespace("eth", port, "getBlockByNumber", "earliest", false) + genesis := res["result"].(map[string]interface{}) + require.Equal(t, "0x0", genesis["timestamp"]) + require.Equal(t, "0x0", genesis["milliTimestamp"]) + }, + ) +} + func TestGetBlockSkipTxIndex(t *testing.T) { tx1 := signAndEncodeCosmosTx(bankSendMsg(mnemonic1), mnemonic1, 7, 0) tx2 := signAndEncodeTx(send(0), mnemonic1) diff --git a/integration_test/evm_module/ws_test/ws_test.go b/integration_test/evm_module/ws_test/ws_test.go index 2db984a66f..beb017fad6 100644 --- a/integration_test/evm_module/ws_test/ws_test.go +++ b/integration_test/evm_module/ws_test/ws_test.go @@ -107,16 +107,18 @@ func TestEthSubscribeNewHeads(t *testing.T) { note.Params.Subscription, ack.Result) } header := note.Params.Result - for _, key := range []string{"hash", "number", "timestamp", "stateRoot", "miner"} { + // An all-zero value for one of these would indicate the producer hook didn't + // fire and we're seeing a default-constructed header. + mustBeNonZero := map[string]bool{ + "hash": true, "number": true, "timestamp": true, "milliTimestamp": true, + } + for _, key := range []string{"hash", "number", "timestamp", "milliTimestamp", "stateRoot", "miner"} { v, ok := header[key] if !ok { t.Fatalf("head notification missing key %q (got %+v)", key, header) } - // All-zero values for hash, number, or timestamp would indicate - // the producer hook didn't fire and we're seeing a default- - // constructed header. if s, _ := v.(string); s == "" || s == "0x" || s == "0x0" { - if key == "hash" || key == "number" || key == "timestamp" { + if mustBeNonZero[key] { t.Fatalf("head notification %q has zero-ish value %q", key, s) } } diff --git a/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts b/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts index b3d6aaa311..6c2b7d2fa9 100644 --- a/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts +++ b/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts @@ -10,6 +10,7 @@ import { sendRevertingTx, signBelowIntrinsicTx, assertCanonicalHeader, + assertSeiMilliTimestamp, assertCanonicalTx, assertGasAccounting, assertActualBytesAndSize, @@ -72,6 +73,7 @@ describe('eth_getBlockByHash', function () { it('returns every canonical header field and echoes the requested hash', async () => { const block = await byHash(sei, richSei.hash, false); assertCanonicalHeader(block, { hasTxs: true }); + assertSeiMilliTimestamp(block); expect(block.hash).to.equal(richSei.hash); expect(BigInt(block.number)).to.equal(BigInt(richSei.number)); }); diff --git a/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts b/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts index dfce91b805..73d775ee9d 100644 --- a/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts +++ b/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts @@ -10,6 +10,7 @@ import { sendRevertingTx, signBelowIntrinsicTx, assertCanonicalHeader, + assertSeiMilliTimestamp, assertCanonicalTx, assertTxTypeSchema, assertGasAccounting, @@ -82,6 +83,19 @@ describe('eth_getBlockByNumber', function () { expect(block.parentHash).to.equal(parent.hash); expect(BigInt(block.timestamp) >= BigInt(parent.timestamp)).to.equal(true); }); + + it('carries the BEP-520-compatible millisecond timestamp', async () => { + const [block, parent] = await Promise.all([ + getBlock(sei, richSei.number, false), + getBlock(sei, richSei.number - 1, false), + ]); + assertSeiMilliTimestamp(block); + assertSeiMilliTimestamp(parent); + expect( + BigInt(block.milliTimestamp!) >= BigInt(parent.milliTimestamp!), + 'milliTimestamp is non-decreasing along the chain', + ).to.equal(true); + }); }); describe('transactions array (hashes vs full objects)', () => { diff --git a/integration_test/rpc_tests/utils/subscribeUtils.ts b/integration_test/rpc_tests/utils/subscribeUtils.ts index 208334e9aa..7667eacc30 100644 --- a/integration_test/rpc_tests/utils/subscribeUtils.ts +++ b/integration_test/rpc_tests/utils/subscribeUtils.ts @@ -109,6 +109,7 @@ export const NEW_HEAD_FIELDS = [ 'sha3Uncles', 'stateRoot', 'timestamp', + 'milliTimestamp', 'transactionsRoot', 'withdrawalsRoot', ] as const; @@ -118,6 +119,7 @@ const HEAD_QUANTITY_FIELDS = [ 'gasLimit', 'gasUsed', 'timestamp', + 'milliTimestamp', 'difficulty', 'baseFeePerGas', 'blobGasUsed', @@ -172,6 +174,9 @@ export function assertNewHeadMatchesBlock(head: any, block: any): void { expect(BigInt(head.timestamp), 'timestamp matches the canonical block').to.equal( BigInt(block.timestamp), ); + expect(BigInt(head.milliTimestamp), 'milliTimestamp matches the canonical block').to.equal( + BigInt(block.milliTimestamp), + ); expect(BigInt(head.gasLimit), 'gasLimit matches the canonical block').to.equal( BigInt(block.gasLimit), ); diff --git a/integration_test/rpc_tests/utils/txUtils.ts b/integration_test/rpc_tests/utils/txUtils.ts index addc0bcb83..6a45d8ef9b 100644 --- a/integration_test/rpc_tests/utils/txUtils.ts +++ b/integration_test/rpc_tests/utils/txUtils.ts @@ -44,7 +44,7 @@ export const CORE_BLOCK_FIELDS = [ 'uncles', ] as const; -export const SEI_ONLY_BLOCK_FIELDS = ['totalDifficulty'] as const; +export const SEI_ONLY_BLOCK_FIELDS = ['milliTimestamp', 'totalDifficulty'] as const; export const GETH_ONLY_BLOCK_FIELDS = [ 'blobGasUsed', 'excessBlobGas', @@ -167,6 +167,8 @@ export interface RpcBlock { gasLimit: string; gasUsed: string; timestamp: string; + /** BEP-520-compatible; geth blocks parsed into this shape do not carry it. */ + milliTimestamp?: string; baseFeePerGas: string; uncles: string[]; transactions: (string | RpcTx)[]; @@ -727,6 +729,20 @@ export function assertCanonicalHeader(block: RpcBlock, opts: { hasTxs: boolean } } } +/** + * Assert the BEP-520-compatible `milliTimestamp`. Sei commits blocks faster than once a second, so + * `timestamp` — whole seconds, as every Ethereum client reads it — cannot separate + * consecutive blocks. geth has no counterpart, so there is nothing to cross-check against; + * the invariant is that the two fields describe the same instant. + */ +export function assertSeiMilliTimestamp(block: RpcBlock): void { + expect(block, 'header is missing milliTimestamp').to.have.property('milliTimestamp'); + expect(block.milliTimestamp, 'milliTimestamp is a canonical quantity').to.match(HEX_QUANTITY); + expect(BigInt(block.milliTimestamp!) / 1000n, 'milliTimestamp agrees with timestamp').to.equal( + BigInt(block.timestamp), + ); +} + // Fields present on every transaction object regardless of type (legacy type-0 has // no accessList / maxFeePerGas, so those live in the type-2 CORE_TX_FIELDS set used // only by the geth parity comparison).