From cb7619702e241f5a5740344b458cec254370a4cd Mon Sep 17 00:00:00 2001 From: Ricardo Prado Date: Mon, 6 Jan 2025 16:04:00 -0300 Subject: [PATCH] Get block notifications API --- docs/rpc.md | 4 + pkg/core/block/block.go | 108 +++++++++++++++++++++++ pkg/core/block/block_test.go | 41 +++++++++ pkg/core/blockchain.go | 5 ++ pkg/core/dao/dao.go | 15 ++++ pkg/core/dao/dao_test.go | 50 +++++++++++ pkg/neorpc/result/block_notifications.go | 12 +++ pkg/rpcclient/rpc.go | 9 ++ pkg/services/rpcsrv/server.go | 58 ++++++++++++ pkg/services/rpcsrv/server_test.go | 46 ++++++++++ pkg/services/rpcsrv/util.go | 49 ++++++++++ 11 files changed, 397 insertions(+) create mode 100644 pkg/neorpc/result/block_notifications.go diff --git a/docs/rpc.md b/docs/rpc.md index d450c1b7b2..1909fc494d 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -248,6 +248,10 @@ block. It can be removed in future versions, but at the moment you can use it to see how much GAS is burned with a particular block (because system fees are burned). +#### `getblocknotifications` call + +This method returns notifications from a block organized by trigger type. Supports filtering by contract and event name. + #### Historic calls A set of `*historic` extension methods provide the ability of interacting with diff --git a/pkg/core/block/block.go b/pkg/core/block/block.go index 9b246a4d3a..44292f63ae 100644 --- a/pkg/core/block/block.go +++ b/pkg/core/block/block.go @@ -51,6 +51,20 @@ type auxBlockIn struct { Transactions []json.RawMessage `json:"tx"` } +type TrimmedBlock struct { + Header + TxHashes []util.Uint256 +} + +type auxTrimmedBlockOut struct { + TxHashes []util.Uint256 `json:"tx"` +} + +// auxTrimmedBlockIn é usado para JSON i/o. +type auxTrimmedBlockIn struct { + TxHashes []json.RawMessage `json:"tx"` +} + // ComputeMerkleRoot computes Merkle tree root hash based on actual block's data. func (b *Block) ComputeMerkleRoot() util.Uint256 { hashes := make([]util.Uint256, len(b.Transactions)) @@ -241,3 +255,97 @@ func (b *Block) ToStackItem() stackitem.Item { return stackitem.NewArray(items) } + +// NewTrimmedBlockFromReader creates a block with only the header and transaction hashes +func NewTrimmedBlockFromReader(stateRootEnabled bool, br *io.BinReader) (*TrimmedBlock, error) { + block := &TrimmedBlock{ + Header: Header{ + StateRootEnabled: stateRootEnabled, + }, + } + + block.Header.DecodeBinary(br) + lenHashes := br.ReadVarUint() + if lenHashes > MaxTransactionsPerBlock { + return nil, ErrMaxContentsPerBlock + } + if lenHashes > 0 { + block.TxHashes = make([]util.Uint256, lenHashes) + for i := range lenHashes { + block.TxHashes[i].DecodeBinary(br) + } + } + + return block, br.Err +} + +func (b TrimmedBlock) MarshalJSON() ([]byte, error) { + abo := auxTrimmedBlockOut{ + TxHashes: b.TxHashes, + } + + if abo.TxHashes == nil { + abo.TxHashes = []util.Uint256{} + } + auxb, err := json.Marshal(abo) + if err != nil { + return nil, err + } + baseBytes, err := json.Marshal(b.Header) + if err != nil { + return nil, err + } + + // Does as the 'normal' block does + if baseBytes[len(baseBytes)-1] != '}' || auxb[0] != '{' { + return nil, errors.New("can't merge internal jsons") + } + baseBytes[len(baseBytes)-1] = ',' + baseBytes = append(baseBytes, auxb[1:]...) + return baseBytes, nil +} + +// UnmarshalJSON implementa a interface json.Unmarshaler. +func (b *TrimmedBlock) UnmarshalJSON(data []byte) error { + // Similar ao Block normal, faz unmarshalling separado para Header e hashes + auxb := new(auxTrimmedBlockIn) + err := json.Unmarshal(data, auxb) + if err != nil { + return err + } + err = json.Unmarshal(data, &b.Header) + if err != nil { + return err + } + if len(auxb.TxHashes) != 0 { + b.TxHashes = make([]util.Uint256, len(auxb.TxHashes)) + for i, hashBytes := range auxb.TxHashes { + err = json.Unmarshal(hashBytes, &b.TxHashes[i]) + if err != nil { + return err + } + } + } + return nil +} + +// ToStackItem converte TrimmedBlock para stackitem.Item. +func (b *TrimmedBlock) ToStackItem() stackitem.Item { + items := []stackitem.Item{ + stackitem.NewByteArray(b.Hash().BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(b.Version))), + stackitem.NewByteArray(b.PrevHash.BytesBE()), + stackitem.NewByteArray(b.MerkleRoot.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(b.Timestamp))), + stackitem.NewBigInteger(new(big.Int).SetUint64(b.Nonce)), + stackitem.NewBigInteger(big.NewInt(int64(b.Index))), + stackitem.NewBigInteger(big.NewInt(int64(b.PrimaryIndex))), + stackitem.NewByteArray(b.NextConsensus.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(len(b.TxHashes)))), + } + if b.StateRootEnabled { + items = append(items, stackitem.NewByteArray(b.PrevStateRoot.BytesBE())) + } + + return stackitem.NewArray(items) +} diff --git a/pkg/core/block/block_test.go b/pkg/core/block/block_test.go index 5400cf9231..ca258a158d 100644 --- a/pkg/core/block/block_test.go +++ b/pkg/core/block/block_test.go @@ -79,6 +79,47 @@ func TestTrimmedBlock(t *testing.T) { } } +func TestNewTrimmedBlockFromReader(t *testing.T) { + block := getDecodedBlock(t, 1) + + buf := io.NewBufBinWriter() + block.EncodeTrimmed(buf.BinWriter) + require.NoError(t, buf.Err) + + r := io.NewBinReaderFromBuf(buf.Bytes()) + trimmedBlock, err := NewTrimmedBlockFromReader(false, r) + require.NoError(t, err) + + assert.Equal(t, block.Version, trimmedBlock.Version) + assert.Equal(t, block.PrevHash, trimmedBlock.PrevHash) + assert.Equal(t, block.MerkleRoot, trimmedBlock.MerkleRoot) + assert.Equal(t, block.Timestamp, trimmedBlock.Timestamp) + assert.Equal(t, block.Index, trimmedBlock.Index) + assert.Equal(t, block.NextConsensus, trimmedBlock.NextConsensus) + + assert.Equal(t, block.Script, trimmedBlock.Script) + assert.Equal(t, len(block.Transactions), len(trimmedBlock.TxHashes)) + for i := range block.Transactions { + assert.Equal(t, block.Transactions[i].Hash(), trimmedBlock.TxHashes[i]) + } + + data, err := json.Marshal(trimmedBlock) + require.NoError(t, err) + + var decoded TrimmedBlock + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, trimmedBlock.Version, decoded.Version) + assert.Equal(t, trimmedBlock.PrevHash, decoded.PrevHash) + assert.Equal(t, trimmedBlock.MerkleRoot, decoded.MerkleRoot) + assert.Equal(t, trimmedBlock.Timestamp, decoded.Timestamp) + assert.Equal(t, trimmedBlock.Index, decoded.Index) + assert.Equal(t, trimmedBlock.NextConsensus, decoded.NextConsensus) + assert.Equal(t, trimmedBlock.Script, decoded.Script) + assert.Equal(t, trimmedBlock.TxHashes, decoded.TxHashes) +} + func newDumbBlock() *Block { return &Block{ Header: Header{ diff --git a/pkg/core/blockchain.go b/pkg/core/blockchain.go index 85291b9ee9..af8d8a11a0 100644 --- a/pkg/core/blockchain.go +++ b/pkg/core/blockchain.go @@ -3149,3 +3149,8 @@ func (bc *Blockchain) GetStoragePrice() int64 { } return bc.contracts.Policy.GetStoragePriceInternal(bc.dao) } + +// GetTrimmedBlock returns a block with only the header and transaction hashes +func (bc *Blockchain) GetTrimmedBlock(hash util.Uint256) (*block.TrimmedBlock, error) { + return bc.dao.GetTrimmedBlock(hash) +} diff --git a/pkg/core/dao/dao.go b/pkg/core/dao/dao.go index 9842c40d93..208a8ac40b 100644 --- a/pkg/core/dao/dao.go +++ b/pkg/core/dao/dao.go @@ -439,6 +439,21 @@ func (dao *Simple) getBlock(key []byte) (*block.Block, error) { return block, nil } +// GetTrimmedBlock returns a block with only the header and transaction hashes +func (dao *Simple) GetTrimmedBlock(hash util.Uint256) (*block.TrimmedBlock, error) { + key := dao.makeExecutableKey(hash) + b, err := dao.Store.Get(key) + if err != nil { + return nil, err + } + + r := io.NewBinReaderFromBuf(b) + if r.ReadB() != storage.ExecBlock { + return nil, storage.ErrKeyNotFound + } + return block.NewTrimmedBlockFromReader(dao.Version.StateRootInHeader, r) +} + // Version represents the current dao version. type Version struct { StoragePrefix storage.KeyPrefix diff --git a/pkg/core/dao/dao_test.go b/pkg/core/dao/dao_test.go index 64946ac59b..39b76dcc09 100644 --- a/pkg/core/dao/dao_test.go +++ b/pkg/core/dao/dao_test.go @@ -14,6 +14,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neo-go/pkg/vm/opcode" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -123,6 +124,55 @@ func TestPutGetBlock(t *testing.T) { require.Error(t, err) } +func TestGetTrimmedBlock(t *testing.T) { + dao := NewSimple(storage.NewMemoryStore(), false) + tx := transaction.New([]byte{byte(opcode.PUSH1)}, 0) + tx.Signers = []transaction.Signer{{Account: util.Uint160{1, 2, 3}}} + tx.Scripts = []transaction.Witness{{}} + + b := &block.Block{ + Header: block.Header{ + Timestamp: 42, + Script: transaction.Witness{ + VerificationScript: []byte{byte(opcode.PUSH1)}, + InvocationScript: []byte{byte(opcode.NOP)}, + }, + }, + Transactions: []*transaction.Transaction{tx}, + } + hash := b.Hash() + appExecResult1 := &state.AppExecResult{ + Container: hash, + Execution: state.Execution{ + Trigger: trigger.OnPersist, + Events: []state.NotificationEvent{}, + Stack: []stackitem.Item{}, + }, + } + err := dao.StoreAsBlock(b, appExecResult1, nil) + require.NoError(t, err) + + trimmedBlock, err := dao.GetTrimmedBlock(hash) + require.NoError(t, err) + require.NotNil(t, trimmedBlock) + + assert.Equal(t, b.Version, trimmedBlock.Version) + assert.Equal(t, b.PrevHash, trimmedBlock.PrevHash) + assert.Equal(t, b.MerkleRoot, trimmedBlock.MerkleRoot) + assert.Equal(t, b.Timestamp, trimmedBlock.Timestamp) + assert.Equal(t, b.Index, trimmedBlock.Index) + assert.Equal(t, b.NextConsensus, trimmedBlock.NextConsensus) + assert.Equal(t, b.Script, trimmedBlock.Script) + + assert.Equal(t, len(b.Transactions), len(trimmedBlock.TxHashes)) + for i := range b.Transactions { + assert.Equal(t, b.Transactions[i].Hash(), trimmedBlock.TxHashes[i]) + } + + _, err = dao.GetTrimmedBlock(util.Uint256{1, 2, 3}) + require.Error(t, err) +} + func TestGetVersion_NoVersion(t *testing.T) { dao := NewSimple(storage.NewMemoryStore(), false) version, err := dao.GetVersion() diff --git a/pkg/neorpc/result/block_notifications.go b/pkg/neorpc/result/block_notifications.go new file mode 100644 index 0000000000..832593c811 --- /dev/null +++ b/pkg/neorpc/result/block_notifications.go @@ -0,0 +1,12 @@ +package result + +import ( + "github.com/nspcc-dev/neo-go/pkg/core/state" +) + +// BlockNotifications represents notifications from a block organized by trigger type. +type BlockNotifications struct { + PrePersistNotifications []state.ContainedNotificationEvent `json:"prepersist,omitempty"` + TxNotifications []state.ContainedNotificationEvent `json:"transactions,omitempty"` + PostPersistNotifications []state.ContainedNotificationEvent `json:"postpersist,omitempty"` +} diff --git a/pkg/rpcclient/rpc.go b/pkg/rpcclient/rpc.go index 2959c00f77..bfc03b3942 100644 --- a/pkg/rpcclient/rpc.go +++ b/pkg/rpcclient/rpc.go @@ -972,3 +972,12 @@ func (c *Client) GetRawNotaryPool() (*result.RawNotaryPool, error) { } return resp, nil } + +// GetBlockNotifications returns notifications from a block organized by trigger type. +func (c *Client) GetBlockNotifications(blockHash util.Uint256, filters ...*neorpc.NotificationFilter) (*result.BlockNotifications, error) { + var resp = &result.BlockNotifications{} + if err := c.performRequest("getblocknotifications", []any{blockHash.StringLE(), filters}, resp); err != nil { + return nil, err + } + return resp, nil +} diff --git a/pkg/services/rpcsrv/server.go b/pkg/services/rpcsrv/server.go index 194ddf1624..6b06e6487b 100644 --- a/pkg/services/rpcsrv/server.go +++ b/pkg/services/rpcsrv/server.go @@ -112,6 +112,7 @@ type ( VerifyWitness(util.Uint160, hash.Hashable, *transaction.Witness, int64) (int64, error) mempool.Feer // fee interface ContractStorageSeeker + GetTrimmedBlock(hash util.Uint256) (*block.TrimmedBlock, error) } // ContractStorageSeeker is the interface `findstorage*` handlers need to be able to @@ -185,6 +186,14 @@ type ( // Item represents Iterator stackitem. Item stackitem.Item } + + notificationComparatorFilter struct { + id neorpc.EventID + filter neorpc.SubscriptionFilter + } + notificationEventContainer struct { + ntf *state.ContainedNotificationEvent + } ) const ( @@ -219,6 +228,7 @@ var rpcHandlers = map[string]func(*Server, params.Params) (any, *neorpc.Error){ "getblockhash": (*Server).getBlockHash, "getblockheader": (*Server).getBlockHeader, "getblockheadercount": (*Server).getBlockHeaderCount, + "getblocknotifications": (*Server).getBlockNotifications, "getblocksysfee": (*Server).getBlockSysFee, "getcandidates": (*Server).getCandidates, "getcommittee": (*Server).getCommittee, @@ -3202,3 +3212,51 @@ func (s *Server) getRawNotaryTransaction(reqParams params.Params) (any, *neorpc. } return tx.Bytes(), nil } + +// getBlockNotifications returns notifications from a specific block with optional filtering. +func (s *Server) getBlockNotifications(reqParams params.Params) (any, *neorpc.Error) { + param := reqParams.Value(0) + hash, respErr := s.blockHashFromParam(param) + if respErr != nil { + return nil, respErr + } + + block, err := s.chain.GetTrimmedBlock(hash) + if err != nil { + return nil, neorpc.ErrUnknownBlock + } + + var filter *neorpc.NotificationFilter + if len(reqParams) > 1 { + filter = new(neorpc.NotificationFilter) + err := json.Unmarshal(reqParams[1].RawMessage, filter) + if err != nil { + return nil, neorpc.WrapErrorWithData(neorpc.ErrInvalidParams, fmt.Sprintf("invalid filter: %s", err)) + } + if err := filter.IsValid(); err != nil { + return nil, neorpc.WrapErrorWithData(neorpc.ErrInvalidParams, fmt.Sprintf("invalid filter: %s", err)) + } + } + + notifications := &result.BlockNotifications{} + + aers, err := s.chain.GetAppExecResults(block.Hash(), trigger.OnPersist) + if err == nil && len(aers) > 0 { + notifications.PrePersistNotifications = processAppExecResults([]state.AppExecResult{aers[0]}, filter) + } + + for _, txHash := range block.TxHashes { + aers, err := s.chain.GetAppExecResults(txHash, trigger.Application) + if err != nil { + return nil, neorpc.NewInternalServerError("failed to get app exec results") + } + notifications.TxNotifications = append(notifications.TxNotifications, processAppExecResults(aers, filter)...) + } + + aers, err = s.chain.GetAppExecResults(block.Hash(), trigger.PostPersist) + if err == nil && len(aers) > 0 { + notifications.PostPersistNotifications = processAppExecResults([]state.AppExecResult{aers[0]}, filter) + } + + return notifications, nil +} diff --git a/pkg/services/rpcsrv/server_test.go b/pkg/services/rpcsrv/server_test.go index da4f7beeb2..fc3654e519 100644 --- a/pkg/services/rpcsrv/server_test.go +++ b/pkg/services/rpcsrv/server_test.go @@ -122,6 +122,8 @@ const ( invokescriptContractAVM = "VwIADBQBDAMOBQYMDQIODw0DDgcJAAAAAErZMCQE2zBwaEH4J+yMqiYEEUAMFA0PAwIJAAIBAwcDBAUCAQAOBgwJStkwJATbMHFpQfgn7IyqJgQSQBNA" // block20StateRootLE is an LE stateroot of block #20 of basic testing chain. block20StateRootLE = "310acac4fd692ab7a90dbd7fcf6feaf1ac33aabeedf6592c4ddd08ff1dac15de" + + neoTokenHash = "ef4073a0f2b305a38ec4050e4d3d28bc40ea63f5" ) var ( @@ -2274,6 +2276,50 @@ var rpcTestCases = map[string][]rpcTestCase{ errCode: neorpc.InvalidParamsCode, }, }, + "getblocknotifications": { + { + name: "positive", + params: `["` + genesisBlockHash + `"]`, + result: func(e *executor) any { return &result.BlockNotifications{} }, + check: func(t *testing.T, e *executor, acc any) { + res, ok := acc.(*result.BlockNotifications) + require.True(t, ok) + require.NotNil(t, res) + }, + }, + { + name: "positive with filter", + params: `["` + genesisBlockHash + `", {"contract":"` + neoTokenHash + `", "name":"Transfer"}]`, + result: func(e *executor) any { return &result.BlockNotifications{} }, + check: func(t *testing.T, e *executor, acc any) { + res, ok := acc.(*result.BlockNotifications) + require.True(t, ok) + require.NotNil(t, res) + for _, ne := range res.TxNotifications { + require.Equal(t, neoTokenHash, ne.ScriptHash) + require.Equal(t, "Transfer", ne.Name) + } + }, + }, + { + name: "invalid hash", + params: `["invalid"]`, + fail: true, + errCode: neorpc.InvalidParamsCode, + }, + { + name: "unknown block", + params: `["` + util.Uint256{}.StringLE() + `"]`, + fail: true, + errCode: neorpc.ErrUnknownBlockCode, + }, + { + name: "invalid filter", + params: `["invalid", {"invalid":"filter"}]`, + fail: true, + errCode: neorpc.InvalidParamsCode, + }, + }, } func TestRPC(t *testing.T) { diff --git a/pkg/services/rpcsrv/util.go b/pkg/services/rpcsrv/util.go index 9607eef18d..7e79f53fab 100644 --- a/pkg/services/rpcsrv/util.go +++ b/pkg/services/rpcsrv/util.go @@ -3,6 +3,12 @@ package rpcsrv import ( "errors" "math" + + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/neorpc" + "github.com/nspcc-dev/neo-go/pkg/neorpc/rpcevent" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/vmstate" ) func checkUint32(i int) error { @@ -18,3 +24,46 @@ func checkInt32(i int) error { } return nil } + +func (c notificationComparatorFilter) EventID() neorpc.EventID { + return c.id +} + +func (c notificationComparatorFilter) Filter() neorpc.SubscriptionFilter { + return c.filter +} + +func (c notificationEventContainer) EventID() neorpc.EventID { + return neorpc.NotificationEventID +} + +func (c notificationEventContainer) EventPayload() any { + return c.ntf +} + +func processAppExecResults(aers []state.AppExecResult, filter *neorpc.NotificationFilter) []state.ContainedNotificationEvent { + var notifications []state.ContainedNotificationEvent + for _, aer := range aers { + if aer.VMState == vmstate.Halt { + notifications = append(notifications, filterEvents(aer.Events, aer.Container, filter)...) + } + } + return notifications +} + +func filterEvents(events []state.NotificationEvent, container util.Uint256, filter *neorpc.NotificationFilter) []state.ContainedNotificationEvent { + var notifications []state.ContainedNotificationEvent + for _, evt := range events { + ntf := state.ContainedNotificationEvent{ + Container: container, + NotificationEvent: evt, + } + if filter == nil || rpcevent.Matches(¬ificationComparatorFilter{ + id: neorpc.NotificationEventID, + filter: *filter, + }, ¬ificationEventContainer{ntf: &ntf}) { + notifications = append(notifications, ntf) + } + } + return notifications +}