Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions evmrpc/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
16 changes: 11 additions & 5 deletions evmrpc/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions evmrpc/block_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
6 changes: 5 additions & 1 deletion evmrpc/notifier_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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"])
Expand Down
14 changes: 8 additions & 6 deletions evmrpc/subscribe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions evmrpc/subscribe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
}
Expand Down Expand Up @@ -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:
Expand All @@ -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",
}
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions evmrpc/tests/block_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions integration_test/evm_module/ws_test/ws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
2 changes: 2 additions & 0 deletions integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
sendRevertingTx,
signBelowIntrinsicTx,
assertCanonicalHeader,
assertSeiMilliTimestamp,
assertCanonicalTx,
assertGasAccounting,
assertActualBytesAndSize,
Expand Down Expand Up @@ -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));
});
Expand Down
14 changes: 14 additions & 0 deletions integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
sendRevertingTx,
signBelowIntrinsicTx,
assertCanonicalHeader,
assertSeiMilliTimestamp,
assertCanonicalTx,
assertTxTypeSchema,
assertGasAccounting,
Expand Down Expand Up @@ -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)', () => {
Expand Down
5 changes: 5 additions & 0 deletions integration_test/rpc_tests/utils/subscribeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const NEW_HEAD_FIELDS = [
'sha3Uncles',
'stateRoot',
'timestamp',
'milliTimestamp',
'transactionsRoot',
'withdrawalsRoot',
] as const;
Expand All @@ -118,6 +119,7 @@ const HEAD_QUANTITY_FIELDS = [
'gasLimit',
'gasUsed',
'timestamp',
'milliTimestamp',
'difficulty',
'baseFeePerGas',
'blobGasUsed',
Expand Down Expand Up @@ -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),
);
Expand Down
18 changes: 17 additions & 1 deletion integration_test/rpc_tests/utils/txUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)[];
Expand Down Expand Up @@ -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).
Expand Down
Loading