websocket: add transactionReceipts for receipts notification

This commit is contained in:
10gic 2025-09-21 22:22:27 +08:00
parent 4414e2833f
commit 118155b565
4 changed files with 320 additions and 4 deletions

View file

@ -43,6 +43,7 @@ var (
errPendingLogsUnsupported = errors.New("pending logs are not supported") errPendingLogsUnsupported = errors.New("pending logs are not supported")
errExceedMaxTopics = errors.New("exceed max topics") errExceedMaxTopics = errors.New("exceed max topics")
errExceedLogQueryLimit = errors.New("exceed max addresses or topics per search position") errExceedLogQueryLimit = errors.New("exceed max addresses or topics per search position")
errExceedMaxTxHashes = errors.New("exceed max number of transaction hashes allowed per transactionReceipts subscription")
) )
const ( const (
@ -50,6 +51,8 @@ const (
maxTopics = 4 maxTopics = 4
// The maximum number of allowed topics within a topic criteria // The maximum number of allowed topics within a topic criteria
maxSubTopics = 1000 maxSubTopics = 1000
// The maximum number of transaction hash criteria allowed in a single subscription
maxTxHashes = 200
) )
// filter is a helper struct that holds meta information over the filter type // filter is a helper struct that holds meta information over the filter type
@ -295,6 +298,71 @@ func (api *FilterAPI) Logs(ctx context.Context, crit FilterCriteria) (*rpc.Subsc
return rpcSub, nil return rpcSub, nil
} }
// TransactionReceiptsFilter defines criteria for transaction receipts subscription.
// If TransactionHashes is nil or empty, receipts for all transactions included in new blocks will be delivered.
// Otherwise, only receipts for the specified transactions will be delivered.
type TransactionReceiptsFilter struct {
TransactionHashes []common.Hash `json:"transactionHashes,omitempty"`
}
// TransactionReceipts creates a subscription that fires transaction receipts when transactions are included in blocks.
func (api *FilterAPI) TransactionReceipts(ctx context.Context, filter *TransactionReceiptsFilter) (*rpc.Subscription, error) {
notifier, supported := rpc.NotifierFromContext(ctx)
if !supported {
return &rpc.Subscription{}, rpc.ErrNotificationsUnsupported
}
// Validate transaction hashes limit
if filter != nil && len(filter.TransactionHashes) > maxTxHashes {
return nil, errExceedMaxTxHashes
}
var (
rpcSub = notifier.CreateSubscription()
filteredReceipts = make(chan []*ReceiptWithTx)
txHashes []common.Hash
)
if filter != nil {
txHashes = filter.TransactionHashes
}
receiptsSub := api.events.SubscribeTransactionReceipts(txHashes, filteredReceipts)
go func() {
defer receiptsSub.Unsubscribe()
signer := types.LatestSigner(api.sys.backend.ChainConfig())
for {
select {
case receiptsWithTx := <-filteredReceipts:
if len(receiptsWithTx) > 0 {
// Convert to the same format as eth_getTransactionReceipt
marshaledReceipts := make([]map[string]interface{}, len(receiptsWithTx))
for i, receiptWithTx := range receiptsWithTx {
marshaledReceipts[i] = ethapi.MarshalReceipt(
receiptWithTx.Receipt,
receiptWithTx.Receipt.BlockHash,
receiptWithTx.Receipt.BlockNumber.Uint64(),
signer,
receiptWithTx.Transaction,
int(receiptWithTx.Receipt.TransactionIndex),
)
}
// Send a batch of tx receipts in one notification
notifier.Notify(rpcSub.ID, marshaledReceipts)
}
case <-rpcSub.Err():
return
}
}
}()
return rpcSub, nil
}
// FilterCriteria represents a request to create a new filter. // FilterCriteria represents a request to create a new filter.
// Same as ethereum.FilterQuery but with UnmarshalJSON() method. // Same as ethereum.FilterQuery but with UnmarshalJSON() method.
type FilterCriteria ethereum.FilterQuery type FilterCriteria ethereum.FilterQuery

View file

