Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
2bd5510
feat(sensor): broadcast block, txs, and hashes
minhd-vu Oct 14, 2025
7e19682
Merge branch 'main' into minhd-vu/sensor-broadcast
minhd-vu Jan 20, 2026
ae4bc71
chore: revert ulxly changes to match main
minhd-vu Jan 20, 2026
1eafffd
fix: make gen
minhd-vu Jan 20, 2026
006e1f7
Merge branch 'main' into minhd-vu/sensor-broadcast
minhd-vu Feb 19, 2026
9b73909
fix: comments
minhd-vu Feb 19, 2026
bf9719a
fix: flag name
minhd-vu Feb 19, 2026
1e2306a
fix: expiresAt
minhd-vu Feb 19, 2026
8041cc6
fix: graceful shutdown
minhd-vu Feb 19, 2026
5409e0b
fix: handle disconnects for peer conns
minhd-vu Feb 19, 2026
c474e29
fix: call cancel()
minhd-vu Feb 20, 2026
6493219
fix: revert some graceful shutdown handling
minhd-vu Feb 20, 2026
1ef0115
fix: revert rpc.go
minhd-vu Feb 20, 2026
7a25ede
fix: revert again
minhd-vu Feb 20, 2026
b33386c
fix: one more revert
minhd-vu Feb 20, 2026
5f6fd24
fix: lint
minhd-vu Feb 20, 2026
0aec080
fix: rename
minhd-vu Feb 20, 2026
7fa8aae
fix: async tx announcements and goroutine cleanup
minhd-vu Feb 25, 2026
be15860
Merge branch 'main' into minhd-vu/sensor-broadcast
minhd-vu Feb 25, 2026
5d4ce45
fix: remove comment
minhd-vu Feb 25, 2026
865141a
feat(sensor): support more rpc methods
minhd-vu Feb 20, 2026
6db99fa
Merge branch 'main' into minhd-vu/sensor-broadcast
minhd-vu Feb 28, 2026
3b66e34
fix: lint
minhd-vu Feb 28, 2026
840ead4
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Feb 28, 2026
97b6e01
docs: make gen
minhd-vu Feb 28, 2026
1499508
Merge branch 'main' into minhd-vu/sensor-broadcast
minhd-vu Mar 2, 2026
da98090
fix: lint
minhd-vu Mar 2, 2026
b0e0544
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Mar 2, 2026
15494bd
fix: lint
minhd-vu Mar 2, 2026
743f953
fix: protocol lengths
minhd-vu Mar 2, 2026
e68b0ba
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Mar 2, 2026
9ccc61d
fix: use bor status packet
minhd-vu Mar 2, 2026
8a107c6
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Mar 2, 2026
6584163
feat: enable pprof lock profiling
minhd-vu Mar 3, 2026
832a463
fix: lock contention
minhd-vu Mar 3, 2026
2eed8ea
fix: use min
minhd-vu Mar 3, 2026
dfcabbd
fix: increase tx cache size
minhd-vu Mar 3, 2026
25ef16c
feat: add protocol version
minhd-vu Mar 3, 2026
abf1c2b
fix: protocol
minhd-vu Mar 3, 2026
0b87513
feat: improve logging
minhd-vu Mar 3, 2026
1af2505
docs: make gen
minhd-vu Mar 3, 2026
95a00e2
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Mar 3, 2026
cf1dae5
fix: merge conflicts
minhd-vu Mar 3, 2026
5d89c67
feat: bloomset
minhd-vu Mar 3, 2026
4408b34
feat: datastructures package
minhd-vu Mar 3, 2026
0b1b33a
Merge branch 'main' into minhd-vu/sensor-broadcast
minhd-vu Mar 3, 2026
2f5585c
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Mar 3, 2026
99a460c
fix: improve lru
minhd-vu Mar 3, 2026
e647649
fix: lint
minhd-vu Mar 3, 2026
ea8cd0f
fix: lint
minhd-vu Mar 3, 2026
9c807af
fix: suggestions
minhd-vu Mar 3, 2026
65e07d1
fix: remove unused
minhd-vu Mar 3, 2026
3b82419
feat: peek many with keys
minhd-vu Mar 3, 2026
2d0e747
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Mar 3, 2026
73bbd26
fix: missing functions
minhd-vu Mar 3, 2026
00de538
fix: goroutine leak
minhd-vu Mar 3, 2026
9fe6c08
Merge branch 'minhd-vu/sensor-broadcast' into minhd-vu/rpc-methods
minhd-vu Mar 3, 2026
c46e2e8
fix: gas methods
minhd-vu Mar 3, 2026
a48a0fe
fix: cleanup
minhd-vu Mar 3, 2026
015df74
Merge branch 'main' into minhd-vu/sensor-broadcast
minhd-vu Mar 4, 2026
73a6c0a
fix: add space
minhd-vu Mar 4, 2026
b33b162
fix: add space in lru
minhd-vu Mar 4, 2026
e58a58f
fix: lint
minhd-vu Mar 4, 2026
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
575 changes: 463 additions & 112 deletions cmd/p2p/sensor/rpc.go

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions cmd/p2p/sensor/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@ created automatically.
The bootnodes may change, so refer to the [Polygon Knowledge Layer][bootnodes]
if the sensor is not discovering peers.

