diff --git a/internal/mock/storage.go b/internal/mock/storage.go index 81dfe5eb..23ae5a1f 100644 --- a/internal/mock/storage.go +++ b/internal/mock/storage.go @@ -13,6 +13,7 @@ type Storage struct { GetWriteBatchFn func() storage.Batch GetBlockFn func(uint64) (*types.Block, error) GetTxFn func(uint64, uint32) (*types.TxResult, error) + GetTxByHashFn func(string) (*types.TxResult, error) } func (m *Storage) GetLatestHeight() (uint64, error) { @@ -41,6 +42,14 @@ func (m *Storage) GetTx(blockNum uint64, index uint32) (*types.TxResult, error) panic("not implemented") } +func (m *Storage) GetTxByHash(txHash string) (*types.TxResult, error) { + if m.GetTxByHashFn != nil { + return m.GetTxByHashFn(txHash) + } + + panic("not implemented") +} + // BlockIterator iterates over Blocks, limiting the results to be between the provided block numbers func (m *Storage) BlockIterator(_, _ uint64) (storage.Iterator[*types.Block], error) { panic("not implemented") // TODO: Implement diff --git a/serve/graph/generated.go b/serve/graph/generated.go index 19a558b9..69694fb5 100644 --- a/serve/graph/generated.go +++ b/serve/graph/generated.go @@ -73,6 +73,7 @@ type ComplexityRoot struct { ContentRaw func(childComplexity int) int GasUsed func(childComplexity int) int GasWanted func(childComplexity int) int + Hash func(childComplexity int) int Index func(childComplexity int) int } } @@ -214,6 +215,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Transaction.GasWanted(childComplexity), true + case "Transaction.hash": + if e.complexity.Transaction.Hash == nil { + break + } + + return e.complexity.Transaction.Hash(childComplexity), true + case "Transaction.index": if e.complexity.Transaction.Index == nil { break @@ -690,6 +698,8 @@ func (ec *executionContext) fieldContext_Query_transactions(ctx context.Context, switch field.Name { case "index": return ec.fieldContext_Transaction_index(ctx, field) + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) case "block_height": return ec.fieldContext_Transaction_block_height(ctx, field) case "gas_wanted": @@ -1008,6 +1018,8 @@ func (ec *executionContext) fieldContext_Subscription_transactions(ctx context.C switch field.Name { case "index": return ec.fieldContext_Transaction_index(ctx, field) + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) case "block_height": return ec.fieldContext_Transaction_block_height(ctx, field) case "gas_wanted": @@ -1137,6 +1149,50 @@ func (ec *executionContext) fieldContext_Transaction_index(ctx context.Context, return fc, nil } +func (ec *executionContext) _Transaction_hash(ctx context.Context, field graphql.CollectedField, obj *model.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_hash(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Hash(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_hash(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Transaction_block_height(ctx context.Context, field graphql.CollectedField, obj *model.Transaction) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Transaction_block_height(ctx, field) if err != nil { @@ -3141,7 +3197,7 @@ func (ec *executionContext) unmarshalInputTransactionFilter(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"from_block_height", "to_block_height", "from_index", "to_index", "from_gas_wanted", "to_gas_wanted", "from_gas_used", "to_gas_used"} + fieldsInOrder := [...]string{"from_block_height", "to_block_height", "from_index", "to_index", "from_gas_wanted", "to_gas_wanted", "from_gas_used", "to_gas_used", "hash"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -3204,6 +3260,13 @@ func (ec *executionContext) unmarshalInputTransactionFilter(ctx context.Context, return it, err } it.ToGasUsed = data + case "hash": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("hash")) + data, err := ec.unmarshalOString2áš–string(ctx, v) + if err != nil { + return it, err + } + it.Hash = data } } @@ -3425,6 +3488,11 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS if out.Values[i] == graphql.Null { out.Invalids++ } + case "hash": + out.Values[i] = ec._Transaction_hash(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "block_height": out.Values[i] = ec._Transaction_block_height(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/serve/graph/model/models_gen.go b/serve/graph/model/models_gen.go index 92f5a244..cd99b076 100644 --- a/serve/graph/model/models_gen.go +++ b/serve/graph/model/models_gen.go @@ -45,4 +45,6 @@ type TransactionFilter struct { FromGasUsed *int `json:"from_gas_used,omitempty"` // Maximum `gas_used` value for filtering Transactions, exclusive. Refines selection based on the computational effort actually consumed. ToGasUsed *int `json:"to_gas_used,omitempty"` + // Hash from Transaction content in base64 encoding. If this filter is used, any other filter will be ignored. + Hash *string `json:"hash,omitempty"` } diff --git a/serve/graph/model/transaction.go b/serve/graph/model/transaction.go index f863cd8c..ab4b9173 100644 --- a/serve/graph/model/transaction.go +++ b/serve/graph/model/transaction.go @@ -1,6 +1,7 @@ package model import ( + "encoding/base64" "fmt" "github.com/gnolang/gno/tm2/pkg/bft/types" @@ -22,6 +23,10 @@ func (t *Transaction) Index() int { return int(t.t.Index) } +func (t *Transaction) Hash() string { + return base64.StdEncoding.EncodeToString(t.t.Tx.Hash()) +} + func (t *Transaction) BlockHeight() int { return int(t.t.Height) } diff --git a/serve/graph/schema.graphql b/serve/graph/schema.graphql index 53a43b9f..b166c06c 100644 --- a/serve/graph/schema.graphql +++ b/serve/graph/schema.graphql @@ -43,6 +43,11 @@ type Transaction { """ index: Int! + """ + Hash from Transaction content in base64 encoding. + """ + hash: String! + """ The height of the Block in which this Transaction is included. Links the Transaction to its containing Block. """ @@ -134,6 +139,12 @@ input TransactionFilter { Maximum `gas_used` value for filtering Transactions, exclusive. Refines selection based on the computational effort actually consumed. """ to_gas_used: Int + + """ + Hash from Transaction content in base64 encoding. If this filter is used, any other filter will be ignored. + """ + hash: String + } """ diff --git a/serve/graph/schema.resolvers.go b/serve/graph/schema.resolvers.go index 16f24a75..0e2180bd 100644 --- a/serve/graph/schema.resolvers.go +++ b/serve/graph/schema.resolvers.go @@ -9,14 +9,22 @@ import ( "math" "github.com/99designs/gqlgen/graphql" - "github.com/vektah/gqlparser/v2/gqlerror" - "github.com/gnolang/tx-indexer/serve/graph/model" "github.com/gnolang/tx-indexer/types" + "github.com/vektah/gqlparser/v2/gqlerror" ) // Transactions is the resolver for the transactions field. func (r *queryResolver) Transactions(ctx context.Context, filter model.TransactionFilter) ([]*model.Transaction, error) { + if filter.Hash != nil { + tx, err := r.store.GetTxByHash(*filter.Hash) + if err != nil { + return nil, gqlerror.Wrap(err) + } + + return []*model.Transaction{model.NewTransaction(tx)}, nil + } + it, err := r. store. TxIterator( diff --git a/serve/handlers/tx/mocks_test.go b/serve/handlers/tx/mocks_test.go index aaa5080c..f65fb6a8 100644 --- a/serve/handlers/tx/mocks_test.go +++ b/serve/handlers/tx/mocks_test.go @@ -4,8 +4,11 @@ import "github.com/gnolang/gno/tm2/pkg/bft/types" type getTxDelegate func(uint64, uint32) (*types.TxResult, error) +type getTxHashDelegate func(string) (*types.TxResult, error) + type mockStorage struct { - getTxFn getTxDelegate + getTxFn getTxDelegate + getTxHashFn getTxHashDelegate } func (m *mockStorage) GetTx(bn uint64, ti uint32) (*types.TxResult, error) { @@ -15,3 +18,11 @@ func (m *mockStorage) GetTx(bn uint64, ti uint32) (*types.TxResult, error) { return nil, nil } + +func (m *mockStorage) GetTxByHash(h string) (*types.TxResult, error) { + if m.getTxHashFn != nil { + return m.getTxHashFn(h) + } + + return nil, nil +} diff --git a/serve/handlers/tx/tx.go b/serve/handlers/tx/tx.go index 4e84d855..fed0bf8f 100644 --- a/serve/handlers/tx/tx.go +++ b/serve/handlers/tx/tx.go @@ -59,6 +59,39 @@ func (h *Handler) GetTxHandler( return encodedResponse, nil } +func (h *Handler) GetTxByHashHandler( + _ *metadata.Metadata, + params []any, +) (any, *spec.BaseJSONError) { + // Check the params + if len(params) < 1 { + return nil, spec.GenerateInvalidParamCountError() + } + + // Extract the params + txHash, ok := params[0].(string) + if !ok { + return nil, spec.GenerateInvalidParamError(1) + } + + // Run the handler + response, err := h.getTxByHash(txHash) + if err != nil { + return nil, spec.GenerateResponseError(err) + } + + if response == nil { + return nil, nil + } + + encodedResponse, err := encode.PrepareValue(response) + if err != nil { + return nil, spec.GenerateResponseError(err) + } + + return encodedResponse, nil +} + // getTx fetches the tx from storage, if any func (h *Handler) getTx(blockNum uint64, txIndex uint32) (*types.TxResult, error) { tx, err := h.storage.GetTx(blockNum, txIndex) @@ -74,3 +107,19 @@ func (h *Handler) getTx(blockNum uint64, txIndex uint32) (*types.TxResult, error return tx, nil } + +// getTx fetches the tx from storage, if any +func (h *Handler) getTxByHash(hash string) (*types.TxResult, error) { + tx, err := h.storage.GetTxByHash(hash) + if errors.Is(err, storageErrors.ErrNotFound) { + // Wrap the error + //nolint:nilnil // This is a special case + return nil, nil + } + + if err != nil { + return nil, err + } + + return tx, nil +} diff --git a/serve/handlers/tx/tx_test.go b/serve/handlers/tx/tx_test.go index 3351ebf2..be723028 100644 --- a/serve/handlers/tx/tx_test.go +++ b/serve/handlers/tx/tx_test.go @@ -149,4 +149,46 @@ func TestGetBlock_Handler(t *testing.T) { assert.Equal(t, txResult, &decodedTxResult) }) + + t.Run("block found in storage by hash", func(t *testing.T) { + t.Parallel() + + var ( + hash = "hash" + + txResult = &types.TxResult{ + Height: 10, + } + + mockStorage = &mockStorage{ + getTxHashFn: func(s string) (*types.TxResult, error) { + require.Equal(t, hash, s) + + return txResult, nil + }, + } + ) + + h := NewHandler(mockStorage) + + responseRaw, err := h.GetTxByHashHandler(nil, []any{hash}) + require.Nil(t, err) + + require.NotNil(t, responseRaw) + + // Make sure the response is valid (base64 + amino) + response, ok := responseRaw.(string) + require.True(t, ok) + + // Decode from base64 + encodedTxResult, decodeErr := base64.StdEncoding.DecodeString(response) + require.Nil(t, decodeErr) + + // Decode from amino binary + var decodedTxResult types.TxResult + + require.NoError(t, amino.Unmarshal(encodedTxResult, &decodedTxResult)) + + assert.Equal(t, txResult, &decodedTxResult) + }) } diff --git a/serve/handlers/tx/types.go b/serve/handlers/tx/types.go index 29d98058..07150871 100644 --- a/serve/handlers/tx/types.go +++ b/serve/handlers/tx/types.go @@ -5,4 +5,7 @@ import "github.com/gnolang/gno/tm2/pkg/bft/types" type Storage interface { // GetTx returns specified tx from permanent storage GetTx(uint64, uint32) (*types.TxResult, error) + + // GetTxByHash fetches the tx using the transaction hash + GetTxByHash(txHash string) (*types.TxResult, error) } diff --git a/serve/jsonrpc.go b/serve/jsonrpc.go index ce1615a4..94d3a0a2 100644 --- a/serve/jsonrpc.go +++ b/serve/jsonrpc.go @@ -120,6 +120,11 @@ func (j *JSONRPC) RegisterTxEndpoints(db tx.Storage) { "getTxResult", txHandler.GetTxHandler, ) + + j.RegisterHandler( + "getTxResultByHash", + txHandler.GetTxByHashHandler, + ) } // RegisterBlockEndpoints registers the block endpoints diff --git a/storage/pebble.go b/storage/pebble.go index ec8d3c25..f6f37a8b 100644 --- a/storage/pebble.go +++ b/storage/pebble.go @@ -1,6 +1,7 @@ package storage import ( + "encoding/base64" "errors" "fmt" "math" @@ -17,11 +18,14 @@ const ( // for the latest height saved in the DB keyLatestHeight = "/meta/lh" - // keyBlocks is the key for each block saved. They are stored by height + // prefixKeyBlocks is the key for each block saved. They are stored by height prefixKeyBlocks = "/data/blocks/" - // keyTxs is the prefix for each transaction saved. + // prefixKeyTxs is the prefix for each transaction saved. prefixKeyTxs = "/data/txs/" + + // prefixKeyTxByHash is a secondary index to query transaction by hash + prefixKeyTxByHash = "/index/txh/" ) func keyTx(blockNum uint64, txIndex uint32) []byte { @@ -33,6 +37,14 @@ func keyTx(blockNum uint64, txIndex uint32) []byte { return key } +func keyHashTx(hash string) []byte { + var key []byte + key = encodeStringAscending(key, prefixKeyTxByHash) + key = encodeStringAscending(key, hash) + + return key +} + func keyBlock(blockNum uint64) []byte { var key []byte key = encodeStringAscending(key, prefixKeyBlocks) @@ -113,6 +125,34 @@ func (s *Pebble) GetTx(blockNum uint64, index uint32) (*types.TxResult, error) { return decodeTx(tx) } +func (s *Pebble) GetTxByHash(txHash string) (*types.TxResult, error) { + txKey, ch, err := s.db.Get(keyHashTx(txHash)) + if errors.Is(err, pebble.ErrNotFound) { + return nil, storageErrors.ErrNotFound + } + + if err != nil { + return nil, err + } + + tx, c, err := s.db.Get(txKey) + + // Close after using the txKey array output + defer ch.Close() + + if errors.Is(err, pebble.ErrNotFound) { + return nil, storageErrors.ErrNotFound + } + + if err != nil { + return nil, err + } + + defer c.Close() + + return decodeTx(tx) +} + func (s *Pebble) BlockIterator(fromBlockNum, toBlockNum uint64) (Iterator[*types.Block], error) { fromKey := keyBlock(fromBlockNum) @@ -316,6 +356,12 @@ func (b *PebbleBatch) SetTx(tx *types.TxResult) error { key := keyTx(uint64(tx.Height), tx.Index) + // write secondary index to be able to query by tx hash + hashIndexKey := keyHashTx(base64.StdEncoding.EncodeToString(tx.Tx.Hash())) + if err := b.b.Set(hashIndexKey, key, pebble.NoSync); err != nil { + return err + } + return b.b.Set( key, encodedTx, diff --git a/storage/types.go b/storage/types.go index 006b4f70..3fff1241 100644 --- a/storage/types.go +++ b/storage/types.go @@ -25,6 +25,9 @@ type Reader interface { // GetTx fetches the tx using the block height and the transaction index GetTx(blockNum uint64, index uint32) (*types.TxResult, error) + // GetTxByHash fetches the tx using the transaction hash + GetTxByHash(txHash string) (*types.TxResult, error) + // BlockIterator iterates over Blocks, limiting the results to be between the provided block numbers BlockIterator(fromBlockNum, toBlockNum uint64) (Iterator[*types.Block], error)