@ -158,6 +158,8 @@ const (
PendingTransactionsSubscription PendingTransactionsSubscription
// BlocksSubscription queries hashes for blocks that are imported // BlocksSubscription queries hashes for blocks that are imported
BlocksSubscription BlocksSubscription
// TransactionReceiptsSubscription queries for transaction receipts when transactions are included in blocks
TransactionReceiptsSubscription
// LastIndexSubscription keeps track of the last index // LastIndexSubscription keeps track of the last index
LastIndexSubscription LastIndexSubscription
) )
@ -182,6 +184,8 @@ type subscription struct {
logs chan []*types.Log logs chan []*types.Log
txs chan []*types.Transaction txs chan []*types.Transaction
headers chan *types.Header headers chan *types.Header
receipts chan []*ReceiptWithTx
txHashes []common.Hash // contains transaction hashes for transactionReceipts subscription filtering
installed chan struct{} // closed when the filter is installed installed chan struct{} // closed when the filter is installed
err chan error // closed when the filter is uninstalled err chan error // closed when the filter is uninstalled
} }
@ -268,6 +272,7 @@ func (sub *Subscription) Unsubscribe() {
case <-sub.f.logs: case <-sub.f.logs:
case <-sub.f.txs: case <-sub.f.txs:
case <-sub.f.headers: case <-sub.f.headers:
case <-sub.f.receipts:
} }
} }
@ -353,6 +358,7 @@ func (es *EventSystem) subscribeLogs(crit ethereum.FilterQuery, logs chan []*typ
logs: logs, logs: logs,
txs: make(chan []*types.Transaction), txs: make(chan []*types.Transaction),
headers: make(chan *types.Header), headers: make(chan *types.Header),
receipts: make(chan []*ReceiptWithTx),
installed: make(chan struct{}), installed: make(chan struct{}),
err: make(chan error), err: make(chan error),
} }
@ -369,6 +375,7 @@ func (es *EventSystem) SubscribeNewHeads(headers chan *types.Header) *Subscripti
logs: make(chan []*types.Log), logs: make(chan []*types.Log),
txs: make(chan []*types.Transaction), txs: make(chan []*types.Transaction),
headers: headers, headers: headers,
receipts: make(chan []*ReceiptWithTx),
installed: make(chan struct{}), installed: make(chan struct{}),
err: make(chan error), err: make(chan error),
} }
@ -385,6 +392,26 @@ func (es *EventSystem) SubscribePendingTxs(txs chan []*types.Transaction) *Subsc
logs: make(chan []*types.Log), logs: make(chan []*types.Log),
txs: txs, txs: txs,
headers: make(chan *types.Header), headers: make(chan *types.Header),
receipts: make(chan []*ReceiptWithTx),
installed: make(chan struct{}),
err: make(chan error),
}
return es.subscribe(sub)
}
// SubscribeTransactionReceipts creates a subscription that writes transaction receipts for
// transactions when they are included in blocks. If txHashes is provided, only receipts
// for those specific transaction hashes will be delivered.
func (es *EventSystem) SubscribeTransactionReceipts(txHashes []common.Hash, receipts chan []*ReceiptWithTx) *Subscription {
sub := &subscription{
id: rpc.NewID(),
typ: TransactionReceiptsSubscription,
created: time.Now(),
logs: make(chan []*types.Log),
txs: make(chan []*types.Transaction),
headers: make(chan *types.Header),
receipts: receipts,
txHashes: txHashes,
installed: make(chan struct{}), installed: make(chan struct{}),
err: make(chan error), err: make(chan error),
} }
@ -415,6 +442,96 @@ func (es *EventSystem) handleChainEvent(filters filterIndex, ev core.ChainEvent)
for _, f := range filters[BlocksSubscription] { for _, f := range filters[BlocksSubscription] {
f.headers <- ev.Header f.headers <- ev.Header
} }
// Handle transaction receipts subscriptions when a new block is added
es.handleReceiptsEvent(filters, ev)
}
// ReceiptWithTx contains a receipt and its corresponding transaction for websocket subscription
type ReceiptWithTx struct {
Receipt *types.Receipt
Transaction *types.Transaction
}
func (es *EventSystem) handleReceiptsEvent(filters filterIndex, ev core.ChainEvent) {
// If there are no transaction receipt subscriptions, skip processing
if len(filters[TransactionReceiptsSubscription]) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Get receipts for this block
receipts, err := es.backend.GetReceipts(ctx, ev.Header.Hash())
if err != nil {
log.Warn("Failed to get receipts for block", "hash", ev.Header.Hash(), "err", err)
return
}
// Get body to retrieve transactions
body, err := es.backend.GetBody(ctx, ev.Header.Hash(), rpc.BlockNumber(ev.Header.Number.Int64()))
if err != nil {
log.Warn("Failed to get block for receipts", "hash", ev.Header.Hash(), "err", err)
return
}
txs := body.Transactions
if len(txs) != len(receipts) {
log.Warn("Transaction count mismatch", "txs", len(txs), "receipts", len(receipts))
return
}
for _, f := range filters[TransactionReceiptsSubscription] {
var filteredReceipts []*ReceiptWithTx
if len(f.txHashes) == 0 {
// No filter, send all receipts with their transactions.
filteredReceipts = make([]*ReceiptWithTx, len(receipts))
for i, receipt := range receipts {
filteredReceipts[i] = &ReceiptWithTx{
Receipt: receipt,
Transaction: txs[i],
}
}
} else if len(f.txHashes) == 1 {
// Filter by single transaction hash.
// This is a common case, so we distinguish it from filtering by multiple tx hashes and made a small optimization.
for i, receipt := range receipts {
if receipt.TxHash == f.txHashes[0] {
filteredReceipts = append(filteredReceipts, &ReceiptWithTx{
Receipt: receipt,
Transaction: txs[i],
})
break
}
}
} else {
// Filter by multiple transaction hashes.
txHashMap := make(map[common.Hash]bool, len(f.txHashes))
for _, hash := range f.txHashes {
txHashMap[hash] = true
}
for i, receipt := range receipts {
if txHashMap[receipt.TxHash] {
filteredReceipts = append(filteredReceipts, &ReceiptWithTx{
Receipt: receipt,
Transaction: txs[i],
})
// Early exit if all receipts are found
if len(filteredReceipts) == len(f.txHashes) {
break
}
}
}
}
if len(filteredReceipts) > 0 {
f.receipts <- filteredReceipts
}
}
} }
// eventLoop (un)installs filters and processes mux events. // eventLoop (un)installs filters and processes mux events.

