From 2252841144a09b55f215905799401b9141c54a46 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 29 Apr 2026 19:59:42 +0200 Subject: [PATCH] rpc/graphql: implement getTransaction, fix Long scalar and pre-Byzantium status - Implement queryResolver.Transaction (getTransaction by hash) - Implement queryResolver.Block hash path (getBlockByHash) - Add GetBlockDetailsByHash and GetBlockNumberForTx to GraphQLAPI - Change Transaction.type schema from Int to Long so EIP-1559 txns return "0x2" (hex) instead of 2 (decimal), matching the spec - Default status=0 for pre-Byzantium receipts (PostState path) - Add custom Long scalar with hex marshal/unmarshal - Add nil guards to all three convertData helpers - Refactor block/tx building into resolver_helpers.go - Pre-allocate Logs, Topics, Transactions, Ommers, Withdrawals slices - Add unit tests: scalar marshal/unmarshal, helper nil guards, TransactionAt bounds, Transaction invalid hash Co-Authored-By: Claude Sonnet 4.6 --- cmd/rpcdaemon/graphql/gqlgen.yml | 22 +- cmd/rpcdaemon/graphql/graph/generated.go | 264 +++++++++++++----- cmd/rpcdaemon/graphql/graph/helpers.go | 9 + cmd/rpcdaemon/graphql/graph/helpers_test.go | 65 +++++ .../graphql/graph/model/models_gen.go | 2 +- .../graphql/graph/resolver_helpers.go | 165 +++++++++++ cmd/rpcdaemon/graphql/graph/scalar/scalar.go | 36 +++ .../graphql/graph/scalar/scalar_test.go | 76 +++++ cmd/rpcdaemon/graphql/graph/schema.graphqls | 2 +- .../graphql/graph/schema.resolvers.go | 261 ++++++----------- .../graphql/graph/schema_resolvers_test.go | 51 ++++ rpc/jsonrpc/graphql_api.go | 52 +++- 12 files changed, 747 insertions(+), 258 deletions(-) create mode 100644 cmd/rpcdaemon/graphql/graph/helpers_test.go create mode 100644 cmd/rpcdaemon/graphql/graph/resolver_helpers.go create mode 100644 cmd/rpcdaemon/graphql/graph/scalar/scalar.go create mode 100644 cmd/rpcdaemon/graphql/graph/scalar/scalar_test.go diff --git a/cmd/rpcdaemon/graphql/gqlgen.yml b/cmd/rpcdaemon/graphql/gqlgen.yml index b5d15a9afee..ae89d8f1d0a 100644 --- a/cmd/rpcdaemon/graphql/gqlgen.yml +++ b/cmd/rpcdaemon/graphql/gqlgen.yml @@ -60,7 +60,7 @@ models: - github.com/99designs/gqlgen/graphql.Int32 Long: model: - - github.com/99designs/gqlgen/graphql.Uint64 + - github.com/erigontech/erigon/cmd/rpcdaemon/graphql/graph/scalar.Uint64 BigInt: model: - github.com/99designs/gqlgen/graphql.String @@ -77,13 +77,17 @@ models: model: - github.com/99designs/gqlgen/graphql.String - github.com/99designs/gqlgen/graphql.Uint64 -# Block: -# fields: -# logs: -# resolver: true # force a resolver to be generated -# ommers: -# resolver: true # force a resolver to be generated -# transactions: -# resolver: true # force a resolver to be generated + Block: + fields: + transactionAt: + resolver: true + account: + resolver: true + Transaction: + fields: + from: + resolver: true + to: + resolver: true omit_getters: true diff --git a/cmd/rpcdaemon/graphql/graph/generated.go b/cmd/rpcdaemon/graphql/graph/generated.go index 446c25807d8..d9ff004480a 100644 --- a/cmd/rpcdaemon/graphql/graph/generated.go +++ b/cmd/rpcdaemon/graphql/graph/generated.go @@ -14,6 +14,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" "github.com/erigontech/erigon/cmd/rpcdaemon/graphql/graph/model" + "github.com/erigontech/erigon/cmd/rpcdaemon/graphql/graph/scalar" gqlparser "github.com/vektah/gqlparser/v2" "github.com/vektah/gqlparser/v2/ast" ) @@ -28,18 +29,10 @@ func NewExecutableSchema(cfg Config) graphql.ExecutableSchema { type Config = graphql.Config[ResolverRoot, DirectiveRoot, ComplexityRoot] type ResolverRoot interface { - Account() AccountResolver Block() BlockResolver Mutation() MutationResolver Query() QueryResolver -} - -type AccountResolver interface { - Storage(ctx context.Context, obj *model.Account, slot string) (string, error) -} - -type BlockResolver interface { - Account(ctx context.Context, obj *model.Block, address string) (*model.Account, error) + Transaction() TransactionResolver } type DirectiveRoot struct { @@ -174,6 +167,11 @@ type ComplexityRoot struct { } } +type BlockResolver interface { + TransactionAt(ctx context.Context, obj *model.Block, index int) (*model.Transaction, error) + + Account(ctx context.Context, obj *model.Block, address string) (*model.Account, error) +} type MutationResolver interface { SendRawTransaction(ctx context.Context, data string) (string, error) } @@ -188,6 +186,10 @@ type QueryResolver interface { Syncing(ctx context.Context) (*model.SyncState, error) ChainID(ctx context.Context) (string, error) } +type TransactionResolver interface { + From(ctx context.Context, obj *model.Transaction, block *uint64) (*model.Account, error) + To(ctx context.Context, obj *model.Transaction, block *uint64) (*model.Account, error) +} type executableSchema graphql.ExecutableSchemaState[ResolverRoot, DirectiveRoot, ComplexityRoot] @@ -1458,8 +1460,7 @@ func (ec *executionContext) _Account_storage(ctx context.Context, field graphql. field, ec.fieldContext_Account_storage, func(ctx context.Context) (any, error) { - fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Account().Storage(ctx, obj, fc.Args["slot"].(string)) + return obj.Storage, nil }, nil, ec.marshalNBytes322string, @@ -1472,8 +1473,8 @@ func (ec *executionContext) fieldContext_Account_storage(ctx context.Context, fi fc = &graphql.FieldContext{ Object: "Account", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { return nil, errors.New("field of type Bytes32 does not have child fields") }, @@ -2446,7 +2447,8 @@ func (ec *executionContext) _Block_transactionAt(ctx context.Context, field grap field, ec.fieldContext_Block_transactionAt, func(ctx context.Context) (any, error) { - return obj.TransactionAt, nil + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Block().TransactionAt(ctx, obj, fc.Args["index"].(int)) }, nil, ec.marshalOTransaction2ᚖgithubᚗcomᚋerigontechᚋerigonᚋcmdᚋrpcdaemonᚋgraphqlᚋgraphᚋmodelᚐTransaction, @@ -2459,8 +2461,8 @@ func (ec *executionContext) fieldContext_Block_transactionAt(ctx context.Context fc = &graphql.FieldContext{ Object: "Block", Field: field, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "hash": @@ -4237,7 +4239,8 @@ func (ec *executionContext) _Transaction_from(ctx context.Context, field graphql field, ec.fieldContext_Transaction_from, func(ctx context.Context) (any, error) { - return obj.From, nil + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Transaction().From(ctx, obj, fc.Args["block"].(*uint64)) }, nil, ec.marshalNAccount2ᚖgithubᚗcomᚋerigontechᚋerigonᚋcmdᚋrpcdaemonᚋgraphqlᚋgraphᚋmodelᚐAccount, @@ -4250,8 +4253,8 @@ func (ec *executionContext) fieldContext_Transaction_from(ctx context.Context, f fc = &graphql.FieldContext{ Object: "Transaction", Field: field, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "address": @@ -4289,7 +4292,8 @@ func (ec *executionContext) _Transaction_to(ctx context.Context, field graphql.C field, ec.fieldContext_Transaction_to, func(ctx context.Context) (any, error) { - return obj.To, nil + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Transaction().To(ctx, obj, fc.Args["block"].(*uint64)) }, nil, ec.marshalOAccount2ᚖgithubᚗcomᚋerigontechᚋerigonᚋcmdᚋrpcdaemonᚋgraphqlᚋgraphᚋmodelᚐAccount, @@ -4302,8 +4306,8 @@ func (ec *executionContext) fieldContext_Transaction_to(ctx context.Context, fie fc = &graphql.FieldContext{ Object: "Transaction", Field: field, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "address": @@ -4936,7 +4940,7 @@ func (ec *executionContext) _Transaction_type(ctx context.Context, field graphql return obj.Type, nil }, nil, - ec.marshalOInt2ᚖint, + ec.marshalOLong2ᚖuint64, true, false, ) @@ -4949,7 +4953,7 @@ func (ec *executionContext) fieldContext_Transaction_type(_ context.Context, fie IsMethod: false, IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") + return nil, errors.New("field of type Long does not have child fields") }, } return fc, nil @@ -6902,56 +6906,56 @@ func (ec *executionContext) _Block(ctx context.Context, sel ast.SelectionSet, ob case "number": out.Values[i] = ec._Block_number(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "hash": out.Values[i] = ec._Block_hash(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "parent": out.Values[i] = ec._Block_parent(ctx, field, obj) case "nonce": out.Values[i] = ec._Block_nonce(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "transactionsRoot": out.Values[i] = ec._Block_transactionsRoot(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "transactionCount": out.Values[i] = ec._Block_transactionCount(ctx, field, obj) case "stateRoot": out.Values[i] = ec._Block_stateRoot(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "receiptsRoot": out.Values[i] = ec._Block_receiptsRoot(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "miner": out.Values[i] = ec._Block_miner(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "extraData": out.Values[i] = ec._Block_extraData(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "gasLimit": out.Values[i] = ec._Block_gasLimit(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "gasUsed": out.Values[i] = ec._Block_gasUsed(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "baseFeePerGas": out.Values[i] = ec._Block_baseFeePerGas(ctx, field, obj) @@ -6960,22 +6964,22 @@ func (ec *executionContext) _Block(ctx context.Context, sel ast.SelectionSet, ob case "timestamp": out.Values[i] = ec._Block_timestamp(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "logsBloom": out.Values[i] = ec._Block_logsBloom(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "mixHash": out.Values[i] = ec._Block_mixHash(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "difficulty": out.Values[i] = ec._Block_difficulty(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "ommerCount": out.Values[i] = ec._Block_ommerCount(ctx, field, obj) @@ -6986,38 +6990,100 @@ func (ec *executionContext) _Block(ctx context.Context, sel ast.SelectionSet, ob case "ommerHash": out.Values[i] = ec._Block_ommerHash(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "transactions": out.Values[i] = ec._Block_transactions(ctx, field, obj) case "transactionAt": - out.Values[i] = ec._Block_transactionAt(ctx, field, obj) + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Block_transactionAt(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "logs": out.Values[i] = ec._Block_logs(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "account": - out.Values[i] = ec._Block_account(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Block_account(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "call": out.Values[i] = ec._Block_call(ctx, field, obj) case "estimateGas": out.Values[i] = ec._Block_estimateGas(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "rawHeader": out.Values[i] = ec._Block_rawHeader(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "raw": out.Values[i] = ec._Block_raw(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "withdrawals": out.Values[i] = ec._Block_withdrawals(ctx, field, obj) @@ -7556,31 +7622,93 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS case "hash": out.Values[i] = ec._Transaction_hash(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "nonce": out.Values[i] = ec._Transaction_nonce(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "index": out.Values[i] = ec._Transaction_index(ctx, field, obj) case "from": - out.Values[i] = ec._Transaction_from(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Transaction_from(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "to": - out.Values[i] = ec._Transaction_to(ctx, field, obj) + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Transaction_to(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "value": out.Values[i] = ec._Transaction_value(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "gasPrice": out.Values[i] = ec._Transaction_gasPrice(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "maxFeePerGas": out.Values[i] = ec._Transaction_maxFeePerGas(ctx, field, obj) @@ -7591,12 +7719,12 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS case "gas": out.Values[i] = ec._Transaction_gas(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "inputData": out.Values[i] = ec._Transaction_inputData(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "block": out.Values[i] = ec._Transaction_block(ctx, field, obj) @@ -7615,17 +7743,17 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS case "r": out.Values[i] = ec._Transaction_r(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "s": out.Values[i] = ec._Transaction_s(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "v": out.Values[i] = ec._Transaction_v(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "type": out.Values[i] = ec._Transaction_type(ctx, field, obj) @@ -7634,12 +7762,12 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS case "raw": out.Values[i] = ec._Transaction_raw(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "rawReceipt": out.Values[i] = ec._Transaction_rawReceipt(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } default: panic("unknown field " + strconv.Quote(field.Name)) @@ -8063,6 +8191,10 @@ func (ec *executionContext) marshalNAccessTuple2ᚖgithubᚗcomᚋerigontechᚋe return ec._AccessTuple(ctx, sel, v) } +func (ec *executionContext) marshalNAccount2githubᚗcomᚋerigontechᚋerigonᚋcmdᚋrpcdaemonᚋgraphqlᚋgraphᚋmodelᚐAccount(ctx context.Context, sel ast.SelectionSet, v model.Account) graphql.Marshaler { + return ec._Account(ctx, sel, &v) +} + func (ec *executionContext) marshalNAccount2ᚖgithubᚗcomᚋerigontechᚋerigonᚋcmdᚋrpcdaemonᚋgraphqlᚋgraphᚋmodelᚐAccount(ctx context.Context, sel ast.SelectionSet, v *model.Account) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -8267,13 +8399,13 @@ func (ec *executionContext) marshalNLog2ᚖgithubᚗcomᚋerigontechᚋerigonᚋ } func (ec *executionContext) unmarshalNLong2uint64(ctx context.Context, v any) (uint64, error) { - res, err := graphql.UnmarshalUint64(v) + res, err := scalar.UnmarshalUint64(v) return res, graphql.ErrorOnPath(ctx, err) } func (ec *executionContext) marshalNLong2uint64(ctx context.Context, sel ast.SelectionSet, v uint64) graphql.Marshaler { _ = sel - res := graphql.MarshalUint64(v) + res := scalar.MarshalUint64(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") @@ -8759,7 +8891,7 @@ func (ec *executionContext) unmarshalOLong2ᚖuint64(ctx context.Context, v any) if v == nil { return nil, nil } - res, err := graphql.UnmarshalUint64(v) + res, err := scalar.UnmarshalUint64(v) return &res, graphql.ErrorOnPath(ctx, err) } @@ -8769,7 +8901,7 @@ func (ec *executionContext) marshalOLong2ᚖuint64(ctx context.Context, sel ast. } _ = sel _ = ctx - res := graphql.MarshalUint64(*v) + res := scalar.MarshalUint64(*v) return res } diff --git a/cmd/rpcdaemon/graphql/graph/helpers.go b/cmd/rpcdaemon/graphql/graph/helpers.go index 12f3dc8fc90..2991de3fd8f 100644 --- a/cmd/rpcdaemon/graphql/graph/helpers.go +++ b/cmd/rpcdaemon/graphql/graph/helpers.go @@ -15,6 +15,9 @@ import ( ) func convertDataToStringP(abstractMap map[string]any, field string) *string { + if abstractMap[field] == nil { + return nil + } var result string switch v := abstractMap[field].(type) { @@ -64,6 +67,9 @@ func convertDataToStringP(abstractMap map[string]any, field string) *string { } func convertDataToIntP(abstractMap map[string]any, field string) *int { + if abstractMap[field] == nil { + return nil + } var result int switch v := abstractMap[field].(type) { @@ -92,6 +98,9 @@ func convertDataToIntP(abstractMap map[string]any, field string) *int { } func convertDataToUint64P(abstractMap map[string]any, field string) *uint64 { + if abstractMap[field] == nil { + return nil + } var result uint64 switch v := abstractMap[field].(type) { diff --git a/cmd/rpcdaemon/graphql/graph/helpers_test.go b/cmd/rpcdaemon/graphql/graph/helpers_test.go new file mode 100644 index 00000000000..8eb49f0842b --- /dev/null +++ b/cmd/rpcdaemon/graphql/graph/helpers_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package graph + +import ( + "testing" + + "github.com/erigontech/erigon/common/hexutil" +) + +func TestConvertDataToStringP_Nil(t *testing.T) { + m := map[string]any{"null": nil, "value": hexutil.Uint64(42)} + + if got := convertDataToStringP(m, "null"); got != nil { + t.Errorf("nil value: expected nil, got %q", *got) + } + if got := convertDataToStringP(m, "missing"); got != nil { + t.Errorf("missing key: expected nil, got %q", *got) + } + if got := convertDataToStringP(m, "value"); got == nil { + t.Error("present value: expected non-nil, got nil") + } +} + +func TestConvertDataToIntP_Nil(t *testing.T) { + m := map[string]any{"null": nil, "value": hexutil.Uint64(5)} + + if got := convertDataToIntP(m, "null"); got != nil { + t.Errorf("nil value: expected nil, got %d", *got) + } + if got := convertDataToIntP(m, "missing"); got != nil { + t.Errorf("missing key: expected nil, got %d", *got) + } + if got := convertDataToIntP(m, "value"); got == nil { + t.Error("present value: expected non-nil, got nil") + } +} + +func TestConvertDataToUint64P_Nil(t *testing.T) { + m := map[string]any{"null": nil, "value": hexutil.Uint64(7)} + + if got := convertDataToUint64P(m, "null"); got != nil { + t.Errorf("nil value: expected nil, got %d", *got) + } + if got := convertDataToUint64P(m, "missing"); got != nil { + t.Errorf("missing key: expected nil, got %d", *got) + } + if got := convertDataToUint64P(m, "value"); got == nil { + t.Error("present value: expected non-nil, got nil") + } +} diff --git a/cmd/rpcdaemon/graphql/graph/model/models_gen.go b/cmd/rpcdaemon/graphql/graph/model/models_gen.go index e691cd4b462..c675c8b42ce 100644 --- a/cmd/rpcdaemon/graphql/graph/model/models_gen.go +++ b/cmd/rpcdaemon/graphql/graph/model/models_gen.go @@ -121,7 +121,7 @@ type Transaction struct { R string `json:"r"` S string `json:"s"` V string `json:"v"` - Type *int `json:"type,omitempty"` + Type *uint64 `json:"type,omitempty"` AccessList []*AccessTuple `json:"accessList,omitempty"` Raw string `json:"raw"` RawReceipt string `json:"rawReceipt"` diff --git a/cmd/rpcdaemon/graphql/graph/resolver_helpers.go b/cmd/rpcdaemon/graphql/graph/resolver_helpers.go new file mode 100644 index 00000000000..badca836dc1 --- /dev/null +++ b/cmd/rpcdaemon/graphql/graph/resolver_helpers.go @@ -0,0 +1,165 @@ +package graph + +import ( + "context" + "strings" + + "github.com/erigontech/erigon/cmd/rpcdaemon/graphql/graph/model" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc" +) + +func (r *Resolver) resolveAccountAtBlock(ctx context.Context, address string, defaultBlock uint64, override *uint64) (*model.Account, error) { + blockNum := rpc.BlockNumber(defaultBlock) + if override != nil { + blockNum = rpc.BlockNumber(*override) + } + addr := common.HexToAddress(address) + balance, nonce, code, err := r.GraphQLAPI.GetAccountInfo(ctx, addr, blockNum) + if err != nil { + return nil, err + } + return &model.Account{ + Address: strings.ToLower(address), + Balance: balance, + TransactionCount: nonce, + Code: code, + BlockNum: uint64(blockNum), + }, nil +} + +func (r *queryResolver) buildBlock(res map[string]any) (*model.Block, error) { + block := &model.Block{} + absBlk := res["block"] + if absBlk == nil { + return block, nil + } + + blk := absBlk.(map[string]any) + + block.Difficulty = *convertDataToStringP(blk, "difficulty") + block.ExtraData = *convertDataToStringP(blk, "extraData") + block.GasLimit = uint64(*convertDataToUint64P(blk, "gasLimit")) + block.GasUsed = *convertDataToUint64P(blk, "gasUsed") + block.Hash = *convertDataToStringP(blk, "hash") + block.Miner = &model.Account{} + if address := convertDataToStringP(blk, "miner"); address != nil { + block.Miner.Address = strings.ToLower(*address) + } + if mixHash := convertDataToStringP(blk, "mixHash"); mixHash != nil { + block.MixHash = *mixHash + } + if blockNonce := convertDataToStringP(blk, "nonce"); blockNonce != nil { + block.Nonce = *blockNonce + } + block.Number = *convertDataToUint64P(blk, "number") + block.Miner.BlockNum = block.Number + block.Parent = &model.Block{} + block.Parent.Hash = *convertDataToStringP(blk, "parentHash") + block.ReceiptsRoot = *convertDataToStringP(blk, "receiptsRoot") + block.StateRoot = *convertDataToStringP(blk, "stateRoot") + block.Timestamp = *convertDataToStringP(blk, "timestamp") + block.TransactionCount = convertDataToIntP(blk, "transactionCount") + block.TransactionsRoot = *convertDataToStringP(blk, "transactionsRoot") + block.BaseFeePerGas = convertDataToStringP(blk, "baseFeePerGas") + block.LogsBloom = "0x" + *convertDataToStringP(blk, "logsBloom") + block.OmmerHash = *convertDataToStringP(blk, "sha3Uncles") + + uncles := blk["uncles"].([]common.Hash) + block.Ommers = make([]*model.Block, 0, len(uncles)) + for _, ommerHash := range uncles { + block.Ommers = append(block.Ommers, &model.Block{Hash: ommerHash.String()}) + } + ommerCount := len(block.Ommers) + block.OmmerCount = &ommerCount + + rcp := res["receipts"].([]map[string]any) + block.Transactions = make([]*model.Transaction, 0, len(rcp)) + for _, transReceipt := range rcp { + trans := r.buildTransaction(block, transReceipt) + block.Transactions = append(block.Transactions, trans) + } + + withdrawals := res["withdrawals"].([]map[string]any) + block.Withdrawals = make([]*model.Withdrawal, 0, len(withdrawals)) + for _, withdrawal := range withdrawals { + w := &model.Withdrawal{} + w.Index = *convertDataToIntP(withdrawal, "index") + w.Validator = *convertDataToIntP(withdrawal, "validator") + w.Address = *convertDataToStringP(withdrawal, "address") + w.Amount = *convertDataToStringP(withdrawal, "amount") + block.Withdrawals = append(block.Withdrawals, w) + } + + return block, nil +} + +func (r *queryResolver) buildTransaction(block *model.Block, transReceipt map[string]any) *model.Transaction { + trans := &model.Transaction{} + trans.Block = block + trans.CumulativeGasUsed = convertDataToUint64P(transReceipt, "cumulativeGasUsed") + trans.Gas = *convertDataToUint64P(transReceipt, "gas") + trans.InputData = *convertDataToStringP(transReceipt, "data") + trans.EffectiveGasPrice = convertDataToStringP(transReceipt, "effectiveGasPrice") + if trans.EffectiveGasPrice != nil { + trans.GasPrice = *trans.EffectiveGasPrice + } + trans.GasUsed = convertDataToUint64P(transReceipt, "gasUsed") + trans.Hash = *convertDataToStringP(transReceipt, "transactionHash") + trans.Index = convertDataToIntP(transReceipt, "transactionIndex") + trans.MaxFeePerGas = convertDataToStringP(transReceipt, "maxFeePerGas") + trans.MaxPriorityFeePerGas = convertDataToStringP(transReceipt, "maxPriorityFeePerGas") + if transNonce := convertDataToStringP(transReceipt, "nonce"); transNonce != nil { + trans.Nonce = *transNonce + } + trans.Status = convertDataToUint64P(transReceipt, "status") + trans.Type = convertDataToUint64P(transReceipt, "type") + trans.Value = *convertDataToStringP(transReceipt, "value") + + logs := transReceipt["logs"].(types.Logs) + trans.Logs = make([]*model.Log, 0, len(logs)) + for _, rlog := range logs { + tlog := model.Log{ + Index: int(rlog.Index), + Data: hexutil.Encode(rlog.Data), + } + tlog.Account = model.NewAccountAtBlock(block.Number) + tlog.Account.Address = strings.ToLower(rlog.Address.String()) + tlog.Topics = make([]string, 0, len(rlog.Topics)) + for _, rtopic := range rlog.Topics { + tlog.Topics = append(tlog.Topics, rtopic.String()) + } + trans.Logs = append(trans.Logs, &tlog) + } + + trans.From = model.NewAccountAtBlock(block.Number) + trans.From.Address = strings.ToLower(*convertDataToStringP(transReceipt, "from")) + + if toAddress := convertDataToStringP(transReceipt, "to"); toAddress != nil { + trans.To = model.NewAccountAtBlock(block.Number) + trans.To.Address = strings.ToLower(*toAddress) + } + + if contractAddr := convertDataToStringP(transReceipt, "contractAddress"); contractAddr != nil { + trans.CreatedContract = model.NewAccountAtBlock(block.Number) + trans.CreatedContract.Address = strings.ToLower(*contractAddr) + } + + if al, ok := transReceipt["accessList"].(types.AccessList); ok { + trans.AccessList = make([]*model.AccessTuple, len(al)) + for i, entry := range al { + keys := make([]string, len(entry.StorageKeys)) + for j, k := range entry.StorageKeys { + keys[j] = k.Hex() + } + trans.AccessList[i] = &model.AccessTuple{ + Address: strings.ToLower(entry.Address.String()), + StorageKeys: keys, + } + } + } + + return trans +} diff --git a/cmd/rpcdaemon/graphql/graph/scalar/scalar.go b/cmd/rpcdaemon/graphql/graph/scalar/scalar.go new file mode 100644 index 00000000000..25e487a43fd --- /dev/null +++ b/cmd/rpcdaemon/graphql/graph/scalar/scalar.go @@ -0,0 +1,36 @@ +package scalar + +import ( + "fmt" + "io" + "strconv" + + "github.com/99designs/gqlgen/graphql" +) + +type Uint64 = uint64 + +func MarshalUint64(v uint64) graphql.Marshaler { + return graphql.WriterFunc(func(w io.Writer) { + fmt.Fprintf(w, `"0x%x"`, v) + }) +} + +func UnmarshalUint64(v any) (uint64, error) { + switch val := v.(type) { + case string: + if len(val) > 2 && val[0] == '0' && (val[1] == 'x' || val[1] == 'X') { + return strconv.ParseUint(val[2:], 16, 64) + } + return strconv.ParseUint(val, 10, 64) + case int64: + if val < 0 { + return 0, fmt.Errorf("Long cannot be negative: %d", val) + } + return uint64(val), nil + case float64: + return uint64(val), nil + default: + return graphql.UnmarshalUint64(v) + } +} diff --git a/cmd/rpcdaemon/graphql/graph/scalar/scalar_test.go b/cmd/rpcdaemon/graphql/graph/scalar/scalar_test.go new file mode 100644 index 00000000000..361034e0c3b --- /dev/null +++ b/cmd/rpcdaemon/graphql/graph/scalar/scalar_test.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package scalar_test + +import ( + "bytes" + "testing" + + "github.com/erigontech/erigon/cmd/rpcdaemon/graphql/graph/scalar" +) + +func TestMarshalUint64(t *testing.T) { + tests := []struct { + input uint64 + want string + }{ + {0, `"0x0"`}, + {2, `"0x2"`}, + {255, `"0xff"`}, + {1000, `"0x3e8"`}, + {^uint64(0), `"0xffffffffffffffff"`}, + } + for _, tt := range tests { + var buf bytes.Buffer + scalar.MarshalUint64(tt.input).MarshalGQL(&buf) + if got := buf.String(); got != tt.want { + t.Errorf("MarshalUint64(%d) = %s, want %s", tt.input, got, tt.want) + } + } +} + +func TestUnmarshalUint64(t *testing.T) { + tests := []struct { + name string + input any + want uint64 + wantErr bool + }{ + {"hex string lowercase", "0x2", 2, false}, + {"hex string uppercase", "0xFF", 255, false}, + {"decimal string", "42", 42, false}, + {"int64 positive", int64(10), 10, false}, + {"float64", float64(7), 7, false}, + {"zero hex", "0x0", 0, false}, + {"zero decimal", "0", 0, false}, + {"negative int64", int64(-1), 0, true}, + {"empty string", "", 0, true}, + {"invalid hex chars", "0xGG", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := scalar.UnmarshalUint64(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalUint64(%v) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("UnmarshalUint64(%v) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} diff --git a/cmd/rpcdaemon/graphql/graph/schema.graphqls b/cmd/rpcdaemon/graphql/graph/schema.graphqls index ff0bbb9800c..14233388363 100644 --- a/cmd/rpcdaemon/graphql/graph/schema.graphqls +++ b/cmd/rpcdaemon/graphql/graph/schema.graphqls @@ -121,7 +121,7 @@ type Transaction { s: BigInt! v: BigInt! # Envelope transaction support - type: Int + type: Long accessList: [AccessTuple!] # Raw is the canonical encoding of the transaction. # For legacy transactions, it returns the RLP encoding. diff --git a/cmd/rpcdaemon/graphql/graph/schema.resolvers.go b/cmd/rpcdaemon/graphql/graph/schema.resolvers.go index 74f65f87f60..1418ee771b0 100644 --- a/cmd/rpcdaemon/graphql/graph/schema.resolvers.go +++ b/cmd/rpcdaemon/graphql/graph/schema.resolvers.go @@ -7,18 +7,31 @@ package graph import ( "context" - "encoding/hex" "fmt" "strconv" - "strings" "github.com/erigontech/erigon/cmd/rpcdaemon/graphql/graph/model" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" - "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/rpc" ) +// TransactionAt is the resolver for the transactionAt field. +func (r *blockResolver) TransactionAt(ctx context.Context, obj *model.Block, index int) (*model.Transaction, error) { + if index < 0 || index >= len(obj.Transactions) { + return nil, nil + } + return obj.Transactions[index], nil +} + +// Account is the resolver for the account field. +func (r *blockResolver) Account(ctx context.Context, obj *model.Block, address string) (*model.Account, error) { + if !common.IsHexAddress(address) { + return nil, fmt.Errorf("invalid address %q", address) + } + return r.resolveAccountAtBlock(ctx, address, obj.Number, nil) +} + // SendRawTransaction is the resolver for the sendRawTransaction field. func (r *mutationResolver) SendRawTransaction(ctx context.Context, data string) (string, error) { panic("not implemented: SendRawTransaction - sendRawTransaction") @@ -26,41 +39,30 @@ func (r *mutationResolver) SendRawTransaction(ctx context.Context, data string) // Block is the resolver for the block field. func (r *queryResolver) Block(ctx context.Context, number *string, hash *string) (*model.Block, error) { + if hash != nil && number == nil { + blockHash := common.HexToHash(*hash) + res, err := r.GraphQLAPI.GetBlockDetailsByHash(ctx, blockHash) + if err != nil { + return nil, err + } + return r.buildBlock(res) + } + var blockNumber rpc.BlockNumber if number != nil { - // Block number is not null, test for a positive long integer bNum, err := strconv.ParseUint(*number, 10, 64) if err == nil { - // Positive integer, go ahead blockNumber = rpc.BlockNumber(bNum) } else { bNum, err := hexutil.DecodeUint64(*number) if err == nil { - // Hexadecimal, 0x prefixed blockNumber = rpc.BlockNumber(bNum) } else { - var err error - return nil, err + return nil, fmt.Errorf("invalid block number: %s", *number) } } } else { - if hash != nil { - blockHash, _ := hexutil.DecodeBig(*hash) - fmt.Println("TODO/GraphQL/Implement me, get Block by hash=", blockHash) - hash = nil - } - } - - if number == nil && hash == nil { - // If neither number or hash is specified (nil), we should deliver "latest" block - /* - rpc.LatestExecutedBlockNumber = BlockNumber(-5) - rpc.FinalizedBlockNumber = BlockNumber(-4) - rpc.SafeBlockNumber = BlockNumber(-3) - rpc.PendingBlockNumber = BlockNumber(-2) - rpc.LatestBlockNumber = BlockNumber(-1) - */ blockNumber = rpc.LatestBlockNumber } @@ -68,120 +70,7 @@ func (r *queryResolver) Block(ctx context.Context, number *string, hash *string) if err != nil { return nil, err } - - block := &model.Block{} - absBlk := res["block"] - - if absBlk != nil { - blk := absBlk.(map[string]any) - - block.Difficulty = *convertDataToStringP(blk, "difficulty") - block.ExtraData = *convertDataToStringP(blk, "extraData") - block.GasLimit = uint64(*convertDataToUint64P(blk, "gasLimit")) - block.GasUsed = *convertDataToUint64P(blk, "gasUsed") - block.Hash = *convertDataToStringP(blk, "hash") - block.Miner = &model.Account{} - address := convertDataToStringP(blk, "miner") - if address != nil { - block.Miner.Address = strings.ToLower(*address) - } - mixHash := convertDataToStringP(blk, "mixHash") - if mixHash != nil { - block.MixHash = *mixHash - } - blockNonce := convertDataToStringP(blk, "nonce") - if blockNonce != nil { - block.Nonce = *blockNonce - } - block.Number = *convertDataToUint64P(blk, "number") - block.Miner.BlockNum = block.Number - block.Parent = &model.Block{} - block.Parent.Hash = *convertDataToStringP(blk, "parentHash") - block.ReceiptsRoot = *convertDataToStringP(blk, "receiptsRoot") - block.StateRoot = *convertDataToStringP(blk, "stateRoot") - block.Timestamp = *convertDataToStringP(blk, "timestamp") - block.TransactionCount = convertDataToIntP(blk, "transactionCount") - block.TransactionsRoot = *convertDataToStringP(blk, "transactionsRoot") - block.BaseFeePerGas = convertDataToStringP(blk, "baseFeePerGas") - block.Transactions = []*model.Transaction{} - - block.LogsBloom = "0x" + *convertDataToStringP(blk, "logsBloom") - block.OmmerHash = *convertDataToStringP(blk, "sha3Uncles") - - // Ommers - block.Ommers = []*model.Block{} - for _, ommerHash := range blk["uncles"].([]common.Hash) { - block.Ommers = append(block.Ommers, &model.Block{Hash: ommerHash.String()}) - } - - ommerCount := len(block.Ommers) - block.OmmerCount = &ommerCount - - // Transactions - absRcp := res["receipts"] - rcp := absRcp.([]map[string]any) - for _, transReceipt := range rcp { - trans := &model.Transaction{} - trans.CumulativeGasUsed = convertDataToUint64P(transReceipt, "cumulativeGasUsed") - trans.InputData = *convertDataToStringP(transReceipt, "data") - trans.EffectiveGasPrice = convertDataToStringP(transReceipt, "effectiveGasPrice") - trans.GasPrice = *convertDataToStringP(transReceipt, "effectiveGasPrice") - trans.GasUsed = convertDataToUint64P(transReceipt, "gasUsed") - trans.Hash = *convertDataToStringP(transReceipt, "transactionHash") - trans.Index = convertDataToIntP(transReceipt, "transactionIndex") - transNonce := convertDataToStringP(transReceipt, "nonce") - if transNonce != nil { - trans.Nonce = *transNonce - } - trans.Status = convertDataToUint64P(transReceipt, "status") - trans.Type = convertDataToIntP(transReceipt, "type") - trans.Value = *convertDataToStringP(transReceipt, "value") - - trans.Logs = make([]*model.Log, 0) - for _, rlog := range transReceipt["logs"].(types.Logs) { - tlog := model.Log{ - Index: int(rlog.Index), - Data: "0x" + hex.EncodeToString(rlog.Data), - } - tlog.Account = model.NewAccountAtBlock(block.Number) - tlog.Account.Address = strings.ToLower(rlog.Address.String()) - - for _, rtopic := range rlog.Topics { - tlog.Topics = append(tlog.Topics, rtopic.String()) - } - - trans.Logs = append(trans.Logs, &tlog) - } - - trans.From = model.NewAccountAtBlock(block.Number) - trans.From.Address = strings.ToLower(*convertDataToStringP(transReceipt, "from")) - - trans.To = model.NewAccountAtBlock(block.Number) - address := convertDataToStringP(transReceipt, "to") - // To address could be nil in case of contract creation - if address != nil { - trans.To.Address = strings.ToLower(*convertDataToStringP(transReceipt, "to")) - } - - block.Transactions = append(block.Transactions, trans) - } - - // Withdrawals - block.Withdrawals = []*model.Withdrawal{} - absWthd := res["withdrawals"] - wthd := absWthd.([]map[string]any) - for _, withdrawal := range wthd { - wthd := &model.Withdrawal{} - wthd.Index = *convertDataToIntP(withdrawal, "index") - wthd.Validator = *convertDataToIntP(withdrawal, "validator") - wthd.Address = *convertDataToStringP(withdrawal, "address") - wthd.Amount = *convertDataToStringP(withdrawal, "amount") - - block.Withdrawals = append(block.Withdrawals, wthd) - } - } - - return block, ctx.Err() + return r.buildBlock(res) } // Blocks is the resolver for the blocks field. @@ -194,7 +83,7 @@ func (r *queryResolver) Blocks(ctx context.Context, from *uint64, to *uint64) ([ toBlockNumber := *to if toBlockNumber >= fromBlockNumber && (toBlockNumber-fromBlockNumber+1) < maxBlocks { - + blocks = make([]*model.Block, 0, toBlockNumber-fromBlockNumber+1) for i := fromBlockNumber; i <= toBlockNumber; i++ { blockNumberStr := strconv.FormatUint(i, 10) block, _ := r.Block(ctx, &blockNumberStr, nil) @@ -214,7 +103,36 @@ func (r *queryResolver) Pending(ctx context.Context) (*model.Pending, error) { // Transaction is the resolver for the transaction field. func (r *queryResolver) Transaction(ctx context.Context, hash string) (*model.Transaction, error) { - panic("not implemented: Transaction - transaction") + hashBytes, decErr := hexutil.Decode(hash) + if decErr != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", decErr) + } + txHash := common.BytesToHash(hashBytes) + + blockNum, ok, err := r.GraphQLAPI.GetBlockNumberForTx(ctx, txHash) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + + blockNumberStr := strconv.FormatUint(blockNum, 10) + block, err := r.Block(ctx, &blockNumberStr, nil) + if err != nil { + return nil, err + } + if block == nil { + return nil, nil + } + + hashStr := txHash.String() + for _, trans := range block.Transactions { + if trans.Hash == hashStr { + return trans, nil + } + } + return nil, nil } // Logs is the resolver for the logs field. @@ -244,52 +162,35 @@ func (r *queryResolver) ChainID(ctx context.Context) (string, error) { return "0x" + strconv.FormatUint(chainID.Uint64(), 16), err } +// From is the resolver for the from field. +func (r *transactionResolver) From(ctx context.Context, obj *model.Transaction, block *uint64) (*model.Account, error) { + if obj.From == nil { + return nil, nil + } + return r.resolveAccountAtBlock(ctx, obj.From.Address, obj.Block.Number, block) +} + +// To is the resolver for the to field. +func (r *transactionResolver) To(ctx context.Context, obj *model.Transaction, block *uint64) (*model.Account, error) { + if obj.To == nil { + return nil, nil + } + return r.resolveAccountAtBlock(ctx, obj.To.Address, obj.Block.Number, block) +} + +// Block returns BlockResolver implementation. +func (r *Resolver) Block() BlockResolver { return &blockResolver{r} } + // Mutation returns MutationResolver implementation. func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } // Query returns QueryResolver implementation. func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } -// Block returns BlockResolver implementation. -func (r *Resolver) Block() BlockResolver { return &blockResolver{r} } - -// Account returns AccountResolver implementation. -func (r *Resolver) Account() AccountResolver { return &accountResolver{r} } +// Transaction returns TransactionResolver implementation. +func (r *Resolver) Transaction() TransactionResolver { return &transactionResolver{r} } +type blockResolver struct{ *Resolver } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } -type blockResolver struct{ *Resolver } -type accountResolver struct{ *Resolver } - -// Account is the resolver for the account field on Block. -func (r *blockResolver) Account(ctx context.Context, obj *model.Block, address string) (*model.Account, error) { - if !common.IsHexAddress(address) { - return nil, fmt.Errorf("invalid address %q", address) - } - addr := common.HexToAddress(address) - blockNumber := rpc.BlockNumber(obj.Number) - - balance, nonce, code, err := r.GraphQLAPI.GetAccountInfo(ctx, addr, blockNumber) - if err != nil { - return nil, err - } - - return &model.Account{ - Address: strings.ToLower(address), - Balance: balance, - TransactionCount: nonce, - Code: code, - BlockNum: obj.Number, - }, nil -} - -// Storage is the resolver for the storage field on Account. -// TODO: each storage(slot) call opens a new DB transaction and rebuilds the state reader, -// causing an N+1 pattern when a query resolves multiple slots. Fix by caching the state -// reader in the request context so all slots for the same account/block share one tx. -func (r *accountResolver) Storage(ctx context.Context, obj *model.Account, slot string) (string, error) { - addr := common.HexToAddress(obj.Address) - blockNumber := rpc.BlockNumber(obj.BlockNum) - - return r.GraphQLAPI.GetAccountStorage(ctx, addr, slot, blockNumber) -} +type transactionResolver struct{ *Resolver } diff --git a/cmd/rpcdaemon/graphql/graph/schema_resolvers_test.go b/cmd/rpcdaemon/graphql/graph/schema_resolvers_test.go index 63e9531de76..0ac1715fe59 100644 --- a/cmd/rpcdaemon/graphql/graph/schema_resolvers_test.go +++ b/cmd/rpcdaemon/graphql/graph/schema_resolvers_test.go @@ -58,3 +58,54 @@ func TestNewAccountAtBlock(t *testing.T) { t.Errorf("expected BlockNum=42, got %d", acc.BlockNum) } } + +func TestBlockResolver_TransactionAt(t *testing.T) { + r := &blockResolver{&Resolver{}} + + tx0 := &model.Transaction{Hash: "0xaaa"} + tx1 := &model.Transaction{Hash: "0xbbb"} + block := &model.Block{Transactions: []*model.Transaction{tx0, tx1}} + + tests := []struct { + name string + index int + want *model.Transaction + }{ + {"first", 0, tx0}, + {"second", 1, tx1}, + {"out of range", 2, nil}, + {"negative", -1, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := r.TransactionAt(context.Background(), block, tt.index) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("TransactionAt(%d) = %v, want %v", tt.index, got, tt.want) + } + }) + } +} + +func TestQueryResolver_Transaction_InvalidHash(t *testing.T) { + r := &queryResolver{&Resolver{}} // GraphQLAPI is nil; error fires before it is called + + tests := []struct { + name string + hash string + }{ + {"empty", ""}, + {"no 0x prefix", "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}, + {"invalid hex chars", "0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := r.Transaction(context.Background(), tt.hash) + if err == nil { + t.Errorf("hash %q: expected error, got nil", tt.hash) + } + }) + } +} diff --git a/rpc/jsonrpc/graphql_api.go b/rpc/jsonrpc/graphql_api.go index 9442672a151..6dd28b08a36 100644 --- a/rpc/jsonrpc/graphql_api.go +++ b/rpc/jsonrpc/graphql_api.go @@ -33,9 +33,11 @@ import ( type GraphQLAPI interface { GetBlockDetails(ctx context.Context, number rpc.BlockNumber) (map[string]any, error) + GetBlockDetailsByHash(ctx context.Context, hash common.Hash) (map[string]any, error) GetChainID(ctx context.Context) (*big.Int, error) GetAccountInfo(ctx context.Context, address common.Address, blockNumber rpc.BlockNumber) (balance string, nonce uint64, code string, err error) GetAccountStorage(ctx context.Context, address common.Address, slot string, blockNumber rpc.BlockNumber) (string, error) + GetBlockNumberForTx(ctx context.Context, hash common.Hash) (blockNum uint64, ok bool, err error) } type GraphQLAPIImpl struct { @@ -50,6 +52,17 @@ func NewGraphQLAPI(base *BaseAPI, db kv.TemporalRoDB) *GraphQLAPIImpl { } } +func (api *GraphQLAPIImpl) GetBlockNumberForTx(ctx context.Context, hash common.Hash) (uint64, bool, error) { + tx, err := api.db.BeginTemporalRo(ctx) + if err != nil { + return 0, false, err + } + defer tx.Rollback() + + blockNum, _, ok, err := api.txnLookup(ctx, tx, hash) + return blockNum, ok, err +} + func (api *GraphQLAPIImpl) GetChainID(ctx context.Context) (*big.Int, error) { tx, err := api.db.BeginTemporalRo(ctx) if err != nil { @@ -85,6 +98,33 @@ func (api *GraphQLAPIImpl) GetBlockDetails(ctx context.Context, blockNumber rpc. return nil, err } + return api.buildBlockDetailsResponse(ctx, tx, block, getBlockRes) +} + +func (api *GraphQLAPIImpl) GetBlockDetailsByHash(ctx context.Context, hash common.Hash) (map[string]any, error) { + tx, err := api.db.BeginTemporalRo(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback() + + block, err := api.blockByHashWithSenders(ctx, tx, hash) + if err != nil { + return nil, err + } + if block == nil { + return nil, nil + } + + getBlockRes, err := api.delegateGetBlockByNumber(tx, block, rpc.BlockNumber(block.NumberU64()), false) + if err != nil { + return nil, err + } + + return api.buildBlockDetailsResponse(ctx, tx, block, getBlockRes) +} + +func (api *GraphQLAPIImpl) buildBlockDetailsResponse(ctx context.Context, tx kv.TemporalTx, block *types.Block, getBlockRes map[string]any) (map[string]any, error) { chainConfig, err := api.chainConfig(ctx, tx) if err != nil { return nil, err @@ -104,6 +144,17 @@ func (api *GraphQLAPIImpl) GetBlockDetails(ctx context.Context, blockNumber rpc. transaction["value"] = txn.GetValue() transaction["data"] = txn.GetData() transaction["logs"] = receipt.Logs + transaction["gas"] = txn.GetGasLimit() + txType := txn.Type() + if txType == types.DynamicFeeTxType || txType == types.SetCodeTxType { + transaction["maxFeePerGas"] = txn.GetFeeCap() + transaction["maxPriorityFeePerGas"] = txn.GetTipCap() + } + transaction["accessList"] = txn.GetAccessList() + // Pre-Byzantium receipts have PostState instead of Status; default status to 0. + if _, hasStatus := transaction["status"]; !hasStatus { + transaction["status"] = hexutil.Uint64(0) + } result = append(result, transaction) } @@ -111,7 +162,6 @@ func (api *GraphQLAPIImpl) GetBlockDetails(ctx context.Context, blockNumber rpc. response["block"] = getBlockRes response["receipts"] = result - // Withdrawals wresult := make([]map[string]any, 0, len(block.Withdrawals())) for _, withdrawal := range block.Withdrawals() { wmap := make(map[string]any)