## JSON-RPC Server

The sensor runs a JSON-RPC server on port 8545 (configurable via `--rpc-port`)
that supports a subset of Ethereum JSON-RPC methods using cached data.

### Supported Methods

| Method | Description |
|--------|-------------|
| `eth_chainId` | Returns the chain ID |
| `eth_blockNumber` | Returns the current head block number |
| `eth_gasPrice` | Returns suggested gas price based on recent blocks |
| `eth_getBlockByHash` | Returns block by hash |
| `eth_getBlockByNumber` | Returns block by number (if cached) |
| `eth_getTransactionByHash` | Returns transaction by hash |
| `eth_getTransactionByBlockHashAndIndex` | Returns transaction at index in block |
| `eth_getBlockTransactionCountByHash` | Returns transaction count in block |
| `eth_getUncleCountByBlockHash` | Returns uncle count in block |
| `eth_sendRawTransaction` | Broadcasts signed transaction to peers |

### Limitations

Methods requiring state or receipts are not supported:
- `eth_getBalance`, `eth_getCode`, `eth_call`, `eth_estimateGas`
- `eth_getTransactionReceipt`, `eth_getLogs`

Data is served from an LRU cache, so older blocks/transactions may not be available.

## Metrics

The sensor exposes Prometheus metrics at `http://localhost:2112/metrics`
Expand Down
28 changes: 28 additions & 0 deletions doc/polycli_p2p_sensor.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@ created automatically.
The bootnodes may change, so refer to the [Polygon Knowledge Layer][bootnodes]
if the sensor is not discovering peers.

## JSON-RPC Server

The sensor runs a JSON-RPC server on port 8545 (configurable via `--rpc-port`)
that supports a subset of Ethereum JSON-RPC methods using cached data.

### Supported Methods

| Method | Description |
|--------|-------------|
| `eth_chainId` | Returns the chain ID |
| `eth_blockNumber` | Returns the current head block number |
| `eth_gasPrice` | Returns suggested gas price based on recent blocks |
| `eth_getBlockByHash` | Returns block by hash |
| `eth_getBlockByNumber` | Returns block by number (if cached) |
| `eth_getTransactionByHash` | Returns transaction by hash |
| `eth_getTransactionByBlockHashAndIndex` | Returns transaction at index in block |
| `eth_getBlockTransactionCountByHash` | Returns transaction count in block |
| `eth_getUncleCountByBlockHash` | Returns uncle count in block |
| `eth_sendRawTransaction` | Broadcasts signed transaction to peers |

### Limitations

Methods requiring state or receipts are not supported:
- `eth_getBalance`, `eth_getCode`, `eth_call`, `eth_estimateGas`
- `eth_getTransactionReceipt`, `eth_getLogs`

Data is served from an LRU cache, so older blocks/transactions may not be available.

## Metrics

The sensor exposes Prometheus metrics at `http://localhost:2112/metrics`
Expand Down
18 changes: 18 additions & 0 deletions p2p/conns.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ func (c *Conns) AddTxs(txs []*types.Transaction) []common.Hash {
return hashes
}

// GetTx retrieves a transaction from the shared cache and updates LRU ordering.
func (c *Conns) GetTx(hash common.Hash) (*types.Transaction, bool) {
return c.txs.Get(hash)
}