View file

@ -31,6 +31,7 @@ import (
"github.com/ethereum/go-ethereum/core/filtermaps" "github.com/ethereum/go-ethereum/core/filtermaps"
"github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/internal/ethapi" "github.com/ethereum/go-ethereum/internal/ethapi"
@ -781,3 +782,133 @@ func TestPendingTxFilterDeadlock(t *testing.T) {
} }
} }
} }
// TestTransactionReceiptsSubscription tests the transaction receipts subscription functionality
func TestTransactionReceiptsSubscription(t *testing.T) {
t.Parallel()
const txNum = 5
// Setup test environment
var (
db = rawdb.NewMemoryDatabase()
backend, sys = newTestFilterSystem(db, Config{})
api = NewFilterAPI(sys)
key1, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
addr1 = crypto.PubkeyToAddress(key1.PublicKey)
signer = types.NewLondonSigner(big.NewInt(1))
genesis = &core.Genesis{
Alloc: types.GenesisAlloc{addr1: {Balance: big.NewInt(1000000000000000000)}}, // 1 ETH
Config: params.TestChainConfig,
BaseFee: big.NewInt(params.InitialBaseFee),
}
_, chain, _ = core.GenerateChainWithGenesis(genesis, ethash.NewFaker(), 1, func(i int, gen *core.BlockGen) {
// Add transactions to the block
for j := 0; j < txNum; j++ {
toAddr := common.HexToAddress("0xb794f5ea0ba39494ce83a213fffba74279579268")
tx, _ := types.SignTx(types.NewTx(&types.LegacyTx{
Nonce: uint64(j),
GasPrice: gen.BaseFee(),
Gas: 21000,
To: &toAddr,
Value: big.NewInt(1000),
Data: nil,
}), signer, key1)
gen.AddTx(tx)
}
})
)
// Insert the blocks into the chain
blockchain, err := core.NewBlockChain(db, genesis, ethash.NewFaker(), nil)
if err != nil {
t.Fatalf("failed to create tester chain: %v", err)
}
if n, err := blockchain.InsertChain(chain); err != nil {
t.Fatalf("block %d: failed to insert into chain: %v", n, err)
}
// Prepare test data
chainEvent := core.ChainEvent{Header: chain[0].Header()}
txHashes := make([]common.Hash, txNum)
for i := 0; i < txNum; i++ {
txHashes[i] = chain[0].Transactions()[i].Hash()
}
testCases := []struct {
name string
filterTxHashes []common.Hash
expectedReceiptTxHashes []common.Hash
expectError bool
}{
{
name: "no filter - should return all receipts",
filterTxHashes: nil,
expectedReceiptTxHashes: txHashes,
expectError: false,
},
{
name: "single tx hash filter",
filterTxHashes: []common.Hash{txHashes[0]},
expectedReceiptTxHashes: []common.Hash{txHashes[0]},
expectError: false,
},
{
name: "multiple tx hashes filter",
filterTxHashes: []common.Hash{txHashes[0], txHashes[1], txHashes[2]},
expectedReceiptTxHashes: []common.Hash{txHashes[0], txHashes[1], txHashes[2]},
expectError: false,
},
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
receiptsChan := make(chan []*ReceiptWithTx)
sub := api.events.SubscribeTransactionReceipts(tc.filterTxHashes, receiptsChan)
// Send chain event
backend.chainFeed.Send(chainEvent)
// Wait for receipts
timeout := time.After(1 * time.Second)
var receivedReceipts []*types.Receipt
for {
select {
case receiptsWithTx := <-receiptsChan:
for _, receiptWithTx := range receiptsWithTx {
receivedReceipts = append(receivedReceipts, receiptWithTx.Receipt)
}
case <-timeout:
t.Fatalf("timeout waiting for receipts")
}
if len(receivedReceipts) >= len(tc.expectedReceiptTxHashes) {
break
}
}
// Verify receipt count
if len(receivedReceipts) != len(tc.expectedReceiptTxHashes) {
t.Errorf("Expected %d receipts, got %d", len(tc.expectedReceiptTxHashes), len(receivedReceipts))
}
// Verify specific transaction hashes are present
if tc.expectedReceiptTxHashes != nil {
receivedHashes := make(map[common.Hash]bool)
for _, receipt := range receivedReceipts {
receivedHashes[receipt.TxHash] = true
}
for _, expectedHash := range tc.expectedReceiptTxHashes {
if !receivedHashes[expectedHash] {
t.Errorf("Expected receipt for tx %x not found", expectedHash)
}
}
}
// Cleanup
sub.Unsubscribe()
<-sub.Err()
})
}
}

View file

@ -627,7 +627,7 @@ func (api *BlockChainAPI) GetBlockReceipts(ctx context.Context, blockNrOrHash rp
result := make([]map[string]interface{}, len(receipts)) result := make([]map[string]interface{}, len(receipts))
for i, receipt := range receipts { for i, receipt := range receipts {
result[i] = marshalReceipt(receipt, block.Hash(), block.NumberU64(), signer, txs[i], i) result[i] = MarshalReceipt(receipt, block.Hash(), block.NumberU64(), signer, txs[i], i)
} }
return result, nil return result, nil
} }
@ -1472,11 +1472,11 @@ func (api *TransactionAPI) GetTransactionReceipt(ctx context.Context, hash commo
return nil, err return nil, err
} }
// Derive the sender. // Derive the sender.
return marshalReceipt(receipt, blockHash, blockNumber, api.signer, tx, int(index)), nil return MarshalReceipt(receipt, blockHash, blockNumber, api.signer, tx, int(index)), nil
} }
// marshalReceipt marshals a transaction receipt into a JSON object. // MarshalReceipt marshals a transaction receipt into a JSON object.
func marshalReceipt(receipt *types.Receipt, blockHash common.Hash, blockNumber uint64, signer types.Signer, tx *types.Transaction, txIndex int) map[string]interface{} { func MarshalReceipt(receipt *types.Receipt, blockHash common.Hash, blockNumber uint64, signer types.Signer, tx *types.Transaction, txIndex int) map[string]interface{} {
from, _ := types.Sender(signer, tx) from, _ := types.Sender(signer, tx)
fields := map[string]interface{}{ fields := map[string]interface{}{