// PeekTxs retrieves multiple transactions from the shared cache without updating LRU ordering.
// Uses a single read lock for better concurrency when LRU ordering is not needed.
func (c *Conns) PeekTxs(hashes []common.Hash) []*types.Transaction {
Expand Down Expand Up @@ -431,6 +436,19 @@ func (c *Conns) GetPeerName(peerID string) string {
return ""
}

// GetBlockByNumber iterates through the cache to find a block by its number.
// Returns the hash, block cache, and true if found; empty values and false otherwise.
func (c *Conns) GetBlockByNumber(number uint64) (common.Hash, BlockCache, bool) {
for _, hash := range c.blocks.Keys() {
if cache, ok := c.blocks.Peek(hash); ok && cache.Header != nil {
if cache.Header.Number.Uint64() == number {
return hash, cache, true
}
}
}
return common.Hash{}, BlockCache{}, false
}

// GetPeerVersion returns the negotiated eth protocol version for a specific peer.
// Returns 0 if the peer is not found.
func (c *Conns) GetPeerVersion(peerID string) uint {
Expand Down
13 changes: 13 additions & 0 deletions p2p/datastructures/lru.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,19 @@ func (c *LRU[K, V]) Remove(key K) (V, bool) {
return zero, false
}

// Keys returns all keys in the cache in LRU order (most recent first).
func (c *LRU[K, V]) Keys() []K {
c.mu.RLock()
defer c.mu.RUnlock()

keys := make([]K, 0, c.list.Len())
for elem := c.list.Front(); elem != nil; elem = elem.Next() {
e := elem.Value.(*entry[K, V])
keys = append(keys, e.key)
}
return keys
}

// AddBatch adds multiple key-value pairs to the cache.
// Uses a single write lock for all additions, reducing lock contention
// compared to calling Add in a loop. Keys and values must have the same length.
Expand Down
256 changes: 256 additions & 0 deletions p2p/gasprice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
package p2p

import (
"math/big"
"sort"
"sync"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)

// Gas price oracle constants (matching Bor/geth defaults)
const (
// gpoSampleNumber is the number of transactions to sample per block
gpoSampleNumber = 3
// gpoCheckBlocks is the number of blocks to check for gas price estimation
gpoCheckBlocks = 20
// gpoPercentile is the percentile to use for gas price estimation
gpoPercentile = 60
)

var (
// gpoMaxPrice is the maximum gas price to suggest (500 gwei)
gpoMaxPrice = big.NewInt(500_000_000_000)
// gpoIgnorePrice is the minimum tip to consider (2 gwei, lower than Bor's 25 gwei for broader network compatibility)
gpoIgnorePrice = big.NewInt(2_000_000_000)
// gpoDefaultPrice is the default gas price when no data is available (1 gwei)
gpoDefaultPrice = big.NewInt(1_000_000_000)
)

// GasPriceOracle estimates gas prices based on recent block data.
// It follows Bor/geth's gas price oracle approach.
type GasPriceOracle struct {
conns *Conns

mu sync.RWMutex
lastHead common.Hash
lastTip *big.Int
}

// NewGasPriceOracle creates a new gas price oracle that uses the given Conns for block data.
func NewGasPriceOracle(conns *Conns) *GasPriceOracle {
return &GasPriceOracle{
conns: conns,
}
}

// SuggestGasPrice estimates the gas price based on recent blocks.
// For EIP-1559 networks, this returns baseFee + suggestedTip.
// For legacy networks, this returns the 60th percentile of gas prices.
func (o *GasPriceOracle) SuggestGasPrice() *big.Int {
head := o.conns.HeadBlock()
if head.Block == nil {
return gpoDefaultPrice
}

// For EIP-1559: return baseFee + suggested tip
if baseFee := head.Block.BaseFee(); baseFee != nil {
tip := o.SuggestGasTipCap()
if tip == nil {
tip = gpoDefaultPrice
}
return new(big.Int).Add(baseFee, tip)
}

// Legacy: return percentile of gas prices
return o.suggestLegacyGasPrice()
}

// suggestLegacyGasPrice estimates gas price for pre-EIP-1559 networks.
func (o *GasPriceOracle) suggestLegacyGasPrice() *big.Int {
keys := o.conns.blocks.Keys()
if len(keys) == 0 {
return gpoDefaultPrice
}

if len(keys) > gpoCheckBlocks {
keys = keys[:gpoCheckBlocks]
}

var prices []*big.Int
for _, hash := range keys {
cache, ok := o.conns.blocks.Peek(hash)
if !ok || cache.Body == nil {
continue
}

for _, tx := range cache.Body.Transactions {
if price := tx.GasPrice(); price != nil && price.Sign() > 0 {
prices = append(prices, new(big.Int).Set(price))
}
}
}

if len(prices) == 0 {
return gpoDefaultPrice
}

sort.Slice(prices, func(i, j int) bool {
return prices[i].Cmp(prices[j]) < 0
})

price := prices[(len(prices)-1)*gpoPercentile/100]
if price.Cmp(gpoMaxPrice) > 0 {
return new(big.Int).Set(gpoMaxPrice)
}
return price
}

// SuggestGasTipCap estimates a gas tip cap (priority fee) based on recent blocks.
// This implementation follows Bor/geth's gas price oracle approach:
// - Samples the lowest N tips from each of the last M blocks
// - Ignores tips below a threshold
// - Returns the configured percentile of collected tips
// - Caches results until head changes
func (o *GasPriceOracle) SuggestGasTipCap() *big.Int {
head := o.conns.HeadBlock()
if head.Block == nil {
return nil
}
headHash := head.Block.Hash()

// Check cache first
o.mu.RLock()
if headHash == o.lastHead && o.lastTip != nil {
tip := new(big.Int).Set(o.lastTip)
o.mu.RUnlock()
return tip
}
lastTip := o.lastTip
o.mu.RUnlock()

// Collect tips from recent blocks
keys := o.conns.blocks.Keys()
if len(keys) == 0 {
return lastTip
}

// Limit to checkBlocks most recent
if len(keys) > gpoCheckBlocks {
keys = keys[:gpoCheckBlocks]
}

var results []*big.Int
for _, hash := range keys {
tips := o.getBlockTips(hash, gpoSampleNumber, gpoIgnorePrice)
if len(tips) == 0 && lastTip != nil {
// Empty block or all tips below threshold, use last tip
tips = []*big.Int{lastTip}
}
results = append(results, tips...)
}

if len(results) == 0 {
return lastTip
}

// Sort and get percentile
sort.Slice(results, func(i, j int) bool {
return results[i].Cmp(results[j]) < 0
})
tip := results[(len(results)-1)*gpoPercentile/100]

// Apply max price cap
if tip.Cmp(gpoMaxPrice) > 0 {
tip = new(big.Int).Set(gpoMaxPrice)
}

// Cache result
o.mu.Lock()
o.lastHead = headHash
o.lastTip = tip
o.mu.Unlock()

return new(big.Int).Set(tip)
}

// getBlockTips returns the lowest N tips from a block that are above the ignore threshold.
// Transactions are sorted by effective tip ascending, and the first N valid tips are returned.
func (o *GasPriceOracle) getBlockTips(hash common.Hash, limit int, ignoreUnder *big.Int) []*big.Int {
cache, ok := o.conns.blocks.Peek(hash)
if !ok || cache.Body == nil || cache.Header == nil {
return nil
}

baseFee := cache.Header.BaseFee
if baseFee == nil {
return nil // Pre-EIP-1559 block
}

// Calculate tips for all transactions
var allTips []*big.Int
for _, tx := range cache.Body.Transactions {
tip := effectiveGasTip(tx, baseFee)
if tip != nil && tip.Sign() > 0 {
allTips = append(allTips, tip)
}
}

if len(allTips) == 0 {
return nil
}

// Sort by tip ascending (lowest first, like Bor)
sort.Slice(allTips, func(i, j int) bool {
return allTips[i].Cmp(allTips[j]) < 0
})

// Collect tips above threshold, up to limit
var tips []*big.Int
for _, tip := range allTips {
if ignoreUnder != nil && tip.Cmp(ignoreUnder) < 0 {
continue
}
tips = append(tips, tip)
if len(tips) >= limit {
break
}
}

return tips
}

// effectiveGasTip returns the effective tip (priority fee) for a transaction.
// For EIP-1559 transactions: min(maxPriorityFeePerGas, maxFeePerGas - baseFee)
// For legacy transactions: gasPrice - baseFee (the implicit tip)
// Returns nil if the tip cannot be determined or is negative.
func effectiveGasTip(tx *types.Transaction, baseFee *big.Int) *big.Int {
switch tx.Type() {
case types.DynamicFeeTxType, types.BlobTxType:
tip := tx.GasTipCap()
if tip == nil {
return nil
}
// Effective tip is min(maxPriorityFeePerGas, maxFeePerGas - baseFee)
if tx.GasFeeCap() != nil {
effectiveTip := new(big.Int).Sub(tx.GasFeeCap(), baseFee)
if effectiveTip.Cmp(tip) < 0 {
tip = effectiveTip
}
}
if tip.Sign() <= 0 {
return nil
}
return new(big.Int).Set(tip)
default:
// Legacy/AccessList transactions: tip is gasPrice - baseFee
if price := tx.GasPrice(); price != nil {
tip := new(big.Int).Sub(price, baseFee)
if tip.Sign() > 0 {
return tip
}
}
return nil
}
}
Loading