From 3b9f42b331360e93625327227eb1a1e9296fa8e3 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Mon, 21 Jul 2025 10:27:19 -0400 Subject: [PATCH 01/16] Add GraphQL API for wallet indexer (#239) --- .github/workflows/go.yaml | 12 +- Makefile | 21 +- go.mod | 17 +- go.sum | 32 +- gqlgen.yml | 181 + internal/data/accounts.go | 57 + internal/data/accounts_test.go | 135 + internal/data/operations.go | 97 +- internal/data/operations_test.go | 245 + internal/data/statechanges.go | 69 + internal/data/statechanges_test.go | 229 + internal/data/transactions.go | 95 + internal/data/transactions_test.go | 253 + ...6-10.3-create_indexer_table_operations.sql | 1 + internal/indexer/processors/helpers.go | 1 + internal/indexer/processors/helpers_test.go | 1 + internal/indexer/processors/token_transfer.go | 7 +- internal/indexer/types/types.go | 36 + internal/serve/graphql/dataloaders/loaders.go | 361 + internal/serve/graphql/generated/generated.go | 8334 +++++++++++++++++ .../serve/graphql/generated/models_gen.go | 6 + .../graphql/resolvers/account.resolvers.go | 68 + .../resolvers/account_resolvers_test.go | 156 + .../graphql/resolvers/operation.resolvers.go | 73 + .../resolvers/operation.resolvers_test.go | 151 + .../graphql/resolvers/queries.resolvers.go | 49 + .../resolvers/queries_resolvers_test.go | 415 + internal/serve/graphql/resolvers/resolver.go | 28 + .../resolvers/statechange.resolvers.go | 172 + .../resolvers/statechange_resolvers_test.go | 224 + .../resolvers/transaction.resolvers.go | 63 + .../resolvers/transaction_resolvers_test.go | 155 + internal/serve/graphql/scalars/uint32.go | 40 + .../serve/graphql/schema/account.graphqls | 20 + .../serve/graphql/schema/directives.graphqls | 23 + internal/serve/graphql/schema/enums.graphqls | 74 + .../serve/graphql/schema/operation.graphqls | 20 + .../serve/graphql/schema/queries.graphqls | 9 + .../serve/graphql/schema/scalars.graphqls | 18 + .../serve/graphql/schema/statechange.graphqls | 38 + .../serve/graphql/schema/transaction.graphqls | 22 + .../serve/middleware/dataloader_middleware.go | 29 + internal/serve/serve.go | 31 + scripts/exclude_from_coverage.sh | 2 + 44 files changed, 12043 insertions(+), 27 deletions(-) create mode 100644 gqlgen.yml create mode 100644 internal/serve/graphql/dataloaders/loaders.go create mode 100644 internal/serve/graphql/generated/generated.go create mode 100644 internal/serve/graphql/generated/models_gen.go create mode 100644 internal/serve/graphql/resolvers/account.resolvers.go create mode 100644 internal/serve/graphql/resolvers/account_resolvers_test.go create mode 100644 internal/serve/graphql/resolvers/operation.resolvers.go create mode 100644 internal/serve/graphql/resolvers/operation.resolvers_test.go create mode 100644 internal/serve/graphql/resolvers/queries.resolvers.go create mode 100644 internal/serve/graphql/resolvers/queries_resolvers_test.go create mode 100644 internal/serve/graphql/resolvers/resolver.go create mode 100644 internal/serve/graphql/resolvers/statechange.resolvers.go create mode 100644 internal/serve/graphql/resolvers/statechange_resolvers_test.go create mode 100644 internal/serve/graphql/resolvers/transaction.resolvers.go create mode 100644 internal/serve/graphql/resolvers/transaction_resolvers_test.go create mode 100644 internal/serve/graphql/scalars/uint32.go create mode 100644 internal/serve/graphql/schema/account.graphqls create mode 100644 internal/serve/graphql/schema/directives.graphqls create mode 100644 internal/serve/graphql/schema/enums.graphqls create mode 100644 internal/serve/graphql/schema/operation.graphqls create mode 100644 internal/serve/graphql/schema/queries.graphqls create mode 100644 internal/serve/graphql/schema/scalars.graphqls create mode 100644 internal/serve/graphql/schema/statechange.graphqls create mode 100644 internal/serve/graphql/schema/transaction.graphqls create mode 100644 internal/serve/middleware/dataloader_middleware.go diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index d1de6b06..f32f7c5c 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -5,6 +5,8 @@ on: push: branches: - main + paths-ignore: + - 'internal/serve/graphql/generated/**' workflow_call: # allows this workflow to be called from another workflow jobs: @@ -17,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23.2" + go-version: "1.24.2" cache: true cache-dependency-path: go.sum @@ -32,7 +34,7 @@ jobs: - name: Run `shadow@v0.31.0` run: | go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@v0.31.0 - shadow ./... + shadow ./... | { grep -v "generated.go" || true; } - name: Run `exhaustive@v0.12.0` run: | @@ -42,7 +44,7 @@ jobs: - name: Run `deadcode@v0.31.0` run: | go install golang.org/x/tools/cmd/deadcode@v0.31.0 - output=$(deadcode -test ./...) + output=$(deadcode -test ./... | { grep -v "UnmarshalUInt32" || true; }) if [[ -n "$output" ]]; then echo "🚨 Deadcode found:" echo "$output" @@ -74,7 +76,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23.2" + go-version: "1.24.2" cache: true cache-dependency-path: go.sum @@ -113,7 +115,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23.2" + go-version: "1.24.2" cache: true cache-dependency-path: go.sum diff --git a/Makefile b/Makefile index b7cacf92..df26159d 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ shadow: ## Run shadow analysis to find shadowed variables echo "Installing shadow..."; \ go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@v0.31.0; \ fi - $(shell go env GOPATH)/bin/shadow ./... + @$(shell go env GOPATH)/bin/shadow ./... | { grep -v "generated.go" || true; } exhaustive: ## Check exhaustiveness of switch statements @echo "==> Running exhaustive..." @@ -63,7 +63,7 @@ deadcode: ## Find unused code echo "Installing deadcode..."; \ go install golang.org/x/tools/cmd/deadcode@v0.31.0; \ fi - @output=$$($(shell go env GOPATH)/bin/deadcode -test ./...); \ + @output=$$($(shell go env GOPATH)/bin/deadcode -test ./... | grep -v "UnmarshalUInt32"); \ if [ -n "$$output" ]; then \ echo "🚨 Deadcode found:"; \ echo "$$output"; \ @@ -95,9 +95,24 @@ govulncheck: ## Check for known vulnerabilities @command -v govulncheck >/dev/null 2>&1 || { go install golang.org/x/vuln/cmd/govulncheck@latest; } $(shell go env GOPATH)/bin/govulncheck ./... -check: tidy fmt vet lint generate shadow exhaustive deadcode goimports govulncheck ## Run all checks +check: tidy fmt vet lint generate shadow exhaustive deadcode goimports govulncheck gql-validate ## Run all checks @echo "✅ All checks completed successfully" +# ==================================================================================== # +# GRAPHQL +# ==================================================================================== # +gql-generate: ## Generate GraphQL code using gqlgen + @echo "==> Generating GraphQL code..." + @command -v $(shell go env GOPATH)/bin/gqlgen >/dev/null 2>&1 || { go install github.com/99designs/gqlgen@v0.17.76; } + $(shell go env GOPATH)/bin/gqlgen generate + @echo "✅ GraphQL code generated successfully" + +gql-validate: ## Validate GraphQL schema + @echo "==> Validating GraphQL schema..." + @command -v $(shell go env GOPATH)/bin/gqlgen >/dev/null 2>&1 || { go install github.com/99designs/gqlgen@v0.17.76; } + $(shell go env GOPATH)/bin/gqlgen validate + @echo "✅ GraphQL schema is valid" + # ==================================================================================== # # TESTING # ==================================================================================== # diff --git a/go.mod b/go.mod index a5316715..19588284 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ module github.com/stellar/wallet-backend -go 1.23.2 +go 1.24 + +toolchain go1.24.2 require ( + github.com/99designs/gqlgen v0.17.76 github.com/alitto/pond v1.9.2 github.com/avast/retry-go/v4 v4.6.1 github.com/aws/aws-sdk-go v1.55.7 @@ -24,8 +27,10 @@ require ( github.com/spf13/viper v1.20.1 github.com/stellar/go v0.0.0-20250715171955-619d12932d40 github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 - github.com/stellar/stellar-rpc v0.9.6-0.20250523162628-6bb9d7a387d5 + github.com/stellar/stellar-rpc v0.9.6-0.20250618231249-2d3e8ff69365 github.com/stretchr/testify v1.10.0 + github.com/vektah/gqlparser/v2 v2.5.30 + github.com/vikstrous/dataloadgen v0.0.9 golang.org/x/term v0.33.0 golang.org/x/text v0.27.0 ) @@ -39,12 +44,13 @@ require ( cloud.google.com/go/iam v1.2.2 // indirect cloud.google.com/go/monitoring v1.21.2 // indirect cloud.google.com/go/storage v1.49.0 // indirect - github.com/BurntSushi/toml v1.3.2 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect @@ -93,8 +99,10 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/schema v1.4.1 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/guregu/null v4.0.0+incompatible // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -113,6 +121,7 @@ require ( github.com/rs/cors v1.11.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 // indirect + github.com/sosodev/duration v1.3.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect @@ -143,7 +152,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect google.golang.org/grpc v1.67.3 // indirect - google.golang.org/protobuf v1.36.5 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/djherbis/atime.v1 v1.0.0 // indirect gopkg.in/djherbis/stream.v1 v1.3.1 // indirect gopkg.in/tylerb/graceful.v1 v1.2.15 // indirect diff --git a/go.sum b/go.sum index b72f6c58..75c10102 100644 --- a/go.sum +++ b/go.sum @@ -25,9 +25,11 @@ cloud.google.com/go/trace v1.11.2 h1:4ZmaBdL8Ng/ajrgKqY5jfvzqMXbrDcBsUGXOT9aqTtI cloud.google.com/go/trace v1.11.2/go.mod h1:bn7OwXd4pd5rFuAnTrzBuoZ4ax2XQeG3qNgYmfCy0Io= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/99designs/gqlgen v0.17.76 h1:YsJBcfACWmXWU2t1yCjoGdOmqcTfOFpjbLAE443fmYI= +github.com/99designs/gqlgen v0.17.76/go.mod h1:miiU+PkAnTIDKMQ1BseUOIVeQHoiwYDZGCswoxl7xec= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 h1:UQ0AhxogsIRZDkElkblfnwjc3IaltCm2HUMvezQaL7s= @@ -40,6 +42,8 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8 github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f h1:zvClvFQwU++UpIUBGC8YmDlfhUrweEy1R1Fj1gu5iIM= github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -48,8 +52,12 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs= github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= @@ -123,6 +131,8 @@ github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+Zlfu github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/djherbis/fscache v0.10.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk= github.com/djherbis/fscache v0.10.1/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c= github.com/dlmiddlecote/sqlstats v1.0.2 h1:gSU11YN23D/iY50A2zVYwgXgy072khatTsIW6UPjUtI= @@ -229,10 +239,14 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw= github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -346,6 +360,8 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -362,8 +378,8 @@ github.com/stellar/go v0.0.0-20250715171955-619d12932d40 h1:D0k1prDxKuDcubED21I8 github.com/stellar/go v0.0.0-20250715171955-619d12932d40/go.mod h1:ac8hwpljbFXC3Sf9nGfqBXXEvAEdnNRqQHGqP7QN8oY= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= -github.com/stellar/stellar-rpc v0.9.6-0.20250523162628-6bb9d7a387d5 h1:V+XezRLVHuk6c1nMkXkWjCwtoHN7F+DK86dK2kkNSZo= -github.com/stellar/stellar-rpc v0.9.6-0.20250523162628-6bb9d7a387d5/go.mod h1:21zn7aUjDQZih77MDIFfsVN5Cjdiv1sMh+V51xgZwRw= +github.com/stellar/stellar-rpc v0.9.6-0.20250618231249-2d3e8ff69365 h1:44MGQ1AXnWiibX1u/aCqizdnbwQASsiSy68+aHODdGk= +github.com/stellar/stellar-rpc v0.9.6-0.20250618231249-2d3e8ff69365/go.mod h1:UT1k9G6GpubOvpqC+AqbziDyu2DWS5cLhnOb6QT+DU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -384,6 +400,10 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= +github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= +github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/vikstrous/dataloadgen v0.0.9 h1:pIVKyTZEFvq9Wbfk4zZ0uFQcMPhE/uCHnlnWB6sNA4g= +github.com/vikstrous/dataloadgen v0.0.9/go.mod h1:8vuQVpBH0ODbMKAPUdCAPcOGezoTIhgAjgex51t4vbg= github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc= github.com/xdrpp/goxdr v0.1.1/go.mod h1:dXo1scL/l6s7iME1gxHWo2XCppbHEKZS7m/KyYWkNzA= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= @@ -511,8 +531,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/gqlgen.yml b/gqlgen.yml new file mode 100644 index 00000000..48472203 --- /dev/null +++ b/gqlgen.yml @@ -0,0 +1,181 @@ +# Where are all the schema files located? globs are supported eg src/**/*.graphqls +schema: + - internal/serve/graphql/schema/*.graphqls + +# Where should the generated server code go? +exec: + package: graphql + layout: single-file # Only other option is "follow-schema," ie multi-file. + + # Only for single-file layout: + filename: internal/serve/graphql/generated/generated.go + + # Only for follow-schema layout: + # dir: internal/serve/graphql/generated + # filename_template: "{name}.generated.go" + + # Optional: Maximum number of goroutines in concurrency to use per child resolvers(default: unlimited) + # worker_limit: 1000 + +# Uncomment to enable federation +# federation: +# filename: graph/federation.go +# package: graph +# version: 2 +# options: +# computed_requires: true + +# Where should any generated models go? +model: + filename: internal/serve/graphql/generated/models_gen.go + package: graphql + + # Optional: Pass in a path to a new gotpl template to use for generating the models + # model_template: [your/path/model.gotpl] + +# Where should the resolver implementations go? +resolver: + package: resolvers + layout: follow-schema # Only other option is "single-file." + + # Only for single-file layout: + # filename: graph/resolver.go + + # Only for follow-schema layout: + dir: internal/serve/graphql/resolvers + filename_template: "{name}.resolvers.go" + + # Optional: turn on to not generate template comments above resolvers + # omit_template_comment: false + # Optional: Pass in a path to a new gotpl template to use for generating resolvers + # resolver_template: [your/path/resolver.gotpl] + # Optional: turn on to avoid rewriting existing resolver(s) when generating + # preserve_resolver: false + +# Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models +# struct_tag: json + +# Optional: turn on to use []Thing instead of []*Thing +# omit_slice_element_pointers: false + +# Optional: turn on to omit Is() methods to interface and unions +# omit_interface_checks: true + +# Optional: turn on to skip generation of ComplexityRoot struct content and Complexity function +# omit_complexity: false + +# Optional: turn on to not generate any file notice comments in generated files +# omit_gqlgen_file_notice: false + +# Optional: turn on to exclude the gqlgen version in the generated file notice. No effect if `omit_gqlgen_file_notice` is true. +# omit_gqlgen_version_in_file_notice: false + +# Optional: turn on to exclude root models such as Query and Mutation from the generated models file. +# omit_root_models: false + +# Optional: turn on to exclude resolver fields from the generated models file. +# omit_resolver_fields: false + +# Optional: turn off to make struct-type struct fields not use pointers +# e.g. type Thing struct { FieldA OtherThing } instead of { FieldA *OtherThing } +# struct_fields_always_pointers: true + +# Optional: turn off to make resolvers return values instead of pointers for structs +# resolvers_always_return_pointers: true + +# Optional: turn on to return pointers instead of values in unmarshalInput +# return_pointers_in_unmarshalinput: false + +# Optional: wrap nullable input fields with Omittable +# nullable_input_omittable: true + +# Optional: set to speed up generation time by not performing a final validation pass. +# skip_validation: true + +# Optional: set to skip running `go mod tidy` when generating server code +# skip_mod_tidy: true + +# Optional: if this is set to true, argument directives that +# decorate a field with a null value will still be called. +# +# This enables argumment directives to not just mutate +# argument values but to set them even if they're null. +call_argument_directives_with_null: true + +# This enables gql server to use function syntax for execution context +# instead of generating receiver methods of the execution context. +# use_function_syntax_for_execution_context: true + +# Optional: set build tags that will be used to load packages +# go_build_tags: +# - private +# - enterprise + +# Optional: set to modify the initialisms regarded for Go names +# go_initialisms: +# replace_defaults: false # if true, the default initialisms will get dropped in favor of the new ones instead of being added +# initialisms: # List of initialisms to for Go names +# - 'CC' +# - 'BCC' + +# gqlgen will search for any type names in the schema in these go packages +# if they match it will use them, otherwise it will generate them. +autobind: +# - "github.com/stellar/wallet-backend/graph/model" + +# This section declares type mapping between the GraphQL and go type systems +# +# The first line in each type will be used as defaults for resolver arguments and +# modelgen, the others will be allowed when binding to fields. Configure them to +# your liking +models: + ID: + model: + - github.com/99designs/gqlgen/graphql.IntID + # gqlgen provides a default GraphQL UUID convenience wrapper for github.com/google/uuid + # but you can override this to provide your own GraphQL UUID implementation + UUID: + model: + - github.com/99designs/gqlgen/graphql.UUID + # The GraphQL spec explicitly states that the Int type is a signed 32-bit + # integer. Using Go int or int64 to represent it can lead to unexpected + # behavior, and some GraphQL tools like Apollo Router will fail when + # communicating numbers that overflow 32-bits. + # + # You may choose to use the custom, built-in Int64 scalar to represent 64-bit + # integers, or ignore the spec and bind Int to graphql.Int / graphql.Int64 + # (the default behavior of gqlgen). This is fine in simple use cases when you + # do not need to worry about interoperability and only expect small numbers. + Int: + model: + - github.com/99designs/gqlgen/graphql.Int32 + UInt32: + model: + - github.com/stellar/wallet-backend/internal/serve/graphql/scalars.UInt32 + Int64: + model: + - github.com/99designs/gqlgen/graphql.Int64 + Time: + model: + - github.com/99designs/gqlgen/graphql.Time + Account: + model: + - github.com/stellar/wallet-backend/internal/indexer/types.Account + Transaction: + model: + - github.com/stellar/wallet-backend/internal/indexer/types.Transaction + Operation: + model: + - github.com/stellar/wallet-backend/internal/indexer/types.Operation + StateChange: + model: + - github.com/stellar/wallet-backend/internal/indexer/types.StateChange + OperationType: + model: + - github.com/stellar/wallet-backend/internal/indexer/types.OperationType + StateChangeCategory: + model: + - github.com/stellar/wallet-backend/internal/indexer/types.StateChangeCategory + StateChangeReason: + model: + - github.com/stellar/wallet-backend/internal/indexer/types.StateChangeReason diff --git a/internal/data/accounts.go b/internal/data/accounts.go index 299494e1..f8a4b117 100644 --- a/internal/data/accounts.go +++ b/internal/data/accounts.go @@ -5,7 +5,10 @@ import ( "fmt" "time" + "github.com/lib/pq" + "github.com/stellar/wallet-backend/internal/db" + "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/metrics" ) @@ -14,6 +17,20 @@ type AccountModel struct { MetricsService metrics.MetricsService } +func (m *AccountModel) Get(ctx context.Context, address string) (*types.Account, error) { + const query = `SELECT * FROM accounts WHERE stellar_address = $1` + var account types.Account + start := time.Now() + err := m.DB.GetContext(ctx, &account, query, address) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "accounts", duration) + if err != nil { + return nil, fmt.Errorf("getting account %s: %w", address, err) + } + m.MetricsService.IncDBQuery("SELECT", "accounts") + return &account, nil +} + func (m *AccountModel) Insert(ctx context.Context, address string) error { const query = `INSERT INTO accounts (stellar_address) VALUES ($1) ON CONFLICT DO NOTHING` start := time.Now() @@ -63,3 +80,43 @@ func (m *AccountModel) IsAccountFeeBumpEligible(ctx context.Context, address str m.MetricsService.IncDBQuery("SELECT", "accounts") return exists, nil } + +// BatchGetByTxHashes gets the accounts that are associated with the given transaction hashes. +func (m *AccountModel) BatchGetByTxHashes(ctx context.Context, txHashes []string) ([]*types.AccountWithTxHash, error) { + const query = ` + SELECT accounts.*, transactions_accounts.tx_hash + FROM transactions_accounts + INNER JOIN accounts + ON transactions_accounts.account_id = accounts.stellar_address + WHERE transactions_accounts.tx_hash = ANY($1)` + var accounts []*types.AccountWithTxHash + start := time.Now() + err := m.DB.SelectContext(ctx, &accounts, query, pq.Array(txHashes)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "accounts", duration) + if err != nil { + return nil, fmt.Errorf("getting accounts by transaction hashes: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "accounts") + return accounts, nil +} + +// BatchGetByOperationIDs gets the accounts that are associated with the given operation IDs. +func (m *AccountModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64) ([]*types.AccountWithOperationID, error) { + const query = ` + SELECT accounts.*, operations_accounts.operation_id + FROM operations_accounts + INNER JOIN accounts + ON operations_accounts.account_id = accounts.stellar_address + WHERE operations_accounts.operation_id = ANY($1)` + var accounts []*types.AccountWithOperationID + start := time.Now() + err := m.DB.SelectContext(ctx, &accounts, query, pq.Array(operationIDs)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "accounts", duration) + if err != nil { + return nil, fmt.Errorf("getting accounts by operation IDs: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "accounts") + return accounts, nil +} diff --git a/internal/data/accounts_test.go b/internal/data/accounts_test.go index ed370590..79cd85b8 100644 --- a/internal/data/accounts_test.go +++ b/internal/data/accounts_test.go @@ -78,6 +78,141 @@ func TestAccountModelDelete(t *testing.T) { assert.ErrorIs(t, err, sql.ErrNoRows) } +func TestAccountModelGet(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + address := keypair.MustRandom().Address() + + // Insert test account + result, err := m.DB.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + rowAffected, err := result.RowsAffected() + require.NoError(t, err) + require.Equal(t, int64(1), rowAffected) + + // Test Get function + account, err := m.Get(ctx, address) + require.NoError(t, err) + assert.Equal(t, address, account.StellarAddress) +} + +func TestAccountModelBatchGetByTxHashes(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + address1 := keypair.MustRandom().Address() + address2 := keypair.MustRandom().Address() + txHash1 := "tx1" + txHash2 := "tx2" + + // Insert test accounts + _, err = m.DB.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1), ($2)", address1, address2) + require.NoError(t, err) + + // Insert test transactions first + _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, 1, 'env1', 'res1', 'meta1', 1, NOW()), ($2, 2, 'env2', 'res2', 'meta2', 2, NOW())", txHash1, txHash2) + require.NoError(t, err) + + // Insert test transactions_accounts links + _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions_accounts (tx_hash, account_id) VALUES ($1, $2), ($3, $4)", txHash1, address1, txHash2, address2) + require.NoError(t, err) + + // Test BatchGetByTxHash function + accounts, err := m.BatchGetByTxHashes(ctx, []string{txHash1, txHash2}) + require.NoError(t, err) + assert.Len(t, accounts, 2) + + // Verify accounts are returned with correct tx_hash + addressSet := make(map[string]string) + for _, acc := range accounts { + addressSet[acc.StellarAddress] = acc.TxHash + } + assert.Equal(t, txHash1, addressSet[address1]) + assert.Equal(t, txHash2, addressSet[address2]) +} + +func TestAccountModelBatchGetByOperationIDs(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + address1 := keypair.MustRandom().Address() + address2 := keypair.MustRandom().Address() + operationID1 := int64(123) + operationID2 := int64(456) + + // Insert test accounts + _, err = m.DB.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1), ($2)", address1, address2) + require.NoError(t, err) + + // Insert test transactions first + _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ('tx1', 1, 'env1', 'res1', 'meta1', 1, NOW()), ('tx2', 2, 'env2', 'res2', 'meta2', 2, NOW())") + require.NoError(t, err) + + // Insert test operations first + _, err = m.DB.ExecContext(ctx, "INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) VALUES ($1, 'tx1', 'payment', 'xdr1', 1, NOW()), ($2, 'tx2', 'payment', 'xdr2', 2 , NOW())", operationID1, operationID2) + require.NoError(t, err) + + // Insert test operations_accounts links + _, err = m.DB.ExecContext(ctx, "INSERT INTO operations_accounts (operation_id, account_id) VALUES ($1, $2), ($3, $4)", operationID1, address1, operationID2, address2) + require.NoError(t, err) + + // Test BatchGetByOperationID function + accounts, err := m.BatchGetByOperationIDs(ctx, []int64{operationID1, operationID2}) + require.NoError(t, err) + assert.Len(t, accounts, 2) + + // Verify accounts are returned with correct operation_id + addressSet := make(map[string]int64) + for _, acc := range accounts { + addressSet[acc.StellarAddress] = acc.OperationID + } + assert.Equal(t, operationID1, addressSet[address1]) + assert.Equal(t, operationID2, addressSet[address2]) +} + func TestAccountModelIsAccountFeeBumpEligible(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() diff --git a/internal/data/operations.go b/internal/data/operations.go index 7328ec06..3ffc6835 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -18,6 +18,84 @@ type OperationModel struct { MetricsService metrics.MetricsService } +func (m *OperationModel) GetAll(ctx context.Context, limit *int32) ([]*types.Operation, error) { + query := `SELECT * FROM operations ORDER BY ledger_created_at DESC` + var args []interface{} + if limit != nil && *limit > 0 { + query += ` LIMIT $1` + args = append(args, *limit) + } + var operations []*types.Operation + start := time.Now() + err := m.DB.SelectContext(ctx, &operations, query, args...) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) + if err != nil { + return nil, fmt.Errorf("getting all operations: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "operations") + return operations, nil +} + +// BatchGetByTxHashes gets the operations that are associated with the given transaction hashes. +func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []string) ([]*types.Operation, error) { + const query = `SELECT * FROM operations WHERE tx_hash = ANY($1)` + var operations []*types.Operation + start := time.Now() + err := m.DB.SelectContext(ctx, &operations, query, pq.Array(txHashes)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) + if err != nil { + return nil, fmt.Errorf("getting operations by tx hashes: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "operations") + return operations, nil +} + +// BatchGetByAccountAddresses gets the operations that are associated with the given account addresses. +func (m *OperationModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string) ([]*types.OperationWithAccountID, error) { + const query = ` + SELECT o.*, oa.account_id + FROM operations o + INNER JOIN operations_accounts oa ON o.id = oa.operation_id + WHERE oa.account_id = ANY($1) + ORDER BY o.ledger_created_at DESC + ` + + var operationsWithAccounts []*types.OperationWithAccountID + start := time.Now() + err := m.DB.SelectContext(ctx, &operationsWithAccounts, query, pq.Array(accountAddresses)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) + if err != nil { + return nil, fmt.Errorf("getting operations by account addresses: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "operations") + + return operationsWithAccounts, nil +} + +// BatchGetByStateChangeIDs gets the operations that are associated with the given state change IDs. +func (m *OperationModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string) ([]*types.OperationWithStateChangeID, error) { + const query = ` + SELECT o.*, sc.id as state_change_id + FROM operations o + INNER JOIN state_changes sc ON o.id = sc.operation_id + WHERE sc.id = ANY($1) + ORDER BY o.ledger_created_at DESC + ` + var operationsWithStateChanges []*types.OperationWithStateChangeID + start := time.Now() + err := m.DB.SelectContext(ctx, &operationsWithStateChanges, query, pq.Array(stateChangeIDs)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) + if err != nil { + return nil, fmt.Errorf("getting operations by state change IDs: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "operations") + return operationsWithStateChanges, nil +} + // BatchInsert inserts the operations and the operations_accounts links. // It returns the IDs of the successfully inserted operations. func (m *OperationModel) BatchInsert( @@ -35,6 +113,7 @@ func (m *OperationModel) BatchInsert( txHashes := make([]string, len(operations)) operationTypes := make([]string, len(operations)) operationXDRs := make([]string, len(operations)) + ledgerNumbers := make([]uint32, len(operations)) ledgerCreatedAts := make([]time.Time, len(operations)) for i, op := range operations { @@ -42,6 +121,7 @@ func (m *OperationModel) BatchInsert( txHashes[i] = op.TxHash operationTypes[i] = string(op.OperationType) operationXDRs[i] = op.OperationXDR + ledgerNumbers[i] = op.LedgerNumber ledgerCreatedAts[i] = op.LedgerCreatedAt } @@ -61,7 +141,7 @@ func (m *OperationModel) BatchInsert( WITH -- STEP 1: Get existing accounts existing_accounts AS ( - SELECT stellar_address FROM accounts WHERE stellar_address=ANY($7) + SELECT stellar_address FROM accounts WHERE stellar_address=ANY($8) ), -- STEP 2: Get operation IDs to insert (connected to at least one existing account) @@ -69,8 +149,8 @@ func (m *OperationModel) BatchInsert( SELECT DISTINCT op_id FROM ( SELECT - UNNEST($6::bigint[]) AS op_id, - UNNEST($7::text[]) AS account_id + UNNEST($7::bigint[]) AS op_id, + UNNEST($8::text[]) AS account_id ) oa WHERE oa.account_id IN (SELECT stellar_address FROM existing_accounts) ), @@ -78,12 +158,13 @@ func (m *OperationModel) BatchInsert( -- STEP 3: Insert those operations inserted_operations AS ( INSERT INTO operations - (id, tx_hash, operation_type, operation_xdr, ledger_created_at) + (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) SELECT vo.op_id, o.tx_hash, o.operation_type, o.operation_xdr, + o.ledger_number, o.ledger_created_at FROM valid_operations vo JOIN ( @@ -92,7 +173,8 @@ func (m *OperationModel) BatchInsert( UNNEST($2::text[]) AS tx_hash, UNNEST($3::text[]) AS operation_type, UNNEST($4::text[]) AS operation_xdr, - UNNEST($5::timestamptz[]) AS ledger_created_at + UNNEST($5::bigint[]) AS ledger_number, + UNNEST($6::timestamptz[]) AS ledger_created_at ) o ON o.id = vo.op_id ON CONFLICT (id) DO NOTHING RETURNING id @@ -106,8 +188,8 @@ func (m *OperationModel) BatchInsert( oa.op_id, oa.account_id FROM ( SELECT - UNNEST($6::bigint[]) AS op_id, - UNNEST($7::text[]) AS account_id + UNNEST($7::bigint[]) AS op_id, + UNNEST($8::text[]) AS account_id ) oa INNER JOIN existing_accounts ea ON ea.stellar_address = oa.account_id INNER JOIN inserted_operations io ON io.id = oa.op_id @@ -125,6 +207,7 @@ func (m *OperationModel) BatchInsert( pq.Array(txHashes), pq.Array(operationTypes), pq.Array(operationXDRs), + pq.Array(ledgerNumbers), pq.Array(ledgerCreatedAts), pq.Array(opIDs), pq.Array(stellarAddresses), diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index a766cc0d..34afddc9 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -215,3 +215,248 @@ func Test_OperationModel_BatchInsert(t *testing.T) { }) } } + +func TestOperationModel_GetAll(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &OperationModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test operations + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) + VALUES + (1, 'tx1', 'payment', 'xdr1', 1, $1), + (2, 'tx2', 'create_account', 'xdr2', 2, $1), + (3, 'tx3', 'payment', 'xdr3', 3, $1) + `, now) + require.NoError(t, err) + + // Test GetAll without limit + operations, err := m.GetAll(ctx, nil) + require.NoError(t, err) + assert.Len(t, operations, 3) + + // Test GetAll with limit + limit := int32(2) + operations, err = m.GetAll(ctx, &limit) + require.NoError(t, err) + assert.Len(t, operations, 2) +} + +func TestOperationModel_BatchGetByTxHashes(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &OperationModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1) + `, now) + require.NoError(t, err) + + // Create test operations + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) + VALUES + (1, 'tx1', 'payment', 'xdr1', 1, $1), + (2, 'tx2', 'create_account', 'xdr2', 2, $1), + (3, 'tx1', 'payment', 'xdr3', 3, $1) + `, now) + require.NoError(t, err) + + // Test BatchGetByTxHash + operations, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}) + require.NoError(t, err) + assert.Len(t, operations, 3) + + // Verify operations are for correct tx hashes + txHashesFound := make(map[string]int) + for _, op := range operations { + txHashesFound[op.TxHash]++ + } + assert.Equal(t, 2, txHashesFound["tx1"]) + assert.Equal(t, 1, txHashesFound["tx2"]) +} + +func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &OperationModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test accounts + address1 := keypair.MustRandom().Address() + address2 := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1), ($2)", address1, address2) + require.NoError(t, err) + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test operations + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) + VALUES + (1, 'tx1', 'payment', 'xdr1', 1, $1), + (2, 'tx2', 'create_account', 'xdr2', 2, $1), + (3, 'tx3', 'payment', 'xdr3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test operations_accounts links + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations_accounts (operation_id, account_id) + VALUES + (1, $1), + (2, $1), + (3, $2) + `, address1, address2) + require.NoError(t, err) + + // Test BatchGetByAccount + operations, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}) + require.NoError(t, err) + assert.Len(t, operations, 3) + + // Verify operations are for correct accounts + accountsFound := make(map[string]int) + for _, op := range operations { + accountsFound[op.AccountID]++ + } + assert.Equal(t, 2, accountsFound[address1]) + assert.Equal(t, 1, accountsFound[address2]) +} + +func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &OperationModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test account + address := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test operations + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) + VALUES + (1, 'tx1', 'payment', 'xdr1', 1, $1), + (2, 'tx2', 'create_account', 'xdr2', 2, $1), + (3, 'tx3', 'payment', 'xdr3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test state changes + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + VALUES + ('sc1', 'credit', $1, 1, $2, 1, 'tx1'), + ('sc2', 'debit', $1, 2, $2, 2, 'tx2'), + ('sc3', 'credit', $1, 3, $2, 1, 'tx3') + `, now, address) + require.NoError(t, err) + + // Test BatchGetByStateChangeID + operations, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}) + require.NoError(t, err) + assert.Len(t, operations, 3) + + // Verify operations are for correct state change IDs + stateChangeIDsFound := make(map[string]int64) + for _, op := range operations { + stateChangeIDsFound[op.StateChangeID] = op.ID + } + assert.Equal(t, int64(1), stateChangeIDsFound["sc1"]) + assert.Equal(t, int64(2), stateChangeIDsFound["sc2"]) + assert.Equal(t, int64(1), stateChangeIDsFound["sc3"]) +} diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index 9ff69ca3..a4714294 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -17,6 +17,41 @@ type StateChangeModel struct { MetricsService metrics.MetricsService } +// BatchGetByAccountAddresses gets the state changes that are associated with the given account addresses. +func (m *StateChangeModel) BatchGetByAccountAddresses( + ctx context.Context, + accountAddresses []string, +) ([]*types.StateChange, error) { + query := ` + SELECT * FROM state_changes WHERE account_id = ANY($1) + ` + var stateChanges []*types.StateChange + err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(accountAddresses)) + if err != nil { + return nil, fmt.Errorf("getting state changes by account addresses: %w", err) + } + return stateChanges, nil +} + +func (m *StateChangeModel) GetAll(ctx context.Context, limit *int32) ([]*types.StateChange, error) { + start := time.Now() + query := `SELECT * FROM state_changes ORDER BY ledger_created_at DESC` + var args []interface{} + if limit != nil && *limit > 0 { + query += ` LIMIT $1` + args = append(args, *limit) + } + var stateChanges []*types.StateChange + err := m.DB.SelectContext(ctx, &stateChanges, query, args...) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) + if err != nil { + return nil, fmt.Errorf("getting all state changes: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "state_changes") + return stateChanges, nil +} + func (m *StateChangeModel) BatchInsert( ctx context.Context, sqlExecuter db.SQLExecuter, @@ -199,3 +234,37 @@ func (m *StateChangeModel) BatchInsert( return insertedIDs, nil } + +// BatchGetByTxHashes gets the state changes that are associated with the given transaction hashes. +func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []string) ([]*types.StateChange, error) { + const query = ` + SELECT * FROM state_changes WHERE tx_hash = ANY($1) + ` + var stateChanges []*types.StateChange + start := time.Now() + err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(txHashes)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) + if err != nil { + return nil, fmt.Errorf("getting state changes by transaction hashes: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "state_changes") + return stateChanges, nil +} + +// BatchGetByOperationIDs gets the state changes that are associated with the given operation IDs. +func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64) ([]*types.StateChange, error) { + const query = ` + SELECT * FROM state_changes WHERE operation_id = ANY($1) + ` + var stateChanges []*types.StateChange + start := time.Now() + err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(operationIDs)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) + if err != nil { + return nil, fmt.Errorf("getting state changes by operation IDs: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "state_changes") + return stateChanges, nil +} diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 518727ac..a6ba29a1 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -195,3 +195,232 @@ func TestStateChangeModel_BatchInsert(t *testing.T) { }) } } + +func TestStateChangeModel_BatchGetByAccountAddresses(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + now := time.Now() + + // Create test accounts + address1 := keypair.MustRandom().Address() + address2 := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1), ($2)", address1, address2) + require.NoError(t, err) + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test state changes + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + VALUES + ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), + ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), + ('sc3', 'credit', $1, 3, $3, 789, 'tx3') + `, now, address1, address2) + require.NoError(t, err) + + m := &StateChangeModel{ + DB: dbConnectionPool, + MetricsService: metrics.NewMockMetricsService(), + } + + // Test BatchGetByAccount + stateChanges, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}) + require.NoError(t, err) + assert.Len(t, stateChanges, 3) + + // Verify state changes are for correct accounts + accountsFound := make(map[string]int) + for _, sc := range stateChanges { + accountsFound[sc.AccountID]++ + } + assert.Equal(t, 2, accountsFound[address1]) + assert.Equal(t, 1, accountsFound[address2]) +} + +func TestStateChangeModel_GetAll(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &StateChangeModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test account + address := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test state changes + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + VALUES + ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), + ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), + ('sc3', 'credit', $1, 3, $2, 789, 'tx3') + `, now, address) + require.NoError(t, err) + + // Test GetAll without limit + stateChanges, err := m.GetAll(ctx, nil) + require.NoError(t, err) + assert.Len(t, stateChanges, 3) + + // Test GetAll with limit + limit := int32(2) + stateChanges, err = m.GetAll(ctx, &limit) + require.NoError(t, err) + assert.Len(t, stateChanges, 2) +} + +func TestStateChangeModel_BatchGetByTxHashes(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &StateChangeModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test account + address := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1) + `, now) + require.NoError(t, err) + + // Create test state changes + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + VALUES + ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), + ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), + ('sc3', 'credit', $1, 3, $2, 789, 'tx1') + `, now, address) + require.NoError(t, err) + + // Test BatchGetByTxHash + stateChanges, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}) + require.NoError(t, err) + assert.Len(t, stateChanges, 3) + + // Verify state changes are for correct tx hashes + txHashesFound := make(map[string]int) + for _, sc := range stateChanges { + txHashesFound[sc.TxHash]++ + } + assert.Equal(t, 2, txHashesFound["tx1"]) + assert.Equal(t, 1, txHashesFound["tx2"]) +} + +func TestStateChangeModel_BatchGetByOperationIDs(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &StateChangeModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test account + address := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test state changes + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + VALUES + ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), + ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), + ('sc3', 'credit', $1, 3, $2, 123, 'tx3') + `, now, address) + require.NoError(t, err) + + // Test BatchGetByOperationID + stateChanges, err := m.BatchGetByOperationIDs(ctx, []int64{123, 456}) + require.NoError(t, err) + assert.Len(t, stateChanges, 3) + + // Verify state changes are for correct operation IDs + operationIDsFound := make(map[int64]int) + for _, sc := range stateChanges { + operationIDsFound[sc.OperationID]++ + } + assert.Equal(t, 2, operationIDsFound[123]) + assert.Equal(t, 1, operationIDsFound[456]) +} diff --git a/internal/data/transactions.go b/internal/data/transactions.go index a121141d..4a27be90 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -18,6 +18,101 @@ type TransactionModel struct { MetricsService metrics.MetricsService } +func (m *TransactionModel) GetByHash(ctx context.Context, hash string) (*types.Transaction, error) { + const query = `SELECT * FROM transactions WHERE hash = $1` + var transaction types.Transaction + start := time.Now() + err := m.DB.GetContext(ctx, &transaction, query, hash) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) + if err != nil { + return nil, fmt.Errorf("getting transaction %s: %w", hash, err) + } + m.MetricsService.IncDBQuery("SELECT", "transactions") + return &transaction, nil +} + +func (m *TransactionModel) GetAll(ctx context.Context, limit *int32) ([]*types.Transaction, error) { + query := `SELECT * FROM transactions ORDER BY ledger_created_at DESC` + args := []interface{}{} + + if limit != nil && *limit > 0 { + query += ` LIMIT $1` + args = append(args, *limit) + } + + var transactions []*types.Transaction + start := time.Now() + err := m.DB.SelectContext(ctx, &transactions, query, args...) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) + if err != nil { + return nil, fmt.Errorf("getting transactions: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "transactions") + return transactions, nil +} + +// BatchGetByAccountAddresses gets the transactions that are associated with the given account addresses. +func (m *TransactionModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string) ([]*types.TransactionWithAccountID, error) { + const query = ` + SELECT transactions.*, transactions_accounts.account_id + FROM transactions_accounts + INNER JOIN transactions + ON transactions_accounts.tx_hash = transactions.hash + WHERE transactions_accounts.account_id = ANY($1)` + var transactions []*types.TransactionWithAccountID + start := time.Now() + err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(accountAddresses)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) + if err != nil { + return nil, fmt.Errorf("getting transactions by accounts: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "transactions") + return transactions, nil +} + +// BatchGetByOperationIDs gets the transactions that are associated with the given operation IDs. +func (m *TransactionModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64) ([]*types.TransactionWithOperationID, error) { + const query = ` + SELECT t.*, o.id as operation_id + FROM operations o + INNER JOIN transactions t + ON o.tx_hash = t.hash + WHERE o.id = ANY($1)` + var transactions []*types.TransactionWithOperationID + start := time.Now() + err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(operationIDs)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) + if err != nil { + return nil, fmt.Errorf("getting transactions by operation IDs: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "transactions") + return transactions, nil +} + +// BatchGetByStateChangeIDs gets the transactions that are associated with the given state changes +func (m *TransactionModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string) ([]*types.TransactionWithStateChangeID, error) { + const query = ` + SELECT t.*, sc.id as state_change_id + FROM state_changes sc + INNER JOIN transactions t ON t.hash = sc.tx_hash + WHERE sc.id = ANY($1) + ` + var transactions []*types.TransactionWithStateChangeID + start := time.Now() + err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(stateChangeIDs)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) + if err != nil { + return nil, fmt.Errorf("getting transactions by state change IDs: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "transactions") + return transactions, nil +} + // BatchInsert inserts the transactions and the transactions_accounts links. // It returns the hashes of the successfully inserted transactions. func (m *TransactionModel) BatchInsert( diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index 75ef2ddf..f5debf6a 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -189,3 +189,256 @@ func Test_TransactionModel_BatchInsert(t *testing.T) { }) } } + +func TestTransactionModel_GetByHash(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &TransactionModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test transaction + txHash := "test_tx_hash" + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES ($1, 1, 'envelope', 'result', 'meta', 1, $2) + `, txHash, now) + require.NoError(t, err) + + // Test GetByHash + transaction, err := m.GetByHash(ctx, txHash) + require.NoError(t, err) + assert.Equal(t, txHash, transaction.Hash) + assert.Equal(t, int64(1), transaction.ToID) + assert.Equal(t, "envelope", transaction.EnvelopeXDR) +} + +func TestTransactionModel_GetAll(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &TransactionModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test transactions + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'envelope1', 'result1', 'meta1', 1, $1), + ('tx2', 2, 'envelope2', 'result2', 'meta2', 2, $1), + ('tx3', 3, 'envelope3', 'result3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Test GetAll without limit + transactions, err := m.GetAll(ctx, nil) + require.NoError(t, err) + assert.Len(t, transactions, 3) + + // Test GetAll with limit + limit := int32(2) + transactions, err = m.GetAll(ctx, &limit) + require.NoError(t, err) + assert.Len(t, transactions, 2) +} + +func TestTransactionModel_BatchGetByAccountAddresses(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &TransactionModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test accounts + address1 := keypair.MustRandom().Address() + address2 := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1), ($2)", address1, address2) + require.NoError(t, err) + + // Create test transactions + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'envelope1', 'result1', 'meta1', 1, $1), + ('tx2', 2, 'envelope2', 'result2', 'meta2', 2, $1), + ('tx3', 3, 'envelope3', 'result3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test transactions_accounts links + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions_accounts (tx_hash, account_id) + VALUES + ('tx1', $1), + ('tx2', $1), + ('tx3', $2) + `, address1, address2) + require.NoError(t, err) + + // Test BatchGetByAccount + transactions, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}) + require.NoError(t, err) + assert.Len(t, transactions, 3) + + // Verify transactions are for correct accounts + accountsFound := make(map[string]int) + for _, tx := range transactions { + accountsFound[tx.AccountID]++ + } + assert.Equal(t, 2, accountsFound[address1]) + assert.Equal(t, 1, accountsFound[address2]) +} + +func TestTransactionModel_BatchGetByOperationIDs(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &TransactionModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test transactions + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'envelope1', 'result1', 'meta1', 1, $1), + ('tx2', 2, 'envelope2', 'result2', 'meta2', 2, $1), + ('tx3', 3, 'envelope3', 'result3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test operations + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) + VALUES + (1, 'tx1', 'payment', 'xdr1', 1, $1), + (2, 'tx2', 'create_account', 'xdr2', 2, $1), + (3, 'tx1', 'payment', 'xdr3', 3, $1) + `, now) + require.NoError(t, err) + + // Test BatchGetByOperationID + transactions, err := m.BatchGetByOperationIDs(ctx, []int64{1, 2, 3}) + require.NoError(t, err) + assert.Len(t, transactions, 3) + + // Verify transactions are for correct operation IDs + operationIDsFound := make(map[int64]string) + for _, tx := range transactions { + operationIDsFound[tx.OperationID] = tx.Hash + } + assert.Equal(t, "tx1", operationIDsFound[1]) + assert.Equal(t, "tx2", operationIDsFound[2]) + assert.Equal(t, "tx1", operationIDsFound[3]) +} + +func TestTransactionModel_BatchGetByStateChangeIDs(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &TransactionModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test account + address := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + + // Create test transactions + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'envelope1', 'result1', 'meta1', 1, $1), + ('tx2', 2, 'envelope2', 'result2', 'meta2', 2, $1), + ('tx3', 3, 'envelope3', 'result3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test state changes + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + VALUES + ('sc1', 'credit', $1, 1, $2, 1, 'tx1'), + ('sc2', 'debit', $1, 2, $2, 2, 'tx2'), + ('sc3', 'credit', $1, 3, $2, 3, 'tx1') + `, now, address) + require.NoError(t, err) + + // Test BatchGetByStateChangeID + transactions, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}) + require.NoError(t, err) + assert.Len(t, transactions, 3) + + // Verify transactions are for correct state change IDs + stateChangeIDsFound := make(map[string]string) + for _, tx := range transactions { + stateChangeIDsFound[tx.StateChangeID] = tx.Hash + } + assert.Equal(t, "tx1", stateChangeIDsFound["sc1"]) + assert.Equal(t, "tx2", stateChangeIDsFound["sc2"]) + assert.Equal(t, "tx1", stateChangeIDsFound["sc3"]) +} diff --git a/internal/db/migrations/2025-06-10.3-create_indexer_table_operations.sql b/internal/db/migrations/2025-06-10.3-create_indexer_table_operations.sql index 4c41fa47..ffd097dd 100644 --- a/internal/db/migrations/2025-06-10.3-create_indexer_table_operations.sql +++ b/internal/db/migrations/2025-06-10.3-create_indexer_table_operations.sql @@ -6,6 +6,7 @@ CREATE TABLE operations ( tx_hash TEXT NOT NULL REFERENCES transactions(hash), operation_type TEXT NOT NULL, operation_xdr TEXT, + ledger_number INTEGER NOT NULL, ledger_created_at TIMESTAMPTZ NOT NULL, ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/internal/indexer/processors/helpers.go b/internal/indexer/processors/helpers.go index 646bbbdb..ed5792a5 100644 --- a/internal/indexer/processors/helpers.go +++ b/internal/indexer/processors/helpers.go @@ -50,6 +50,7 @@ func ConvertOperation(transaction *ingest.LedgerTransaction, op *xdr.Operation, ID: opID, OperationType: types.OperationTypeFromXDR(op.Body.Type), OperationXDR: xdrOpStr, + LedgerNumber: transaction.Ledger.LedgerSequence(), LedgerCreatedAt: transaction.Ledger.ClosedAt(), TxHash: transaction.Hash.HexString(), }, nil diff --git a/internal/indexer/processors/helpers_test.go b/internal/indexer/processors/helpers_test.go index f0235ae9..ae02b224 100644 --- a/internal/indexer/processors/helpers_test.go +++ b/internal/indexer/processors/helpers_test.go @@ -69,6 +69,7 @@ func Test_ConvertOperation(t *testing.T) { OperationType: types.OperationTypeFromXDR(op.Body.Type), OperationXDR: opXDRStr, LedgerCreatedAt: time.Date(2025, time.June, 19, 0, 3, 16, 0, time.UTC), + LedgerNumber: 4873, TxHash: "64eb94acc50eefc323cea80387fdceefc31466cc3a69eb8d2b312e0b5c3c62f0", } assert.Equal(t, wantDataOp, gotDataOp) diff --git a/internal/indexer/processors/token_transfer.go b/internal/indexer/processors/token_transfer.go index dbcf4b0a..856c6999 100644 --- a/internal/indexer/processors/token_transfer.go +++ b/internal/indexer/processors/token_transfer.go @@ -58,7 +58,10 @@ func (p *TokenTransferProcessor) ProcessTransaction(ctx context.Context, tx inge for _, e := range txEvents.OperationEvents { meta := e.GetMeta() contractAddress := meta.GetContractAddress() - opIdx := meta.GetOperationIndex() + // The input operationIndex is 0-indexed. + // As per SEP-35, the OperationIndex in the output proto is 1-indexed. + // So we need to subtract 1 from the input operationIndex to get the correct operation index. + opIdx := meta.GetOperationIndex() - 1 event := e.GetEvent() // For non-fee events, we need operation details to determine the correct state change type @@ -86,7 +89,7 @@ func (p *TokenTransferProcessor) ProcessTransaction(ctx context.Context, tx inge // parseOperationDetails extracts operation metadata needed for processing token transfer events. // Returns operation ID, type, and source account which determine how events should be categorized. func (p *TokenTransferProcessor) parseOperationDetails(tx ingest.LedgerTransaction, ledgerIdx uint32, txIdx uint32, opIdx uint32) (int64, *xdr.OperationType, string, error) { - op, found := tx.GetOperation(opIdx - 1) + op, found := tx.GetOperation(opIdx) if !found { return 0, nil, "", ErrOperationNotFound } diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index 191a6f4a..0df9d846 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -15,6 +15,16 @@ type Account struct { CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` } +type AccountWithTxHash struct { + Account + TxHash string `json:"txHash,omitempty" db:"tx_hash"` +} + +type AccountWithOperationID struct { + Account + OperationID int64 `json:"operationId,omitempty" db:"operation_id"` +} + type Transaction struct { Hash string `json:"hash,omitempty" db:"hash"` ToID int64 `json:"to_id,omitempty" db:"to_id"` @@ -30,6 +40,21 @@ type Transaction struct { StateChanges []StateChange `json:"stateChanges,omitempty" db:"state_changes"` } +type TransactionWithAccountID struct { + Transaction + AccountID string `json:"accountId,omitempty" db:"account_id"` +} + +type TransactionWithStateChangeID struct { + Transaction + StateChangeID string `json:"stateChangeId,omitempty" db:"state_change_id"` +} + +type TransactionWithOperationID struct { + Transaction + OperationID int64 `json:"operationId,omitempty" db:"operation_id"` +} + // xdrToOperationTypeMap provides 1:1 mapping between XDR OperationType and custom OperationType var xdrToOperationTypeMap = map[xdr.OperationType]OperationType{ xdr.OperationTypeCreateAccount: OperationTypeCreateAccount, @@ -104,6 +129,7 @@ type Operation struct { ID int64 `json:"id,omitempty" db:"id"` OperationType OperationType `json:"operationType,omitempty" db:"operation_type"` OperationXDR string `json:"operationXdr,omitempty" db:"operation_xdr"` + LedgerNumber uint32 `json:"ledgerNumber,omitempty" db:"ledger_number"` LedgerCreatedAt time.Time `json:"ledgerCreatedAt,omitempty" db:"ledger_created_at"` IngestedAt time.Time `json:"ingestedAt,omitempty" db:"ingested_at"` // Relationships: @@ -113,6 +139,16 @@ type Operation struct { StateChanges []StateChange `json:"stateChanges,omitempty" db:"state_changes"` } +type OperationWithAccountID struct { + Operation + AccountID string `db:"account_id"` +} + +type OperationWithStateChangeID struct { + Operation + StateChangeID string `db:"state_change_id"` +} + type StateChangeCategory string const ( diff --git a/internal/serve/graphql/dataloaders/loaders.go b/internal/serve/graphql/dataloaders/loaders.go new file mode 100644 index 00000000..4f71cd7a --- /dev/null +++ b/internal/serve/graphql/dataloaders/loaders.go @@ -0,0 +1,361 @@ +// GraphQL DataLoaders package - implements efficient batching for GraphQL resolvers +// DataLoaders solve the N+1 query problem by batching multiple requests into single database queries +// This is essential for GraphQL performance when resolving relationship fields +package dataloaders + +import ( + "context" + "time" + + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +// Dataloaders struct holds all dataloader instances for GraphQL resolvers +// Each dataloader batches requests for a specific type of data relationship +// GraphQL resolvers use these to efficiently load related data +type Dataloaders struct { + // OperationsByTxHashLoader batches requests for operations by transaction hash + // Used by Transaction.operations field resolver to prevent N+1 queries + OperationsByTxHashLoader *dataloadgen.Loader[string, []*types.Operation] + + // TransactionsByAccountLoader batches requests for transactions by account address + // Used by Account.transactions field resolver to prevent N+1 queries + TransactionsByAccountLoader *dataloadgen.Loader[string, []*types.Transaction] + + // OperationsByAccountLoader batches requests for operations by account address + // Used by Account.operations field resolver to prevent N+1 queries + OperationsByAccountLoader *dataloadgen.Loader[string, []*types.Operation] + + // StateChangesByAccountLoader batches requests for state changes by account address + // Used by Account.statechanges field resolver to prevent N+1 queries + StateChangesByAccountLoader *dataloadgen.Loader[string, []*types.StateChange] + + // AccountsByTxHashLoader batches requests for accounts by transaction hash + // Used by Transaction.accounts field resolver to prevent N+1 queries + AccountsByTxHashLoader *dataloadgen.Loader[string, []*types.Account] + + // StateChangesByTxHashLoader batches requests for state changes by transaction hash + // Used by Transaction.stateChanges field resolver to prevent N+1 queries + StateChangesByTxHashLoader *dataloadgen.Loader[string, []*types.StateChange] + + // TransactionsByOperationIDLoader batches requests for transactions by operation ID + // Used by Operation.transaction field resolver to prevent N+1 queries + TransactionsByOperationIDLoader *dataloadgen.Loader[int64, *types.Transaction] + + // AccountsByOperationIDLoader batches requests for accounts by operation ID + // Used by Operation.accounts field resolver to prevent N+1 queries + AccountsByOperationIDLoader *dataloadgen.Loader[int64, []*types.Account] + + // StateChangesByOperationIDLoader batches requests for state changes by operation ID + // Used by Operation.stateChanges field resolver to prevent N+1 queries + StateChangesByOperationIDLoader *dataloadgen.Loader[int64, []*types.StateChange] + + // OperationByStateChangeIDLoader batches requests for operations by state change ID + // Used by StateChange.operation field resolver to prevent N+1 queries + OperationByStateChangeIDLoader *dataloadgen.Loader[string, *types.Operation] + + // TransactionByStateChangeIDLoader batches requests for transactions by state change ID + // Used by StateChange.transaction field resolver to prevent N+1 queries + TransactionByStateChangeIDLoader *dataloadgen.Loader[string, *types.Transaction] +} + +// NewDataloaders creates a new instance of all dataloaders +// This is called during GraphQL server initialization +// The dataloaders are then injected into GraphQL context by middleware +// GraphQL resolvers access these loaders to batch database queries efficiently +func NewDataloaders(models *data.Models) *Dataloaders { + return &Dataloaders{ + OperationsByTxHashLoader: operationsByTxHashLoader(models), + TransactionsByAccountLoader: transactionsByAccountLoader(models), + OperationsByAccountLoader: operationsByAccountLoader(models), + StateChangesByAccountLoader: stateChangesByAccountLoader(models), + AccountsByTxHashLoader: accountsByTxHashLoader(models), + StateChangesByTxHashLoader: stateChangesByTxHashLoader(models), + TransactionsByOperationIDLoader: txByOperationIDLoader(models), + AccountsByOperationIDLoader: accountsByOperationIDLoader(models), + StateChangesByOperationIDLoader: stateChangesByOperationIDLoader(models), + OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models), + TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models), + } +} + +// newOneToManyLoader is a generic helper function that creates a dataloader for one-to-many relationships. +// It abstracts the common pattern of fetching items, grouping them by a key, and returning them in the +// order of the original keys. This reduces boilerplate code in dataloader definitions. +// +// Parameters: +// - fetcher: A function that fetches all items for a given set of keys. +// - getKey: A function that extracts the grouping key from an item. +// +// Returns: +// - A configured dataloadgen.Loader for one-to-many relationships. +func newOneToManyLoader[K comparable, V any, T any]( + fetcher func(ctx context.Context, keys []K) ([]T, error), + getKey func(item T) K, + transform func(item T) V, +) *dataloadgen.Loader[K, []*V] { + return dataloadgen.NewLoader( + func(ctx context.Context, keys []K) ([][]*V, []error) { + items, err := fetcher(ctx, keys) + if err != nil { + // if the fetcher function returns an error, we'll return it for each key. + // this is a requirement for dataloadgen, which expects an error for each key. + errors := make([]error, len(keys)) + for i := range keys { + errors[i] = err + } + return nil, errors + } + + itemsByKey := make(map[K][]*V) + for _, item := range items { + key := getKey(item) + transformedItem := transform(item) + itemsByKey[key] = append(itemsByKey[key], &transformedItem) + } + + result := make([][]*V, len(keys)) + for i, key := range keys { + result[i] = itemsByKey[key] + } + + return result, nil + }, + dataloadgen.WithBatchCapacity(100), + dataloadgen.WithWait(5*time.Millisecond), + ) +} + +// newOneToOneLoader is a generic helper function that creates a dataloader for one-to-one relationships. +// It abstracts the common pattern of fetching a single item for each key and returning them in the +// order of the original keys. This is useful for relationships where each key maps to exactly one item. +// +// Parameters: +// - fetcher: A function that fetches all items for a given set of keys. +// - getKey: A function that extracts the grouping key from an item. +// - setKey: A function that associates a fetched item with its corresponding key. This is necessary +// because the fetcher may not return items in the same order as the input keys. +// +// Returns: +// - A configured dataloadgen.Loader for one-to-one relationships. +func newOneToOneLoader[K comparable, V any, T any]( + fetcher func(ctx context.Context, keys []K) ([]T, error), + getKey func(item T) K, + transform func(item T) V, +) *dataloadgen.Loader[K, *V] { + return dataloadgen.NewLoader( + func(ctx context.Context, keys []K) ([]*V, []error) { + items, err := fetcher(ctx, keys) + if err != nil { + errors := make([]error, len(keys)) + for i := range keys { + errors[i] = err + } + return nil, errors + } + + itemsByKey := make(map[K]*V) + for _, item := range items { + key := getKey(item) + transformedItem := transform(item) + itemsByKey[key] = &transformedItem + } + result := make([]*V, len(keys)) + for i, key := range keys { + result[i] = itemsByKey[key] + } + + return result, nil + }, + dataloadgen.WithBatchCapacity(100), + dataloadgen.WithWait(5*time.Millisecond), + ) +} + +// opByTxHashLoader creates a dataloader for fetching operations by transaction hash +// This prevents N+1 queries when multiple transactions request their operations +// The loader batches multiple transaction hashes into a single database query +func operationsByTxHashLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Operation] { + return newOneToManyLoader( + func(ctx context.Context, keys []string) ([]*types.Operation, error) { + return models.Operations.BatchGetByTxHashes(ctx, keys) + }, + func(item *types.Operation) string { + return item.TxHash + }, + func(item *types.Operation) types.Operation { + return *item + }, + ) +} + +// txByAccountLoader creates a dataloader for fetching transactions by account address +// This prevents N+1 queries when multiple accounts request their transactions +// The loader batches multiple account addresses into a single database query +func transactionsByAccountLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Transaction] { + return newOneToManyLoader( + func(ctx context.Context, keys []string) ([]*types.TransactionWithAccountID, error) { + return models.Transactions.BatchGetByAccountAddresses(ctx, keys) + }, + func(item *types.TransactionWithAccountID) string { + return item.AccountID + }, + func(item *types.TransactionWithAccountID) types.Transaction { + return item.Transaction + }, + ) +} + +// opByAccountLoader creates a dataloader for fetching operations by account address +// This prevents N+1 queries when multiple accounts request their operations +// The loader batches multiple account addresses into a single database query +func operationsByAccountLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Operation] { + return newOneToManyLoader( + func(ctx context.Context, keys []string) ([]*types.OperationWithAccountID, error) { + return models.Operations.BatchGetByAccountAddresses(ctx, keys) + }, + func(item *types.OperationWithAccountID) string { + return item.AccountID + }, + func(item *types.OperationWithAccountID) types.Operation { + return item.Operation + }, + ) +} + +// stateChangesByAccountLoader creates a dataloader for fetching state changes by account address +func stateChangesByAccountLoader(models *data.Models) *dataloadgen.Loader[string, []*types.StateChange] { + return newOneToManyLoader( + func(ctx context.Context, keys []string) ([]*types.StateChange, error) { + return models.StateChanges.BatchGetByAccountAddresses(ctx, keys) + }, + func(item *types.StateChange) string { + return item.AccountID + }, + func(item *types.StateChange) types.StateChange { + return *item + }, + ) +} + +// accountsByTxHashLoader creates a dataloader for fetching accounts by transaction hash +// This prevents N+1 queries when multiple transactions request their accounts +// The loader batches multiple transaction hashes into a single database query +func accountsByTxHashLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Account] { + return newOneToManyLoader( + func(ctx context.Context, keys []string) ([]*types.AccountWithTxHash, error) { + return models.Account.BatchGetByTxHashes(ctx, keys) + }, + func(item *types.AccountWithTxHash) string { + return item.TxHash + }, + func(item *types.AccountWithTxHash) types.Account { + return item.Account + }, + ) +} + +// stateChangesByTxHashLoader creates a dataloader for fetching state changes by transaction hash +// This prevents N+1 queries when multiple transactions request their state changes +// The loader batches multiple transaction hashes into a single database query +func stateChangesByTxHashLoader(models *data.Models) *dataloadgen.Loader[string, []*types.StateChange] { + return newOneToManyLoader( + func(ctx context.Context, keys []string) ([]*types.StateChange, error) { + return models.StateChanges.BatchGetByTxHashes(ctx, keys) + }, + func(item *types.StateChange) string { + return item.TxHash + }, + func(item *types.StateChange) types.StateChange { + return *item + }, + ) +} + +// txByOperationIDLoader creates a dataloader for fetching transactions by operation ID +// This prevents N+1 queries when multiple operations request their transaction +// The loader batches multiple operation IDs into a single database query +func txByOperationIDLoader(models *data.Models) *dataloadgen.Loader[int64, *types.Transaction] { + return newOneToOneLoader( + func(ctx context.Context, keys []int64) ([]*types.TransactionWithOperationID, error) { + return models.Transactions.BatchGetByOperationIDs(ctx, keys) + }, + func(item *types.TransactionWithOperationID) int64 { + return item.OperationID + }, + func(item *types.TransactionWithOperationID) types.Transaction { + return item.Transaction + }, + ) +} + +// accountsByOperationIDLoader creates a dataloader for fetching accounts by operation ID +// This prevents N+1 queries when multiple operations request their accounts +// The loader batches multiple operation IDs into a single database query +func accountsByOperationIDLoader(models *data.Models) *dataloadgen.Loader[int64, []*types.Account] { + return newOneToManyLoader( + func(ctx context.Context, keys []int64) ([]*types.AccountWithOperationID, error) { + return models.Account.BatchGetByOperationIDs(ctx, keys) + }, + func(item *types.AccountWithOperationID) int64 { + return item.OperationID + }, + func(item *types.AccountWithOperationID) types.Account { + return item.Account + }, + ) +} + +// stateChangesByOperationIDLoader creates a dataloader for fetching state changes by operation ID +// This prevents N+1 queries when multiple operations request their state changes +// The loader batches multiple operation IDs into a single database query +func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[int64, []*types.StateChange] { + return newOneToManyLoader( + func(ctx context.Context, keys []int64) ([]*types.StateChange, error) { + return models.StateChanges.BatchGetByOperationIDs(ctx, keys) + }, + func(item *types.StateChange) int64 { + return item.OperationID + }, + func(item *types.StateChange) types.StateChange { + return *item + }, + ) +} + +// operationByStateChangeIDLoader creates a dataloader for fetching operations by state change ID +// This prevents N+1 queries when multiple state changes request their operations +// The loader batches multiple state change IDs into a single database query +func operationByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[string, *types.Operation] { + return newOneToOneLoader( + func(ctx context.Context, keys []string) ([]*types.OperationWithStateChangeID, error) { + return models.Operations.BatchGetByStateChangeIDs(ctx, keys) + }, + func(item *types.OperationWithStateChangeID) string { + return item.StateChangeID + }, + func(item *types.OperationWithStateChangeID) types.Operation { + return item.Operation + }, + ) +} + +// transactionByStateChangeIDLoader creates a dataloader for fetching transactions by state change ID +// This prevents N+1 queries when multiple state changes request their transactions +// The loader batches multiple state change IDs into a single database query +func transactionByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[string, *types.Transaction] { + return newOneToOneLoader( + func(ctx context.Context, keys []string) ([]*types.TransactionWithStateChangeID, error) { + return models.Transactions.BatchGetByStateChangeIDs(ctx, keys) + }, + func(item *types.TransactionWithStateChangeID) string { + return item.StateChangeID + }, + func(item *types.TransactionWithStateChangeID) types.Transaction { + return item.Transaction + }, + ) +} diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go new file mode 100644 index 00000000..2f30b7f9 --- /dev/null +++ b/internal/serve/graphql/generated/generated.go @@ -0,0 +1,8334 @@ +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. + +package graphql + +import ( + "bytes" + "context" + "errors" + "fmt" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/99designs/gqlgen/graphql/introspection" + gqlparser "github.com/vektah/gqlparser/v2" + "github.com/vektah/gqlparser/v2/ast" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/scalars" +) + +// region ************************** generated!.gotpl ************************** + +// NewExecutableSchema creates an ExecutableSchema from the ResolverRoot interface. +func NewExecutableSchema(cfg Config) graphql.ExecutableSchema { + return &executableSchema{ + schema: cfg.Schema, + resolvers: cfg.Resolvers, + directives: cfg.Directives, + complexity: cfg.Complexity, + } +} + +type Config struct { + Schema *ast.Schema + Resolvers ResolverRoot + Directives DirectiveRoot + Complexity ComplexityRoot +} + +type ResolverRoot interface { + Account() AccountResolver + Operation() OperationResolver + Query() QueryResolver + StateChange() StateChangeResolver + Transaction() TransactionResolver +} + +type DirectiveRoot struct { +} + +type ComplexityRoot struct { + Account struct { + Address func(childComplexity int) int + Operations func(childComplexity int) int + StateChanges func(childComplexity int) int + Transactions func(childComplexity int) int + } + + Operation struct { + Accounts func(childComplexity int) int + ID func(childComplexity int) int + IngestedAt func(childComplexity int) int + LedgerCreatedAt func(childComplexity int) int + LedgerNumber func(childComplexity int) int + OperationType func(childComplexity int) int + OperationXDR func(childComplexity int) int + StateChanges func(childComplexity int) int + Transaction func(childComplexity int) int + } + + Query struct { + Account func(childComplexity int, address string) int + Operations func(childComplexity int, limit *int32) int + StateChanges func(childComplexity int, limit *int32) int + TransactionByHash func(childComplexity int, hash string) int + Transactions func(childComplexity int, limit *int32) int + } + + StateChange struct { + AccountID func(childComplexity int) int + Amount func(childComplexity int) int + ClaimableBalanceID func(childComplexity int) int + Flags func(childComplexity int) int + ID func(childComplexity int) int + IngestedAt func(childComplexity int) int + KeyValue func(childComplexity int) int + LedgerCreatedAt func(childComplexity int) int + LedgerNumber func(childComplexity int) int + LiquidityPoolID func(childComplexity int) int + OfferID func(childComplexity int) int + Operation func(childComplexity int) int + SignerAccountID func(childComplexity int) int + SignerWeights func(childComplexity int) int + SpenderAccountID func(childComplexity int) int + SponsorAccountID func(childComplexity int) int + SponsoredAccountID func(childComplexity int) int + StateChangeCategory func(childComplexity int) int + StateChangeReason func(childComplexity int) int + Thresholds func(childComplexity int) int + TokenID func(childComplexity int) int + Transaction func(childComplexity int) int + } + + Transaction struct { + Accounts func(childComplexity int) int + EnvelopeXDR func(childComplexity int) int + Hash func(childComplexity int) int + IngestedAt func(childComplexity int) int + LedgerCreatedAt func(childComplexity int) int + LedgerNumber func(childComplexity int) int + MetaXDR func(childComplexity int) int + Operations func(childComplexity int) int + ResultXDR func(childComplexity int) int + StateChanges func(childComplexity int) int + } +} + +type AccountResolver interface { + Address(ctx context.Context, obj *types.Account) (string, error) + Transactions(ctx context.Context, obj *types.Account) ([]*types.Transaction, error) + Operations(ctx context.Context, obj *types.Account) ([]*types.Operation, error) + StateChanges(ctx context.Context, obj *types.Account) ([]*types.StateChange, error) +} +type OperationResolver interface { + Transaction(ctx context.Context, obj *types.Operation) (*types.Transaction, error) + Accounts(ctx context.Context, obj *types.Operation) ([]*types.Account, error) + StateChanges(ctx context.Context, obj *types.Operation) ([]*types.StateChange, error) +} +type QueryResolver interface { + TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) + Transactions(ctx context.Context, limit *int32) ([]*types.Transaction, error) + Account(ctx context.Context, address string) (*types.Account, error) + Operations(ctx context.Context, limit *int32) ([]*types.Operation, error) + StateChanges(ctx context.Context, limit *int32) ([]*types.StateChange, error) +} +type StateChangeResolver interface { + TokenID(ctx context.Context, obj *types.StateChange) (*string, error) + Amount(ctx context.Context, obj *types.StateChange) (*string, error) + ClaimableBalanceID(ctx context.Context, obj *types.StateChange) (*string, error) + LiquidityPoolID(ctx context.Context, obj *types.StateChange) (*string, error) + OfferID(ctx context.Context, obj *types.StateChange) (*string, error) + SignerAccountID(ctx context.Context, obj *types.StateChange) (*string, error) + SpenderAccountID(ctx context.Context, obj *types.StateChange) (*string, error) + SponsoredAccountID(ctx context.Context, obj *types.StateChange) (*string, error) + SponsorAccountID(ctx context.Context, obj *types.StateChange) (*string, error) + SignerWeights(ctx context.Context, obj *types.StateChange) (*string, error) + Thresholds(ctx context.Context, obj *types.StateChange) (*string, error) + Flags(ctx context.Context, obj *types.StateChange) ([]string, error) + KeyValue(ctx context.Context, obj *types.StateChange) (*string, error) + Operation(ctx context.Context, obj *types.StateChange) (*types.Operation, error) + Transaction(ctx context.Context, obj *types.StateChange) (*types.Transaction, error) +} +type TransactionResolver interface { + Operations(ctx context.Context, obj *types.Transaction) ([]*types.Operation, error) + Accounts(ctx context.Context, obj *types.Transaction) ([]*types.Account, error) + StateChanges(ctx context.Context, obj *types.Transaction) ([]*types.StateChange, error) +} + +type executableSchema struct { + schema *ast.Schema + resolvers ResolverRoot + directives DirectiveRoot + complexity ComplexityRoot +} + +func (e *executableSchema) Schema() *ast.Schema { + if e.schema != nil { + return e.schema + } + return parsedSchema +} + +func (e *executableSchema) Complexity(ctx context.Context, typeName, field string, childComplexity int, rawArgs map[string]any) (int, bool) { + ec := executionContext{nil, e, 0, 0, nil} + _ = ec + switch typeName + "." + field { + + case "Account.address": + if e.complexity.Account.Address == nil { + break + } + + return e.complexity.Account.Address(childComplexity), true + + case "Account.operations": + if e.complexity.Account.Operations == nil { + break + } + + return e.complexity.Account.Operations(childComplexity), true + + case "Account.stateChanges": + if e.complexity.Account.StateChanges == nil { + break + } + + return e.complexity.Account.StateChanges(childComplexity), true + + case "Account.transactions": + if e.complexity.Account.Transactions == nil { + break + } + + return e.complexity.Account.Transactions(childComplexity), true + + case "Operation.accounts": + if e.complexity.Operation.Accounts == nil { + break + } + + return e.complexity.Operation.Accounts(childComplexity), true + + case "Operation.id": + if e.complexity.Operation.ID == nil { + break + } + + return e.complexity.Operation.ID(childComplexity), true + + case "Operation.ingestedAt": + if e.complexity.Operation.IngestedAt == nil { + break + } + + return e.complexity.Operation.IngestedAt(childComplexity), true + + case "Operation.ledgerCreatedAt": + if e.complexity.Operation.LedgerCreatedAt == nil { + break + } + + return e.complexity.Operation.LedgerCreatedAt(childComplexity), true + + case "Operation.ledgerNumber": + if e.complexity.Operation.LedgerNumber == nil { + break + } + + return e.complexity.Operation.LedgerNumber(childComplexity), true + + case "Operation.operationType": + if e.complexity.Operation.OperationType == nil { + break + } + + return e.complexity.Operation.OperationType(childComplexity), true + + case "Operation.operationXdr": + if e.complexity.Operation.OperationXDR == nil { + break + } + + return e.complexity.Operation.OperationXDR(childComplexity), true + + case "Operation.stateChanges": + if e.complexity.Operation.StateChanges == nil { + break + } + + return e.complexity.Operation.StateChanges(childComplexity), true + + case "Operation.transaction": + if e.complexity.Operation.Transaction == nil { + break + } + + return e.complexity.Operation.Transaction(childComplexity), true + + case "Query.account": + if e.complexity.Query.Account == nil { + break + } + + args, err := ec.field_Query_account_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Account(childComplexity, args["address"].(string)), true + + case "Query.operations": + if e.complexity.Query.Operations == nil { + break + } + + args, err := ec.field_Query_operations_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Operations(childComplexity, args["limit"].(*int32)), true + + case "Query.stateChanges": + if e.complexity.Query.StateChanges == nil { + break + } + + args, err := ec.field_Query_stateChanges_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.StateChanges(childComplexity, args["limit"].(*int32)), true + + case "Query.transactionByHash": + if e.complexity.Query.TransactionByHash == nil { + break + } + + args, err := ec.field_Query_transactionByHash_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.TransactionByHash(childComplexity, args["hash"].(string)), true + + case "Query.transactions": + if e.complexity.Query.Transactions == nil { + break + } + + args, err := ec.field_Query_transactions_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Transactions(childComplexity, args["limit"].(*int32)), true + + case "StateChange.accountId": + if e.complexity.StateChange.AccountID == nil { + break + } + + return e.complexity.StateChange.AccountID(childComplexity), true + + case "StateChange.amount": + if e.complexity.StateChange.Amount == nil { + break + } + + return e.complexity.StateChange.Amount(childComplexity), true + + case "StateChange.claimableBalanceId": + if e.complexity.StateChange.ClaimableBalanceID == nil { + break + } + + return e.complexity.StateChange.ClaimableBalanceID(childComplexity), true + + case "StateChange.flags": + if e.complexity.StateChange.Flags == nil { + break + } + + return e.complexity.StateChange.Flags(childComplexity), true + + case "StateChange.id": + if e.complexity.StateChange.ID == nil { + break + } + + return e.complexity.StateChange.ID(childComplexity), true + + case "StateChange.ingestedAt": + if e.complexity.StateChange.IngestedAt == nil { + break + } + + return e.complexity.StateChange.IngestedAt(childComplexity), true + + case "StateChange.keyValue": + if e.complexity.StateChange.KeyValue == nil { + break + } + + return e.complexity.StateChange.KeyValue(childComplexity), true + + case "StateChange.ledgerCreatedAt": + if e.complexity.StateChange.LedgerCreatedAt == nil { + break + } + + return e.complexity.StateChange.LedgerCreatedAt(childComplexity), true + + case "StateChange.ledgerNumber": + if e.complexity.StateChange.LedgerNumber == nil { + break + } + + return e.complexity.StateChange.LedgerNumber(childComplexity), true + + case "StateChange.liquidityPoolId": + if e.complexity.StateChange.LiquidityPoolID == nil { + break + } + + return e.complexity.StateChange.LiquidityPoolID(childComplexity), true + + case "StateChange.offerId": + if e.complexity.StateChange.OfferID == nil { + break + } + + return e.complexity.StateChange.OfferID(childComplexity), true + + case "StateChange.operation": + if e.complexity.StateChange.Operation == nil { + break + } + + return e.complexity.StateChange.Operation(childComplexity), true + + case "StateChange.signerAccountId": + if e.complexity.StateChange.SignerAccountID == nil { + break + } + + return e.complexity.StateChange.SignerAccountID(childComplexity), true + + case "StateChange.signerWeights": + if e.complexity.StateChange.SignerWeights == nil { + break + } + + return e.complexity.StateChange.SignerWeights(childComplexity), true + + case "StateChange.spenderAccountId": + if e.complexity.StateChange.SpenderAccountID == nil { + break + } + + return e.complexity.StateChange.SpenderAccountID(childComplexity), true + + case "StateChange.sponsorAccountId": + if e.complexity.StateChange.SponsorAccountID == nil { + break + } + + return e.complexity.StateChange.SponsorAccountID(childComplexity), true + + case "StateChange.sponsoredAccountId": + if e.complexity.StateChange.SponsoredAccountID == nil { + break + } + + return e.complexity.StateChange.SponsoredAccountID(childComplexity), true + + case "StateChange.stateChangeCategory": + if e.complexity.StateChange.StateChangeCategory == nil { + break + } + + return e.complexity.StateChange.StateChangeCategory(childComplexity), true + + case "StateChange.stateChangeReason": + if e.complexity.StateChange.StateChangeReason == nil { + break + } + + return e.complexity.StateChange.StateChangeReason(childComplexity), true + + case "StateChange.thresholds": + if e.complexity.StateChange.Thresholds == nil { + break + } + + return e.complexity.StateChange.Thresholds(childComplexity), true + + case "StateChange.tokenId": + if e.complexity.StateChange.TokenID == nil { + break + } + + return e.complexity.StateChange.TokenID(childComplexity), true + + case "StateChange.transaction": + if e.complexity.StateChange.Transaction == nil { + break + } + + return e.complexity.StateChange.Transaction(childComplexity), true + + case "Transaction.accounts": + if e.complexity.Transaction.Accounts == nil { + break + } + + return e.complexity.Transaction.Accounts(childComplexity), true + + case "Transaction.envelopeXdr": + if e.complexity.Transaction.EnvelopeXDR == nil { + break + } + + return e.complexity.Transaction.EnvelopeXDR(childComplexity), true + + case "Transaction.hash": + if e.complexity.Transaction.Hash == nil { + break + } + + return e.complexity.Transaction.Hash(childComplexity), true + + case "Transaction.ingestedAt": + if e.complexity.Transaction.IngestedAt == nil { + break + } + + return e.complexity.Transaction.IngestedAt(childComplexity), true + + case "Transaction.ledgerCreatedAt": + if e.complexity.Transaction.LedgerCreatedAt == nil { + break + } + + return e.complexity.Transaction.LedgerCreatedAt(childComplexity), true + + case "Transaction.ledgerNumber": + if e.complexity.Transaction.LedgerNumber == nil { + break + } + + return e.complexity.Transaction.LedgerNumber(childComplexity), true + + case "Transaction.metaXdr": + if e.complexity.Transaction.MetaXDR == nil { + break + } + + return e.complexity.Transaction.MetaXDR(childComplexity), true + + case "Transaction.operations": + if e.complexity.Transaction.Operations == nil { + break + } + + return e.complexity.Transaction.Operations(childComplexity), true + + case "Transaction.resultXdr": + if e.complexity.Transaction.ResultXDR == nil { + break + } + + return e.complexity.Transaction.ResultXDR(childComplexity), true + + case "Transaction.stateChanges": + if e.complexity.Transaction.StateChanges == nil { + break + } + + return e.complexity.Transaction.StateChanges(childComplexity), true + + } + return 0, false +} + +func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { + opCtx := graphql.GetOperationContext(ctx) + ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)} + inputUnmarshalMap := graphql.BuildUnmarshalerMap() + first := true + + switch opCtx.Operation.Operation { + case ast.Query: + return func(ctx context.Context) *graphql.Response { + var response graphql.Response + var data graphql.Marshaler + if first { + first = false + ctx = graphql.WithUnmarshalerMap(ctx, inputUnmarshalMap) + data = ec._Query(ctx, opCtx.Operation.SelectionSet) + } else { + if atomic.LoadInt32(&ec.pendingDeferred) > 0 { + result := <-ec.deferredResults + atomic.AddInt32(&ec.pendingDeferred, -1) + data = result.Result + response.Path = result.Path + response.Label = result.Label + response.Errors = result.Errors + } else { + return nil + } + } + var buf bytes.Buffer + data.MarshalGQL(&buf) + response.Data = buf.Bytes() + if atomic.LoadInt32(&ec.deferred) > 0 { + hasNext := atomic.LoadInt32(&ec.pendingDeferred) > 0 + response.HasNext = &hasNext + } + + return &response + } + + default: + return graphql.OneShot(graphql.ErrorResponse(ctx, "unsupported GraphQL operation")) + } +} + +type executionContext struct { + *graphql.OperationContext + *executableSchema + deferred int32 + pendingDeferred int32 + deferredResults chan graphql.DeferredResult +} + +func (ec *executionContext) processDeferredGroup(dg graphql.DeferredGroup) { + atomic.AddInt32(&ec.pendingDeferred, 1) + go func() { + ctx := graphql.WithFreshResponseContext(dg.Context) + dg.FieldSet.Dispatch(ctx) + ds := graphql.DeferredResult{ + Path: dg.Path, + Label: dg.Label, + Result: dg.FieldSet, + Errors: graphql.GetErrors(ctx), + } + // null fields should bubble up + if dg.FieldSet.Invalids > 0 { + ds.Result = graphql.Null + } + ec.deferredResults <- ds + }() +} + +func (ec *executionContext) introspectSchema() (*introspection.Schema, error) { + if ec.DisableIntrospection { + return nil, errors.New("introspection disabled") + } + return introspection.WrapSchema(ec.Schema()), nil +} + +func (ec *executionContext) introspectType(name string) (*introspection.Type, error) { + if ec.DisableIntrospection { + return nil, errors.New("introspection disabled") + } + return introspection.WrapTypeFromDef(ec.Schema(), ec.Schema().Types[name]), nil +} + +var sources = []*ast.Source{ + {Name: "../schema/account.graphqls", Input: `# GraphQL Account type - represents a blockchain account +# In GraphQL, types define the shape of data that can be queried +type Account{ + address: String! + + # GraphQL Relationships - these fields use resolvers for data fetching + # Each relationship resolver will be called when the field is requested + + # All transactions associated with this account + # Uses dataloader for efficient batching to prevent N+1 queries + transactions: [Transaction!]! + + # All operations associated with this account + # Uses dataloader for efficient batching to prevent N+1 queries + operations: [Operation!]! + + # All state changes associated with this account + # Uses resolver to fetch related state changes + stateChanges: [StateChange!]! +} +`, BuiltIn: false}, + {Name: "../schema/directives.graphqls", Input: `# GraphQL Directive - provides metadata to control gqlgen code generation +# Directives are like annotations that modify how GraphQL processes fields + +# @goField directive - controls how gqlgen generates Go code for fields +# This is a gqlgen-specific directive for customizing field resolution +directive @goField( + # forceResolver: Boolean - forces gqlgen to generate a resolver function + # even if the Go struct has a matching field name + # Useful when you need custom logic for field resolution + forceResolver: Boolean + + # name: String - specifies the Go struct field name to map to + # Allows mapping GraphQL field names to different Go field names + name: String + + # omittable: Boolean - indicates if the field can be omitted from queries + # Used for optional fields in input types + omittable: Boolean + + # type: String - specifies the Go type for the field + # Overrides gqlgen's default type inference + type: String +) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION +`, BuiltIn: false}, + {Name: "../schema/enums.graphqls", Input: `# GraphQL Enums - provide type safety and restrict values to predefined options +# These enums match Go constants and provide better GraphQL introspection + +# OperationType enum - defines all possible operation types +# GraphQL enums are validated at query time, preventing invalid values +enum OperationType { + CREATE_ACCOUNT + PAYMENT + PATH_PAYMENT_STRICT_RECEIVE + PATH_PAYMENT_STRICT_SEND + MANAGE_SELL_OFFER + CREATE_PASSIVE_SELL_OFFER + MANAGE_BUY_OFFER + SET_OPTIONS + CHANGE_TRUST + ALLOW_TRUST + ACCOUNT_MERGE + INFLATION + MANAGE_DATA + BUMP_SEQUENCE + CREATE_CLAIMABLE_BALANCE + CLAIM_CLAIMABLE_BALANCE + BEGIN_SPONSORING_FUTURE_RESERVES + END_SPONSORING_FUTURE_RESERVES + REVOKE_SPONSORSHIP + CLAWBACK + CLAWBACK_CLAIMABLE_BALANCE + SET_TRUST_LINE_FLAGS + LIQUIDITY_POOL_DEPOSIT + LIQUIDITY_POOL_WITHDRAW + INVOKE_HOST_FUNCTION + EXTEND_FOOTPRINT_TTL + RESTORE_FOOTPRINT +} + +# StateChangeCategory enum - categorizes the type of state change +# Used in GraphQL queries to filter state changes by category +enum StateChangeCategory { + DEBIT + CREDIT + MINT + BURN + SIGNER + SIGNATURE_THRESHOLD + METADATA + FLAGS + LIABILITY + TRUSTLINE_FLAGS + ALLOWANCE + SPONSORSHIP + CONTRACT + AUTHORIZATION + UNSUPPORTED +} + +# StateChangeReason enum - provides specific reason for the state change +# Used in GraphQL queries to understand why a state change occurred +enum StateChangeReason { + ADD + REMOVE + UPDATE + LOW + MEDIUM + HIGH + HOME_DOMAIN + SET + CLEAR + SELL + BUY + DATA_ENTRY + CONSUME + DEPLOY + INVOKE +} +`, BuiltIn: false}, + {Name: "../schema/operation.graphqls", Input: `# GraphQL Operation type - represents a blockchain operation +# Operations are the individual actions within a transaction +type Operation{ + id: Int64! + operationType: OperationType! + operationXdr: String! + ledgerNumber: UInt32! + ledgerCreatedAt: Time! + ingestedAt: Time! + + # GraphQL Relationships - these fields use resolvers + # Parent transaction + transaction: Transaction! @goField(forceResolver: true) + + # Related accounts - uses resolver with dataloader for efficiency + accounts: [Account!]! @goField(forceResolver: true) + + # Related state changes - uses resolver to fetch associated changes + stateChanges: [StateChange!]! @goField(forceResolver: true) +} +`, BuiltIn: false}, + {Name: "../schema/queries.graphqls", Input: `# GraphQL Query root type - defines all available queries in the API +# In GraphQL, the Query type is the entry point for read operations +type Query { + transactionByHash(hash: String!): Transaction + transactions(limit: Int): [Transaction!]! + account(address: String!): Account + operations(limit: Int): [Operation!]! + stateChanges(limit: Int): [StateChange!]! +} +`, BuiltIn: false}, + {Name: "../schema/scalars.graphqls", Input: `# GraphQL Custom Scalars - extend GraphQL's built-in scalar types +# Custom scalars provide type safety for specific data formats +# gqlgen requires custom marshal/unmarshal functions for these types + +# Time scalar - represents timestamps +# Handles conversion between Go time.Time and GraphQL string/int representations +# Used for createdAt, ingestedAt, and other timestamp fields +scalar Time + +# UInt32 scalar - represents unsigned 32-bit integers +# GraphQL doesn't have native uint32, so we define a custom scalar +# Used for ledger numbers and other positive integer values +scalar UInt32 + +# Int64 scalar - represents 64-bit integers +# GraphQL's Int type is 32-bit, so we need custom scalar for larger values +# Used for database IDs and other large integer values +scalar Int64 +`, BuiltIn: false}, + {Name: "../schema/statechange.graphqls", Input: `# GraphQL StateChange type - represents changes to blockchain state +# This type has many nullable fields to handle various state change scenarios +# TODO: Break state change type into interface design and add sub types that implement the interface +type StateChange{ + id: String! + accountId: String! + stateChangeCategory: StateChangeCategory! + stateChangeReason: StateChangeReason + ingestedAt: Time! + ledgerCreatedAt: Time! + ledgerNumber: UInt32! + + # GraphQL Nullable fields - these map to sql.NullString in Go + # GraphQL handles nullable fields gracefully - they return null if not set + tokenId: String + amount: String + claimableBalanceId: String + liquidityPoolId: String + offerId: String + signerAccountId: String + spenderAccountId: String + sponsoredAccountId: String + sponsorAccountId: String + + # GraphQL fields for JSONB data - require custom resolvers + # These fields need special handling to convert between Go types and GraphQL + signerWeights: String + thresholds: String + flags: [String!] + keyValue: String + + # GraphQL Relationships - these fields use resolvers + # Related operation + operation: Operation! @goField(forceResolver: true) + + # Related transaction + transaction: Transaction! @goField(forceResolver: true) +} +`, BuiltIn: false}, + {Name: "../schema/transaction.graphqls", Input: `# GraphQL Transaction type - represents a blockchain transaction +# gqlgen generates Go structs from this schema definition +type Transaction{ + hash: String! + envelopeXdr: String! + resultXdr: String! + metaXdr: String! + ledgerNumber: UInt32! + ledgerCreatedAt: Time! + ingestedAt: Time! + + # GraphQL Relationships - these fields require resolvers + # @goField(forceResolver: true) tells gqlgen to always generate a resolver + # even if the Go struct has a matching field + operations: [Operation!]! @goField(forceResolver: true) + + # Related accounts - uses resolver with dataloader for efficiency + accounts: [Account!]! @goField(forceResolver: true) + + # Related state changes - uses resolver to fetch associated changes + stateChanges: [StateChange!]! @goField(forceResolver: true) +} +`, BuiltIn: false}, +} +var parsedSchema = gqlparser.MustLoadSchema(sources...) + +// endregion ************************** generated!.gotpl ************************** + +// region ***************************** args.gotpl ***************************** + +func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query___type_argsName(ctx, rawArgs) + if err != nil { + return nil, err + } + args["name"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query___type_argsName( + ctx context.Context, + rawArgs map[string]any, +) (string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + if tmp, ok := rawArgs["name"]; ok { + return ec.unmarshalNString2string(ctx, tmp) + } + + var zeroVal string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_account_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_account_argsAddress(ctx, rawArgs) + if err != nil { + return nil, err + } + args["address"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_account_argsAddress( + ctx context.Context, + rawArgs map[string]any, +) (string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("address")) + if tmp, ok := rawArgs["address"]; ok { + return ec.unmarshalNString2string(ctx, tmp) + } + + var zeroVal string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_operations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_operations_argsLimit(ctx, rawArgs) + if err != nil { + return nil, err + } + args["limit"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_operations_argsLimit( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) + if tmp, ok := rawArgs["limit"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Query_stateChanges_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_stateChanges_argsLimit(ctx, rawArgs) + if err != nil { + return nil, err + } + args["limit"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_stateChanges_argsLimit( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) + if tmp, ok := rawArgs["limit"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Query_transactionByHash_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_transactionByHash_argsHash(ctx, rawArgs) + if err != nil { + return nil, err + } + args["hash"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_transactionByHash_argsHash( + ctx context.Context, + rawArgs map[string]any, +) (string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("hash")) + if tmp, ok := rawArgs["hash"]; ok { + return ec.unmarshalNString2string(ctx, tmp) + } + + var zeroVal string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_transactions_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_transactions_argsLimit(ctx, rawArgs) + if err != nil { + return nil, err + } + args["limit"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_transactions_argsLimit( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) + if tmp, ok := rawArgs["limit"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field___Directive_args_argsIncludeDeprecated(ctx, rawArgs) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} +func (ec *executionContext) field___Directive_args_argsIncludeDeprecated( + ctx context.Context, + rawArgs map[string]any, +) (*bool, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) + if tmp, ok := rawArgs["includeDeprecated"]; ok { + return ec.unmarshalOBoolean2ᚖbool(ctx, tmp) + } + + var zeroVal *bool + return zeroVal, nil +} + +func (ec *executionContext) field___Field_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field___Field_args_argsIncludeDeprecated(ctx, rawArgs) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} +func (ec *executionContext) field___Field_args_argsIncludeDeprecated( + ctx context.Context, + rawArgs map[string]any, +) (*bool, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) + if tmp, ok := rawArgs["includeDeprecated"]; ok { + return ec.unmarshalOBoolean2ᚖbool(ctx, tmp) + } + + var zeroVal *bool + return zeroVal, nil +} + +func (ec *executionContext) field___Type_enumValues_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field___Type_enumValues_argsIncludeDeprecated(ctx, rawArgs) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} +func (ec *executionContext) field___Type_enumValues_argsIncludeDeprecated( + ctx context.Context, + rawArgs map[string]any, +) (bool, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) + if tmp, ok := rawArgs["includeDeprecated"]; ok { + return ec.unmarshalOBoolean2bool(ctx, tmp) + } + + var zeroVal bool + return zeroVal, nil +} + +func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field___Type_fields_argsIncludeDeprecated(ctx, rawArgs) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} +func (ec *executionContext) field___Type_fields_argsIncludeDeprecated( + ctx context.Context, + rawArgs map[string]any, +) (bool, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) + if tmp, ok := rawArgs["includeDeprecated"]; ok { + return ec.unmarshalOBoolean2bool(ctx, tmp) + } + + var zeroVal bool + return zeroVal, nil +} + +// endregion ***************************** args.gotpl ***************************** + +// region ************************** directives.gotpl ************************** + +// endregion ************************** directives.gotpl ************************** + +// region **************************** field.gotpl ***************************** + +func (ec *executionContext) _Account_address(ctx context.Context, field graphql.CollectedField, obj *types.Account) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Account_address(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Account().Address(rctx, obj) + }) + 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_Account_address(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Account", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _Account_transactions(ctx context.Context, field graphql.CollectedField, obj *types.Account) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Account_transactions(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Account().Transactions(rctx, obj) + }) + 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.([]*types.Transaction) + fc.Result = res + return ec.marshalNTransaction2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransactionᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Account_transactions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Account", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) + case "envelopeXdr": + return ec.fieldContext_Transaction_envelopeXdr(ctx, field) + case "resultXdr": + return ec.fieldContext_Transaction_resultXdr(ctx, field) + case "metaXdr": + return ec.fieldContext_Transaction_metaXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Transaction_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Transaction_ingestedAt(ctx, field) + case "operations": + return ec.fieldContext_Transaction_operations(ctx, field) + case "accounts": + return ec.fieldContext_Transaction_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Transaction_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Account_operations(ctx context.Context, field graphql.CollectedField, obj *types.Account) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Account_operations(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Account().Operations(rctx, obj) + }) + 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.([]*types.Operation) + fc.Result = res + return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Account_operations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Account", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Operation_id(ctx, field) + case "operationType": + return ec.fieldContext_Operation_operationType(ctx, field) + case "operationXdr": + return ec.fieldContext_Operation_operationXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Operation_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Operation_ingestedAt(ctx, field) + case "transaction": + return ec.fieldContext_Operation_transaction(ctx, field) + case "accounts": + return ec.fieldContext_Operation_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Operation_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Account_stateChanges(ctx context.Context, field graphql.CollectedField, obj *types.Account) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Account_stateChanges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Account().StateChanges(rctx, obj) + }) + 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.([]*types.StateChange) + fc.Result = res + return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Account_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Account", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_StateChange_id(ctx, field) + case "accountId": + return ec.fieldContext_StateChange_accountId(ctx, field) + case "stateChangeCategory": + return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) + case "stateChangeReason": + return ec.fieldContext_StateChange_stateChangeReason(ctx, field) + case "ingestedAt": + return ec.fieldContext_StateChange_ingestedAt(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) + case "ledgerNumber": + return ec.fieldContext_StateChange_ledgerNumber(ctx, field) + case "tokenId": + return ec.fieldContext_StateChange_tokenId(ctx, field) + case "amount": + return ec.fieldContext_StateChange_amount(ctx, field) + case "claimableBalanceId": + return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) + case "liquidityPoolId": + return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) + case "offerId": + return ec.fieldContext_StateChange_offerId(ctx, field) + case "signerAccountId": + return ec.fieldContext_StateChange_signerAccountId(ctx, field) + case "spenderAccountId": + return ec.fieldContext_StateChange_spenderAccountId(ctx, field) + case "sponsoredAccountId": + return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) + case "sponsorAccountId": + return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) + case "signerWeights": + return ec.fieldContext_StateChange_signerWeights(ctx, field) + case "thresholds": + return ec.fieldContext_StateChange_thresholds(ctx, field) + case "flags": + return ec.fieldContext_StateChange_flags(ctx, field) + case "keyValue": + return ec.fieldContext_StateChange_keyValue(ctx, field) + case "operation": + return ec.fieldContext_StateChange_operation(ctx, field) + case "transaction": + return ec.fieldContext_StateChange_transaction(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_id(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_id(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, 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.(int64) + fc.Result = res + return ec.marshalNInt642int64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int64 does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_operationType(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_operationType(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.OperationType, 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.(types.OperationType) + fc.Result = res + return ec.marshalNOperationType2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_operationType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type OperationType does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_operationXdr(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_operationXdr(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.OperationXDR, 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_Operation_operationXdr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: false, + 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) _Operation_ledgerNumber(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_ledgerNumber(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.LedgerNumber, 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.(uint32) + fc.Result = res + return ec.marshalNUInt322uint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_ledgerNumber(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UInt32 does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_ledgerCreatedAt(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_ledgerCreatedAt(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.LedgerCreatedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_ledgerCreatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_ingestedAt(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_ingestedAt(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IngestedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_ingestedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_transaction(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_transaction(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Operation().Transaction(rctx, obj) + }) + 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.(*types.Transaction) + fc.Result = res + return ec.marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_transaction(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) + case "envelopeXdr": + return ec.fieldContext_Transaction_envelopeXdr(ctx, field) + case "resultXdr": + return ec.fieldContext_Transaction_resultXdr(ctx, field) + case "metaXdr": + return ec.fieldContext_Transaction_metaXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Transaction_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Transaction_ingestedAt(ctx, field) + case "operations": + return ec.fieldContext_Transaction_operations(ctx, field) + case "accounts": + return ec.fieldContext_Transaction_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Transaction_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_accounts(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_accounts(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Operation().Accounts(rctx, obj) + }) + 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.([]*types.Account) + fc.Result = res + return ec.marshalNAccount2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccountᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_accounts(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "address": + return ec.fieldContext_Account_address(ctx, field) + case "transactions": + return ec.fieldContext_Account_transactions(ctx, field) + case "operations": + return ec.fieldContext_Account_operations(ctx, field) + case "stateChanges": + return ec.fieldContext_Account_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Account", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Operation_stateChanges(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Operation_stateChanges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Operation().StateChanges(rctx, obj) + }) + 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.([]*types.StateChange) + fc.Result = res + return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Operation_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Operation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_StateChange_id(ctx, field) + case "accountId": + return ec.fieldContext_StateChange_accountId(ctx, field) + case "stateChangeCategory": + return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) + case "stateChangeReason": + return ec.fieldContext_StateChange_stateChangeReason(ctx, field) + case "ingestedAt": + return ec.fieldContext_StateChange_ingestedAt(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) + case "ledgerNumber": + return ec.fieldContext_StateChange_ledgerNumber(ctx, field) + case "tokenId": + return ec.fieldContext_StateChange_tokenId(ctx, field) + case "amount": + return ec.fieldContext_StateChange_amount(ctx, field) + case "claimableBalanceId": + return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) + case "liquidityPoolId": + return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) + case "offerId": + return ec.fieldContext_StateChange_offerId(ctx, field) + case "signerAccountId": + return ec.fieldContext_StateChange_signerAccountId(ctx, field) + case "spenderAccountId": + return ec.fieldContext_StateChange_spenderAccountId(ctx, field) + case "sponsoredAccountId": + return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) + case "sponsorAccountId": + return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) + case "signerWeights": + return ec.fieldContext_StateChange_signerWeights(ctx, field) + case "thresholds": + return ec.fieldContext_StateChange_thresholds(ctx, field) + case "flags": + return ec.fieldContext_StateChange_flags(ctx, field) + case "keyValue": + return ec.fieldContext_StateChange_keyValue(ctx, field) + case "operation": + return ec.fieldContext_StateChange_operation(ctx, field) + case "transaction": + return ec.fieldContext_StateChange_transaction(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_transactionByHash(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_transactionByHash(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().TransactionByHash(rctx, fc.Args["hash"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*types.Transaction) + fc.Result = res + return ec.marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_transactionByHash(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) + case "envelopeXdr": + return ec.fieldContext_Transaction_envelopeXdr(ctx, field) + case "resultXdr": + return ec.fieldContext_Transaction_resultXdr(ctx, field) + case "metaXdr": + return ec.fieldContext_Transaction_metaXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Transaction_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Transaction_ingestedAt(ctx, field) + case "operations": + return ec.fieldContext_Transaction_operations(ctx, field) + case "accounts": + return ec.fieldContext_Transaction_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Transaction_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_transactionByHash_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_transactions(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_transactions(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().Transactions(rctx, fc.Args["limit"].(*int32)) + }) + 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.([]*types.Transaction) + fc.Result = res + return ec.marshalNTransaction2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransactionᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_transactions(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) + case "envelopeXdr": + return ec.fieldContext_Transaction_envelopeXdr(ctx, field) + case "resultXdr": + return ec.fieldContext_Transaction_resultXdr(ctx, field) + case "metaXdr": + return ec.fieldContext_Transaction_metaXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Transaction_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Transaction_ingestedAt(ctx, field) + case "operations": + return ec.fieldContext_Transaction_operations(ctx, field) + case "accounts": + return ec.fieldContext_Transaction_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Transaction_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_transactions_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_account(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_account(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().Account(rctx, fc.Args["address"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*types.Account) + fc.Result = res + return ec.marshalOAccount2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccount(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_account(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "address": + return ec.fieldContext_Account_address(ctx, field) + case "transactions": + return ec.fieldContext_Account_transactions(ctx, field) + case "operations": + return ec.fieldContext_Account_operations(ctx, field) + case "stateChanges": + return ec.fieldContext_Account_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Account", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_account_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_operations(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_operations(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().Operations(rctx, fc.Args["limit"].(*int32)) + }) + 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.([]*types.Operation) + fc.Result = res + return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_operations(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Operation_id(ctx, field) + case "operationType": + return ec.fieldContext_Operation_operationType(ctx, field) + case "operationXdr": + return ec.fieldContext_Operation_operationXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Operation_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Operation_ingestedAt(ctx, field) + case "transaction": + return ec.fieldContext_Operation_transaction(ctx, field) + case "accounts": + return ec.fieldContext_Operation_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Operation_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_operations_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_stateChanges(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_stateChanges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().StateChanges(rctx, fc.Args["limit"].(*int32)) + }) + 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.([]*types.StateChange) + fc.Result = res + return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_stateChanges(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_StateChange_id(ctx, field) + case "accountId": + return ec.fieldContext_StateChange_accountId(ctx, field) + case "stateChangeCategory": + return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) + case "stateChangeReason": + return ec.fieldContext_StateChange_stateChangeReason(ctx, field) + case "ingestedAt": + return ec.fieldContext_StateChange_ingestedAt(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) + case "ledgerNumber": + return ec.fieldContext_StateChange_ledgerNumber(ctx, field) + case "tokenId": + return ec.fieldContext_StateChange_tokenId(ctx, field) + case "amount": + return ec.fieldContext_StateChange_amount(ctx, field) + case "claimableBalanceId": + return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) + case "liquidityPoolId": + return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) + case "offerId": + return ec.fieldContext_StateChange_offerId(ctx, field) + case "signerAccountId": + return ec.fieldContext_StateChange_signerAccountId(ctx, field) + case "spenderAccountId": + return ec.fieldContext_StateChange_spenderAccountId(ctx, field) + case "sponsoredAccountId": + return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) + case "sponsorAccountId": + return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) + case "signerWeights": + return ec.fieldContext_StateChange_signerWeights(ctx, field) + case "thresholds": + return ec.fieldContext_StateChange_thresholds(ctx, field) + case "flags": + return ec.fieldContext_StateChange_flags(ctx, field) + case "keyValue": + return ec.fieldContext_StateChange_keyValue(ctx, field) + case "operation": + return ec.fieldContext_StateChange_operation(ctx, field) + case "transaction": + return ec.fieldContext_StateChange_transaction(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_stateChanges_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query___type(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.introspectType(fc.Args["name"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*introspection.Type) + fc.Result = res + return ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query___type(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query___type_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query___schema(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query___schema(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.introspectSchema() + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*introspection.Schema) + fc.Result = res + return ec.marshalO__Schema2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐSchema(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "description": + return ec.fieldContext___Schema_description(ctx, field) + case "types": + return ec.fieldContext___Schema_types(ctx, field) + case "queryType": + return ec.fieldContext___Schema_queryType(ctx, field) + case "mutationType": + return ec.fieldContext___Schema_mutationType(ctx, field) + case "subscriptionType": + return ec.fieldContext___Schema_subscriptionType(ctx, field) + case "directives": + return ec.fieldContext___Schema_directives(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Schema", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChange_id(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_id(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, 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_StateChange_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: false, + 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) _StateChange_accountId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_accountId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.AccountID, 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_StateChange_accountId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: false, + 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) _StateChange_stateChangeCategory(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_stateChangeCategory(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.StateChangeCategory, 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.(types.StateChangeCategory) + fc.Result = res + return ec.marshalNStateChangeCategory2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeCategory(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_stateChangeCategory(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type StateChangeCategory does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChange_stateChangeReason(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_stateChangeReason(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.StateChangeReason, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*types.StateChangeReason) + fc.Result = res + return ec.marshalOStateChangeReason2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeReason(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_stateChangeReason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type StateChangeReason does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChange_ingestedAt(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_ingestedAt(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IngestedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_ingestedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChange_ledgerCreatedAt(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_ledgerCreatedAt(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.LedgerCreatedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_ledgerCreatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChange_ledgerNumber(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_ledgerNumber(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.LedgerNumber, 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.(uint32) + fc.Result = res + return ec.marshalNUInt322uint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_ledgerNumber(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UInt32 does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChange_tokenId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_tokenId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().TokenID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_tokenId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_amount(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_amount(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().Amount(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_amount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_claimableBalanceId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_claimableBalanceId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().ClaimableBalanceID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_claimableBalanceId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_liquidityPoolId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_liquidityPoolId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().LiquidityPoolID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_liquidityPoolId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_offerId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_offerId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().OfferID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_offerId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_signerAccountId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_signerAccountId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().SignerAccountID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_signerAccountId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_spenderAccountId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_spenderAccountId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().SpenderAccountID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_spenderAccountId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_sponsoredAccountId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_sponsoredAccountId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().SponsoredAccountID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_sponsoredAccountId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_sponsorAccountId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_sponsorAccountId(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().SponsorAccountID(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_sponsorAccountId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_signerWeights(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_signerWeights(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().SignerWeights(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_signerWeights(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_thresholds(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_thresholds(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().Thresholds(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_thresholds(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_flags(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_flags(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().Flags(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]string) + fc.Result = res + return ec.marshalOString2ᚕstringᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_flags(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_keyValue(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_keyValue(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().KeyValue(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_keyValue(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + 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) _StateChange_operation(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_operation(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().Operation(rctx, obj) + }) + 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.(*types.Operation) + fc.Result = res + return ec.marshalNOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_operation(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Operation_id(ctx, field) + case "operationType": + return ec.fieldContext_Operation_operationType(ctx, field) + case "operationXdr": + return ec.fieldContext_Operation_operationXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Operation_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Operation_ingestedAt(ctx, field) + case "transaction": + return ec.fieldContext_Operation_transaction(ctx, field) + case "accounts": + return ec.fieldContext_Operation_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Operation_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChange_transaction(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChange_transaction(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.StateChange().Transaction(rctx, obj) + }) + 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.(*types.Transaction) + fc.Result = res + return ec.marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChange_transaction(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChange", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) + case "envelopeXdr": + return ec.fieldContext_Transaction_envelopeXdr(ctx, field) + case "resultXdr": + return ec.fieldContext_Transaction_resultXdr(ctx, field) + case "metaXdr": + return ec.fieldContext_Transaction_metaXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Transaction_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Transaction_ingestedAt(ctx, field) + case "operations": + return ec.fieldContext_Transaction_operations(ctx, field) + case "accounts": + return ec.fieldContext_Transaction_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Transaction_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_hash(ctx context.Context, field graphql.CollectedField, obj *types.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) (any, 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(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + 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_envelopeXdr(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_envelopeXdr(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.EnvelopeXDR, 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_envelopeXdr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + 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_resultXdr(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_resultXdr(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ResultXDR, 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_resultXdr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + 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_metaXdr(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_metaXdr(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.MetaXDR, 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_metaXdr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + 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_ledgerNumber(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_ledgerNumber(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.LedgerNumber, 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.(uint32) + fc.Result = res + return ec.marshalNUInt322uint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_ledgerNumber(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UInt32 does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_ledgerCreatedAt(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_ledgerCreatedAt(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.LedgerCreatedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_ledgerCreatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_ingestedAt(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_ingestedAt(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IngestedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_ingestedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_operations(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_operations(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Transaction().Operations(rctx, obj) + }) + 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.([]*types.Operation) + fc.Result = res + return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_operations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Operation_id(ctx, field) + case "operationType": + return ec.fieldContext_Operation_operationType(ctx, field) + case "operationXdr": + return ec.fieldContext_Operation_operationXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Operation_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Operation_ingestedAt(ctx, field) + case "transaction": + return ec.fieldContext_Operation_transaction(ctx, field) + case "accounts": + return ec.fieldContext_Operation_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Operation_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_accounts(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_accounts(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Transaction().Accounts(rctx, obj) + }) + 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.([]*types.Account) + fc.Result = res + return ec.marshalNAccount2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccountᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_accounts(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "address": + return ec.fieldContext_Account_address(ctx, field) + case "transactions": + return ec.fieldContext_Account_transactions(ctx, field) + case "operations": + return ec.fieldContext_Account_operations(ctx, field) + case "stateChanges": + return ec.fieldContext_Account_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Account", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_stateChanges(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_stateChanges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Transaction().StateChanges(rctx, obj) + }) + 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.([]*types.StateChange) + fc.Result = res + return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_StateChange_id(ctx, field) + case "accountId": + return ec.fieldContext_StateChange_accountId(ctx, field) + case "stateChangeCategory": + return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) + case "stateChangeReason": + return ec.fieldContext_StateChange_stateChangeReason(ctx, field) + case "ingestedAt": + return ec.fieldContext_StateChange_ingestedAt(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) + case "ledgerNumber": + return ec.fieldContext_StateChange_ledgerNumber(ctx, field) + case "tokenId": + return ec.fieldContext_StateChange_tokenId(ctx, field) + case "amount": + return ec.fieldContext_StateChange_amount(ctx, field) + case "claimableBalanceId": + return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) + case "liquidityPoolId": + return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) + case "offerId": + return ec.fieldContext_StateChange_offerId(ctx, field) + case "signerAccountId": + return ec.fieldContext_StateChange_signerAccountId(ctx, field) + case "spenderAccountId": + return ec.fieldContext_StateChange_spenderAccountId(ctx, field) + case "sponsoredAccountId": + return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) + case "sponsorAccountId": + return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) + case "signerWeights": + return ec.fieldContext_StateChange_signerWeights(ctx, field) + case "thresholds": + return ec.fieldContext_StateChange_thresholds(ctx, field) + case "flags": + return ec.fieldContext_StateChange_flags(ctx, field) + case "keyValue": + return ec.fieldContext_StateChange_keyValue(ctx, field) + case "operation": + return ec.fieldContext_StateChange_operation(ctx, field) + case "transaction": + return ec.fieldContext_StateChange_transaction(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Directive_name(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Name, 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___Directive_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + 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) ___Directive_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Directive_description(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Description(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Directive_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + 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) ___Directive_isRepeatable(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Directive_isRepeatable(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsRepeatable, 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Directive_isRepeatable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_locations(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Directive_locations(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Locations, 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.marshalN__DirectiveLocation2ᚕstringᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Directive_locations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type __DirectiveLocation does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_args(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Directive_args(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Args, 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.([]introspection.InputValue) + fc.Result = res + return ec.marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Directive_args(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___InputValue_name(ctx, field) + case "description": + return ec.fieldContext___InputValue_description(ctx, field) + case "type": + return ec.fieldContext___InputValue_type(ctx, field) + case "defaultValue": + return ec.fieldContext___InputValue_defaultValue(ctx, field) + case "isDeprecated": + return ec.fieldContext___InputValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___InputValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __InputValue", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Directive_args_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___EnumValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___EnumValue_name(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Name, 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___EnumValue_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + Field: field, + IsMethod: false, + 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) ___EnumValue_description(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___EnumValue_description(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Description(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___EnumValue_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + 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) ___EnumValue_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___EnumValue_isDeprecated(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsDeprecated(), 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___EnumValue_isDeprecated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___EnumValue_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___EnumValue_deprecationReason(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.DeprecationReason(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___EnumValue_deprecationReason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + 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) ___Field_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Field_name(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Name, 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___Field_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: false, + 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) ___Field_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Field_description(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Description(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Field_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + 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) ___Field_args(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Field_args(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Args, 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.([]introspection.InputValue) + fc.Result = res + return ec.marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Field_args(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___InputValue_name(ctx, field) + case "description": + return ec.fieldContext___InputValue_description(ctx, field) + case "type": + return ec.fieldContext___InputValue_type(ctx, field) + case "defaultValue": + return ec.fieldContext___InputValue_defaultValue(ctx, field) + case "isDeprecated": + return ec.fieldContext___InputValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___InputValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __InputValue", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Field_args_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___Field_type(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Field_type(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Type, 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.(*introspection.Type) + fc.Result = res + return ec.marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Field_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Field_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Field_isDeprecated(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsDeprecated(), 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Field_isDeprecated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Field_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Field_deprecationReason(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.DeprecationReason(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Field_deprecationReason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + 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) ___InputValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___InputValue_name(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Name, 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___InputValue_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: false, + 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) ___InputValue_description(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___InputValue_description(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Description(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___InputValue_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + 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) ___InputValue_type(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___InputValue_type(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Type, 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.(*introspection.Type) + fc.Result = res + return ec.marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___InputValue_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_defaultValue(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___InputValue_defaultValue(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.DefaultValue, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___InputValue_defaultValue(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: false, + 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) ___InputValue_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___InputValue_isDeprecated(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsDeprecated(), 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___InputValue_isDeprecated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___InputValue_deprecationReason(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.DeprecationReason(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___InputValue_deprecationReason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + 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) ___Schema_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Schema_description(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Description(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Schema_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + 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) ___Schema_types(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Schema_types(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Types(), 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.([]introspection.Type) + fc.Result = res + return ec.marshalN__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Schema_types(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_queryType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Schema_queryType(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.QueryType(), 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.(*introspection.Type) + fc.Result = res + return ec.marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Schema_queryType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_mutationType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Schema_mutationType(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.MutationType(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*introspection.Type) + fc.Result = res + return ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Schema_mutationType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_subscriptionType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Schema_subscriptionType(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.SubscriptionType(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*introspection.Type) + fc.Result = res + return ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Schema_subscriptionType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_directives(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Schema_directives(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Directives(), 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.([]introspection.Directive) + fc.Result = res + return ec.marshalN__Directive2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirectiveᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Schema_directives(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___Directive_name(ctx, field) + case "description": + return ec.fieldContext___Directive_description(ctx, field) + case "isRepeatable": + return ec.fieldContext___Directive_isRepeatable(ctx, field) + case "locations": + return ec.fieldContext___Directive_locations(ctx, field) + case "args": + return ec.fieldContext___Directive_args(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Directive", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_kind(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_kind(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Kind(), 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.marshalN__TypeKind2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_kind(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type __TypeKind does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_name(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Name(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + 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) ___Type_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_description(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Description(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + 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) ___Type_specifiedByURL(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_specifiedByURL(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.SpecifiedByURL(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_specifiedByURL(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + 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) ___Type_fields(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_fields(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Fields(fc.Args["includeDeprecated"].(bool)), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]introspection.Field) + fc.Result = res + return ec.marshalO__Field2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐFieldᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_fields(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___Field_name(ctx, field) + case "description": + return ec.fieldContext___Field_description(ctx, field) + case "args": + return ec.fieldContext___Field_args(ctx, field) + case "type": + return ec.fieldContext___Field_type(ctx, field) + case "isDeprecated": + return ec.fieldContext___Field_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___Field_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Field", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Type_fields_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___Type_interfaces(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_interfaces(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Interfaces(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]introspection.Type) + fc.Result = res + return ec.marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_interfaces(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_possibleTypes(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_possibleTypes(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.PossibleTypes(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]introspection.Type) + fc.Result = res + return ec.marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_possibleTypes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_enumValues(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_enumValues(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.EnumValues(fc.Args["includeDeprecated"].(bool)), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]introspection.EnumValue) + fc.Result = res + return ec.marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_enumValues(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___EnumValue_name(ctx, field) + case "description": + return ec.fieldContext___EnumValue_description(ctx, field) + case "isDeprecated": + return ec.fieldContext___EnumValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___EnumValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __EnumValue", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Type_enumValues_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___Type_inputFields(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_inputFields(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.InputFields(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]introspection.InputValue) + fc.Result = res + return ec.marshalO__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_inputFields(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___InputValue_name(ctx, field) + case "description": + return ec.fieldContext___InputValue_description(ctx, field) + case "type": + return ec.fieldContext___InputValue_type(ctx, field) + case "defaultValue": + return ec.fieldContext___InputValue_defaultValue(ctx, field) + case "isDeprecated": + return ec.fieldContext___InputValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___InputValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __InputValue", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_ofType(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_ofType(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.OfType(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*introspection.Type) + fc.Result = res + return ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_ofType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_isOneOf(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + fc, err := ec.fieldContext___Type_isOneOf(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsOneOf(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalOBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +// endregion **************************** field.gotpl ***************************** + +// region **************************** input.gotpl ***************************** + +// endregion **************************** input.gotpl ***************************** + +// region ************************** interface.gotpl *************************** + +// endregion ************************** interface.gotpl *************************** + +// region **************************** object.gotpl **************************** + +var accountImplementors = []string{"Account"} + +func (ec *executionContext) _Account(ctx context.Context, sel ast.SelectionSet, obj *types.Account) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, accountImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Account") + case "address": + 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._Account_address(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 "transactions": + 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._Account_transactions(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 "operations": + 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._Account_operations(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 "stateChanges": + 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._Account_stateChanges(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) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var operationImplementors = []string{"Operation"} + +func (ec *executionContext) _Operation(ctx context.Context, sel ast.SelectionSet, obj *types.Operation) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, operationImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Operation") + case "id": + out.Values[i] = ec._Operation_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "operationType": + out.Values[i] = ec._Operation_operationType(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "operationXdr": + out.Values[i] = ec._Operation_operationXdr(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ledgerNumber": + out.Values[i] = ec._Operation_ledgerNumber(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ledgerCreatedAt": + out.Values[i] = ec._Operation_ledgerCreatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ingestedAt": + out.Values[i] = ec._Operation_ingestedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "transaction": + 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._Operation_transaction(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 "accounts": + 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._Operation_accounts(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 "stateChanges": + 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._Operation_stateChanges(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) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var queryImplementors = []string{"Query"} + +func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, queryImplementors) + ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ + Object: "Query", + }) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + innerCtx := graphql.WithRootFieldContext(ctx, &graphql.RootFieldContext{ + Object: field.Name, + Field: field, + }) + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Query") + case "transactionByHash": + 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._Query_transactionByHash(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "transactions": + 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._Query_transactions(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "account": + 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._Query_account(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "operations": + 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._Query_operations(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "stateChanges": + 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._Query_stateChanges(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "__type": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Query___type(ctx, field) + }) + case "__schema": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Query___schema(ctx, field) + }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var stateChangeImplementors = []string{"StateChange"} + +func (ec *executionContext) _StateChange(ctx context.Context, sel ast.SelectionSet, obj *types.StateChange) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, stateChangeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("StateChange") + case "id": + out.Values[i] = ec._StateChange_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "accountId": + out.Values[i] = ec._StateChange_accountId(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "stateChangeCategory": + out.Values[i] = ec._StateChange_stateChangeCategory(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "stateChangeReason": + out.Values[i] = ec._StateChange_stateChangeReason(ctx, field, obj) + case "ingestedAt": + out.Values[i] = ec._StateChange_ingestedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ledgerCreatedAt": + out.Values[i] = ec._StateChange_ledgerCreatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ledgerNumber": + out.Values[i] = ec._StateChange_ledgerNumber(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "tokenId": + 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._StateChange_tokenId(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 "amount": + 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._StateChange_amount(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 "claimableBalanceId": + 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._StateChange_claimableBalanceId(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 "liquidityPoolId": + 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._StateChange_liquidityPoolId(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 "offerId": + 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._StateChange_offerId(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 "signerAccountId": + 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._StateChange_signerAccountId(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 "spenderAccountId": + 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._StateChange_spenderAccountId(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 "sponsoredAccountId": + 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._StateChange_sponsoredAccountId(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 "sponsorAccountId": + 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._StateChange_sponsorAccountId(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 "signerWeights": + 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._StateChange_signerWeights(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 "thresholds": + 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._StateChange_thresholds(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 "flags": + 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._StateChange_flags(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 "keyValue": + 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._StateChange_keyValue(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 "operation": + 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._StateChange_operation(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 "transaction": + 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._StateChange_transaction(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) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var transactionImplementors = []string{"Transaction"} + +func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionSet, obj *types.Transaction) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, transactionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Transaction") + case "hash": + out.Values[i] = ec._Transaction_hash(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "envelopeXdr": + out.Values[i] = ec._Transaction_envelopeXdr(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "resultXdr": + out.Values[i] = ec._Transaction_resultXdr(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "metaXdr": + out.Values[i] = ec._Transaction_metaXdr(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ledgerNumber": + out.Values[i] = ec._Transaction_ledgerNumber(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ledgerCreatedAt": + out.Values[i] = ec._Transaction_ledgerCreatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "ingestedAt": + out.Values[i] = ec._Transaction_ingestedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "operations": + 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_operations(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 "accounts": + 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_accounts(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 "stateChanges": + 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_stateChanges(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) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __DirectiveImplementors = []string{"__Directive"} + +func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __DirectiveImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Directive") + case "name": + out.Values[i] = ec.___Directive_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___Directive_description(ctx, field, obj) + case "isRepeatable": + out.Values[i] = ec.___Directive_isRepeatable(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "locations": + out.Values[i] = ec.___Directive_locations(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "args": + out.Values[i] = ec.___Directive_args(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __EnumValueImplementors = []string{"__EnumValue"} + +func (ec *executionContext) ___EnumValue(ctx context.Context, sel ast.SelectionSet, obj *introspection.EnumValue) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __EnumValueImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__EnumValue") + case "name": + out.Values[i] = ec.___EnumValue_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___EnumValue_description(ctx, field, obj) + case "isDeprecated": + out.Values[i] = ec.___EnumValue_isDeprecated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deprecationReason": + out.Values[i] = ec.___EnumValue_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __FieldImplementors = []string{"__Field"} + +func (ec *executionContext) ___Field(ctx context.Context, sel ast.SelectionSet, obj *introspection.Field) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __FieldImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Field") + case "name": + out.Values[i] = ec.___Field_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___Field_description(ctx, field, obj) + case "args": + out.Values[i] = ec.___Field_args(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "type": + out.Values[i] = ec.___Field_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "isDeprecated": + out.Values[i] = ec.___Field_isDeprecated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deprecationReason": + out.Values[i] = ec.___Field_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __InputValueImplementors = []string{"__InputValue"} + +func (ec *executionContext) ___InputValue(ctx context.Context, sel ast.SelectionSet, obj *introspection.InputValue) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __InputValueImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__InputValue") + case "name": + out.Values[i] = ec.___InputValue_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___InputValue_description(ctx, field, obj) + case "type": + out.Values[i] = ec.___InputValue_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "defaultValue": + out.Values[i] = ec.___InputValue_defaultValue(ctx, field, obj) + case "isDeprecated": + out.Values[i] = ec.___InputValue_isDeprecated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deprecationReason": + out.Values[i] = ec.___InputValue_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __SchemaImplementors = []string{"__Schema"} + +func (ec *executionContext) ___Schema(ctx context.Context, sel ast.SelectionSet, obj *introspection.Schema) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __SchemaImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Schema") + case "description": + out.Values[i] = ec.___Schema_description(ctx, field, obj) + case "types": + out.Values[i] = ec.___Schema_types(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "queryType": + out.Values[i] = ec.___Schema_queryType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "mutationType": + out.Values[i] = ec.___Schema_mutationType(ctx, field, obj) + case "subscriptionType": + out.Values[i] = ec.___Schema_subscriptionType(ctx, field, obj) + case "directives": + out.Values[i] = ec.___Schema_directives(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __TypeImplementors = []string{"__Type"} + +func (ec *executionContext) ___Type(ctx context.Context, sel ast.SelectionSet, obj *introspection.Type) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __TypeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Type") + case "kind": + out.Values[i] = ec.___Type_kind(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "name": + out.Values[i] = ec.___Type_name(ctx, field, obj) + case "description": + out.Values[i] = ec.___Type_description(ctx, field, obj) + case "specifiedByURL": + out.Values[i] = ec.___Type_specifiedByURL(ctx, field, obj) + case "fields": + out.Values[i] = ec.___Type_fields(ctx, field, obj) + case "interfaces": + out.Values[i] = ec.___Type_interfaces(ctx, field, obj) + case "possibleTypes": + out.Values[i] = ec.___Type_possibleTypes(ctx, field, obj) + case "enumValues": + out.Values[i] = ec.___Type_enumValues(ctx, field, obj) + case "inputFields": + out.Values[i] = ec.___Type_inputFields(ctx, field, obj) + case "ofType": + out.Values[i] = ec.___Type_ofType(ctx, field, obj) + case "isOneOf": + out.Values[i] = ec.___Type_isOneOf(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +// endregion **************************** object.gotpl **************************** + +// region ***************************** type.gotpl ***************************** + +func (ec *executionContext) marshalNAccount2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccountᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.Account) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNAccount2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccount(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNAccount2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccount(ctx context.Context, sel ast.SelectionSet, v *types.Account) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Account(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) { + res, err := graphql.UnmarshalBoolean(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.SelectionSet, v bool) graphql.Marshaler { + _ = sel + res := graphql.MarshalBoolean(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalNInt642int64(ctx context.Context, v any) (int64, error) { + res, err := graphql.UnmarshalInt64(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNInt642int64(ctx context.Context, sel ast.SelectionSet, v int64) graphql.Marshaler { + _ = sel + res := graphql.MarshalInt64(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalNOperation2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx context.Context, sel ast.SelectionSet, v types.Operation) graphql.Marshaler { + return ec._Operation(ctx, sel, &v) +} + +func (ec *executionContext) marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.Operation) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx context.Context, sel ast.SelectionSet, v *types.Operation) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Operation(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNOperationType2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationType(ctx context.Context, v any) (types.OperationType, error) { + tmp, err := graphql.UnmarshalString(v) + res := types.OperationType(tmp) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNOperationType2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationType(ctx context.Context, sel ast.SelectionSet, v types.OperationType) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(string(v)) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.StateChange) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNStateChange2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChange(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNStateChange2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChange(ctx context.Context, sel ast.SelectionSet, v *types.StateChange) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._StateChange(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNStateChangeCategory2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeCategory(ctx context.Context, v any) (types.StateChangeCategory, error) { + tmp, err := graphql.UnmarshalString(v) + res := types.StateChangeCategory(tmp) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNStateChangeCategory2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeCategory(ctx context.Context, sel ast.SelectionSet, v types.StateChangeCategory) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(string(v)) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { + res, err := graphql.UnmarshalString(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalNTime2timeᚐTime(ctx context.Context, v any) (time.Time, error) { + res, err := graphql.UnmarshalTime(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNTime2timeᚐTime(ctx context.Context, sel ast.SelectionSet, v time.Time) graphql.Marshaler { + _ = sel + res := graphql.MarshalTime(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalNTransaction2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx context.Context, sel ast.SelectionSet, v types.Transaction) graphql.Marshaler { + return ec._Transaction(ctx, sel, &v) +} + +func (ec *executionContext) marshalNTransaction2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransactionᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.Transaction) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx context.Context, sel ast.SelectionSet, v *types.Transaction) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Transaction(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNUInt322uint32(ctx context.Context, v any) (uint32, error) { + res, err := scalars.UnmarshalUInt32(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNUInt322uint32(ctx context.Context, sel ast.SelectionSet, v uint32) graphql.Marshaler { + _ = sel + res := scalars.MarshalUInt32(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler { + return ec.___Directive(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__Directive2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirectiveᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Directive) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalN__DirectiveLocation2string(ctx context.Context, v any) (string, error) { + res, err := graphql.UnmarshalString(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalN__DirectiveLocation2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalN__DirectiveLocation2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalN__DirectiveLocation2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalN__DirectiveLocation2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__DirectiveLocation2string(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalN__EnumValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValue(ctx context.Context, sel ast.SelectionSet, v introspection.EnumValue) graphql.Marshaler { + return ec.___EnumValue(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__Field2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐField(ctx context.Context, sel ast.SelectionSet, v introspection.Field) graphql.Marshaler { + return ec.___Field(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__InputValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValue(ctx context.Context, sel ast.SelectionSet, v introspection.InputValue) graphql.Marshaler { + return ec.___InputValue(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.InputValue) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__InputValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValue(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalN__Type2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx context.Context, sel ast.SelectionSet, v introspection.Type) graphql.Marshaler { + return ec.___Type(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Type) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__Type2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx context.Context, sel ast.SelectionSet, v *introspection.Type) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec.___Type(ctx, sel, v) +} + +func (ec *executionContext) unmarshalN__TypeKind2string(ctx context.Context, v any) (string, error) { + res, err := graphql.UnmarshalString(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalN__TypeKind2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalOAccount2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccount(ctx context.Context, sel ast.SelectionSet, v *types.Account) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Account(ctx, sel, v) +} + +func (ec *executionContext) unmarshalOBoolean2bool(ctx context.Context, v any) (bool, error) { + res, err := graphql.UnmarshalBoolean(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOBoolean2bool(ctx context.Context, sel ast.SelectionSet, v bool) graphql.Marshaler { + _ = sel + _ = ctx + res := graphql.MarshalBoolean(v) + return res +} + +func (ec *executionContext) unmarshalOBoolean2ᚖbool(ctx context.Context, v any) (*bool, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalBoolean(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast.SelectionSet, v *bool) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalBoolean(*v) + return res +} + +func (ec *executionContext) unmarshalOInt2ᚖint32(ctx context.Context, v any) (*int32, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalInt32(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOInt2ᚖint32(ctx context.Context, sel ast.SelectionSet, v *int32) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalInt32(*v) + return res +} + +func (ec *executionContext) unmarshalOStateChangeReason2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeReason(ctx context.Context, v any) (*types.StateChangeReason, error) { + if v == nil { + return nil, nil + } + tmp, err := graphql.UnmarshalString(v) + res := types.StateChangeReason(tmp) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOStateChangeReason2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeReason(ctx context.Context, sel ast.SelectionSet, v *types.StateChangeReason) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalString(string(*v)) + return res +} + +func (ec *executionContext) unmarshalOString2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + if v == nil { + return nil, nil + } + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalOString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2string(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalOString2ᚖstring(ctx context.Context, v any) (*string, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalString(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOString2ᚖstring(ctx context.Context, sel ast.SelectionSet, v *string) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalString(*v) + return res +} + +func (ec *executionContext) marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx context.Context, sel ast.SelectionSet, v *types.Transaction) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Transaction(ctx, sel, v) +} + +func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__EnumValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValue(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__Field2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐFieldᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Field) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__Field2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐField(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.InputValue) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__InputValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValue(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__Schema2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐSchema(ctx context.Context, sel ast.SelectionSet, v *introspection.Schema) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec.___Schema(ctx, sel, v) +} + +func (ec *executionContext) marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Type) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalN__Type2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx context.Context, sel ast.SelectionSet, v *introspection.Type) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec.___Type(ctx, sel, v) +} + +// endregion ***************************** type.gotpl ***************************** diff --git a/internal/serve/graphql/generated/models_gen.go b/internal/serve/graphql/generated/models_gen.go new file mode 100644 index 00000000..0d529b78 --- /dev/null +++ b/internal/serve/graphql/generated/models_gen.go @@ -0,0 +1,6 @@ +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. + +package graphql + +type Query struct { +} diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go new file mode 100644 index 00000000..c7a95b45 --- /dev/null +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -0,0 +1,68 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.76 + +import ( + "context" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +// Address is the resolver for the address field. +func (r *accountResolver) Address(ctx context.Context, obj *types.Account) (string, error) { + return obj.StellarAddress, nil +} + +// Transactions is the resolver for the transactions field. +// This is a field resolver - it resolves the "transactions" field on an Account object +// gqlgen calls this when a GraphQL query requests the transactions field on an Account +// Field resolvers receive the parent object (Account) and return the field value +func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account) ([]*types.Transaction, error) { + // Extract dataloaders from GraphQL context + // Dataloaders are injected by middleware to batch database queries + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to efficiently batch-load transactions for this account + // This prevents N+1 queries when multiple accounts request their transactions + transactions, err := loaders.TransactionsByAccountLoader.Load(ctx, obj.StellarAddress) + if err != nil { + return nil, err + } + return transactions, nil +} + +// Operations is the resolver for the operations field. +// This field resolver handles the "operations" field on an Account object +// Demonstrates the same dataloader pattern as Transactions resolver +func (r *accountResolver) Operations(ctx context.Context, obj *types.Account) ([]*types.Operation, error) { + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to batch-load operations for this account + operations, err := loaders.OperationsByAccountLoader.Load(ctx, obj.StellarAddress) + if err != nil { + return nil, err + } + return operations, nil +} + +// StateChanges is the resolver for the stateChanges field. +func (r *accountResolver) StateChanges(ctx context.Context, obj *types.Account) ([]*types.StateChange, error) { + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to batch-load state changes for this account + stateChanges, err := loaders.StateChangesByAccountLoader.Load(ctx, obj.StellarAddress) + if err != nil { + return nil, err + } + return stateChanges, nil +} + +// Account returns graphql1.AccountResolver implementation. +func (r *Resolver) Account() graphql1.AccountResolver { return &accountResolver{r} } + +type accountResolver struct{ *Resolver } diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go new file mode 100644 index 00000000..482e049c --- /dev/null +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -0,0 +1,156 @@ +package resolvers + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +func TestAccountResolver_Transactions(t *testing.T) { + resolver := &accountResolver{&Resolver{}} + parentAccount := &types.Account{StellarAddress: "test-account"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Transaction, []error) { + assert.Equal(t, []string{"test-account"}, keys) + results := [][]*types.Transaction{ + { + {Hash: "tx1"}, + {Hash: "tx2"}, + }, + } + return results, nil + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + TransactionsByAccountLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + transactions, err := resolver.Transactions(ctx, parentAccount) + + require.NoError(t, err) + require.Len(t, transactions, 2) + assert.Equal(t, "tx1", transactions[0].Hash) + assert.Equal(t, "tx2", transactions[1].Hash) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Transaction, []error) { + return nil, []error{errors.New("something went wrong")} + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + TransactionsByAccountLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Transactions(ctx, parentAccount) + + require.Error(t, err) + assert.EqualError(t, err, "something went wrong") + }) +} + +func TestAccountResolver_Operations(t *testing.T) { + resolver := &accountResolver{&Resolver{}} + parentAccount := &types.Account{StellarAddress: "test-account"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { + assert.Equal(t, []string{"test-account"}, keys) + results := [][]*types.Operation{ + { + {ID: 1, TxHash: "tx1"}, + {ID: 2, TxHash: "tx2"}, + }, + } + return results, nil + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + OperationsByAccountLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + operations, err := resolver.Operations(ctx, parentAccount) + + require.NoError(t, err) + require.Len(t, operations, 2) + assert.Equal(t, "tx1", operations[0].TxHash) + assert.Equal(t, "tx2", operations[1].TxHash) + }) + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { + return nil, []error{errors.New("something went wrong")} + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + OperationsByAccountLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Operations(ctx, parentAccount) + + require.Error(t, err) + assert.EqualError(t, err, "something went wrong") + }) +} + +func TestAccountResolver_StateChanges(t *testing.T) { + resolver := &accountResolver{&Resolver{}} + parentAccount := &types.Account{StellarAddress: "test-account"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { + assert.Equal(t, []string{"test-account"}, keys) + results := [][]*types.StateChange{ + { + {ID: "sc1", TxHash: "tx1"}, + {ID: "sc2", TxHash: "tx1"}, + }, + } + return results, nil + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + StateChangesByAccountLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + stateChanges, err := resolver.StateChanges(ctx, parentAccount) + + require.NoError(t, err) + require.Len(t, stateChanges, 2) + assert.Equal(t, "sc1", stateChanges[0].ID) + assert.Equal(t, "sc2", stateChanges[1].ID) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { + return nil, []error{errors.New("sc fetch error")} + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + StateChangesByAccountLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.StateChanges(ctx, parentAccount) + + require.Error(t, err) + assert.EqualError(t, err, "sc fetch error") + }) +} diff --git a/internal/serve/graphql/resolvers/operation.resolvers.go b/internal/serve/graphql/resolvers/operation.resolvers.go new file mode 100644 index 00000000..fa3c1ad7 --- /dev/null +++ b/internal/serve/graphql/resolvers/operation.resolvers.go @@ -0,0 +1,73 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.76 + +import ( + "context" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +// Transaction is the resolver for the transaction field. +// This is a field resolver - it resolves the "transaction" field on an Operation object +// gqlgen calls this when a GraphQL query requests the transaction field on an Operation +// Field resolvers receive the parent object (Operation) and return the field value +func (r *operationResolver) Transaction(ctx context.Context, obj *types.Operation) (*types.Transaction, error) { + // Extract dataloaders from GraphQL context + // Dataloaders are injected by middleware to batch database queries + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to efficiently batch-load transaction for this operation + // This prevents N+1 queries when multiple operations request their transaction + transaction, err := loaders.TransactionsByOperationIDLoader.Load(ctx, obj.ID) + if err != nil { + return nil, err + } + return transaction, nil +} + +// Accounts is the resolver for the accounts field. +// This is a field resolver - it resolves the "accounts" field on an Operation object +// gqlgen calls this when a GraphQL query requests the accounts field on an Operation +// Field resolvers receive the parent object (Operation) and return the field value +func (r *operationResolver) Accounts(ctx context.Context, obj *types.Operation) ([]*types.Account, error) { + // Extract dataloaders from GraphQL context + // Dataloaders are injected by middleware to batch database queries + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to efficiently batch-load accounts for this operation + // This prevents N+1 queries when multiple operations request their accounts + accounts, err := loaders.AccountsByOperationIDLoader.Load(ctx, obj.ID) + if err != nil { + return nil, err + } + return accounts, nil +} + +// StateChanges is the resolver for the stateChanges field. +// This is a field resolver - it resolves the "stateChanges" field on an Operation object +// gqlgen calls this when a GraphQL query requests the stateChanges field on an Operation +// Field resolvers receive the parent object (Operation) and return the field value +func (r *operationResolver) StateChanges(ctx context.Context, obj *types.Operation) ([]*types.StateChange, error) { + // Extract dataloaders from GraphQL context + // Dataloaders are injected by middleware to batch database queries + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to efficiently batch-load state changes for this operation + // This prevents N+1 queries when multiple operations request their state changes + stateChanges, err := loaders.StateChangesByOperationIDLoader.Load(ctx, obj.ID) + if err != nil { + return nil, err + } + return stateChanges, nil +} + +// Operation returns graphql1.OperationResolver implementation. +func (r *Resolver) Operation() graphql1.OperationResolver { return &operationResolver{r} } + +type operationResolver struct{ *Resolver } diff --git a/internal/serve/graphql/resolvers/operation.resolvers_test.go b/internal/serve/graphql/resolvers/operation.resolvers_test.go new file mode 100644 index 00000000..bbf1e7dc --- /dev/null +++ b/internal/serve/graphql/resolvers/operation.resolvers_test.go @@ -0,0 +1,151 @@ +package resolvers + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +func TestOperationResolver_Transaction(t *testing.T) { + resolver := &operationResolver{&Resolver{}} + parentOperation := &types.Operation{ID: 123} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []int64) ([]*types.Transaction, []error) { + assert.Equal(t, []int64{123}, keys) + results := []*types.Transaction{ + {Hash: "tx1"}, + } + return results, nil + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + TransactionsByOperationIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + transaction, err := resolver.Transaction(ctx, parentOperation) + + require.NoError(t, err) + require.NotNil(t, transaction) + assert.Equal(t, "tx1", transaction.Hash) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []int64) ([]*types.Transaction, []error) { + return nil, []error{errors.New("something went wrong")} + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + TransactionsByOperationIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Transaction(ctx, parentOperation) + + require.Error(t, err) + assert.EqualError(t, err, "something went wrong") + }) +} + +func TestOperationResolver_Accounts(t *testing.T) { + resolver := &operationResolver{&Resolver{}} + parentOperation := &types.Operation{ID: 123} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []int64) ([][]*types.Account, []error) { + assert.Equal(t, []int64{123}, keys) + results := [][]*types.Account{ + { + {StellarAddress: "G-ACCOUNT1"}, + {StellarAddress: "G-ACCOUNT2"}, + }, + } + return results, nil + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + AccountsByOperationIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + accounts, err := resolver.Accounts(ctx, parentOperation) + + require.NoError(t, err) + require.Len(t, accounts, 2) + assert.Equal(t, "G-ACCOUNT1", accounts[0].StellarAddress) + assert.Equal(t, "G-ACCOUNT2", accounts[1].StellarAddress) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []int64) ([][]*types.Account, []error) { + return nil, []error{errors.New("account fetch error")} + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + AccountsByOperationIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Accounts(ctx, parentOperation) + + require.Error(t, err) + assert.EqualError(t, err, "account fetch error") + }) +} + +func TestOperationResolver_StateChanges(t *testing.T) { + resolver := &operationResolver{&Resolver{}} + parentOperation := &types.Operation{ID: 123} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []int64) ([][]*types.StateChange, []error) { + assert.Equal(t, []int64{123}, keys) + results := [][]*types.StateChange{ + { + {ID: "sc1"}, + {ID: "sc2"}, + }, + } + return results, nil + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + StateChangesByOperationIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + stateChanges, err := resolver.StateChanges(ctx, parentOperation) + + require.NoError(t, err) + require.Len(t, stateChanges, 2) + assert.Equal(t, "sc1", stateChanges[0].ID) + assert.Equal(t, "sc2", stateChanges[1].ID) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []int64) ([][]*types.StateChange, []error) { + return nil, []error{errors.New("sc fetch error")} + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + StateChangesByOperationIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.StateChanges(ctx, parentOperation) + + require.Error(t, err) + assert.EqualError(t, err, "sc fetch error") + }) +} diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go new file mode 100644 index 00000000..85efa808 --- /dev/null +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -0,0 +1,49 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.76 + +import ( + "context" + + "github.com/stellar/wallet-backend/internal/indexer/types" + graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" +) + +// TransactionByHash is the resolver for the transactionByHash field. +// This is a root query resolver - it handles the "transactionByHash" query. +// gqlgen calls this function when a GraphQL query requests "transactionByHash" +func (r *queryResolver) TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) { + return r.models.Transactions.GetByHash(ctx, hash) +} + +// Transactions is the resolver for the transactions field. +// This resolver handles the "transactions" query. +// It demonstrates handling optional arguments (limit can be nil) +func (r *queryResolver) Transactions(ctx context.Context, limit *int32) ([]*types.Transaction, error) { + return r.models.Transactions.GetAll(ctx, limit) +} + +// Account is the resolver for the account field. +// This resolver handles the "account" query. +// It shows the standard pattern: receive args, query data, return result or error +func (r *queryResolver) Account(ctx context.Context, address string) (*types.Account, error) { + return r.models.Account.Get(ctx, address) +} + +// Operations is the resolver for the operations field. +// This resolver handles the "operations" query. +func (r *queryResolver) Operations(ctx context.Context, limit *int32) ([]*types.Operation, error) { + return r.models.Operations.GetAll(ctx, limit) +} + +// StateChanges is the resolver for the stateChanges field. +func (r *queryResolver) StateChanges(ctx context.Context, limit *int32) ([]*types.StateChange, error) { + return r.models.StateChanges.GetAll(ctx, limit) +} + +// Query returns graphql1.QueryResolver implementation. +func (r *Resolver) Query() graphql1.QueryResolver { return &queryResolver{r} } + +type queryResolver struct{ *Resolver } diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go new file mode 100644 index 00000000..f12b48d1 --- /dev/null +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -0,0 +1,415 @@ +package resolvers + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/db" + "github.com/stellar/wallet-backend/internal/db/dbtest" + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" +) + +func TestQueryResolver_TransactionByHash(t *testing.T) { + ctx := context.Background() + + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + cleanUpDB := func() { + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) + require.NoError(t, err) + } + + t.Run("success", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &queryResolver{ + &Resolver{ + models: &data.Models{ + Transactions: &data.TransactionModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }, + } + + expectedTx := &types.Transaction{ + Hash: "tx1", + ToID: 1, + EnvelopeXDR: "envelope1", + ResultXDR: "result1", + MetaXDR: "meta1", + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + } + + dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + expectedTx.Hash, expectedTx.ToID, expectedTx.EnvelopeXDR, expectedTx.ResultXDR, expectedTx.MetaXDR, expectedTx.LedgerNumber, expectedTx.LedgerCreatedAt) + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + tx, err := resolver.TransactionByHash(ctx, "tx1") + + require.NoError(t, err) + assert.Equal(t, expectedTx.Hash, tx.Hash) + assert.Equal(t, expectedTx.ToID, tx.ToID) + assert.Equal(t, expectedTx.EnvelopeXDR, tx.EnvelopeXDR) + assert.Equal(t, expectedTx.ResultXDR, tx.ResultXDR) + assert.Equal(t, expectedTx.MetaXDR, tx.MetaXDR) + assert.Equal(t, expectedTx.LedgerNumber, tx.LedgerNumber) + cleanUpDB() + }) +} + +func TestQueryResolver_Transactions(t *testing.T) { + ctx := context.Background() + + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + cleanUpDB := func() { + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) + require.NoError(t, err) + } + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &queryResolver{ + &Resolver{ + models: &data.Models{ + Transactions: &data.TransactionModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }, + } + + tx1 := &types.Transaction{ + Hash: "tx1", + ToID: 1, + EnvelopeXDR: "envelope1", + ResultXDR: "result1", + MetaXDR: "meta1", + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + } + tx2 := &types.Transaction{ + Hash: "tx2", + ToID: 2, + EnvelopeXDR: "envelope2", + ResultXDR: "result2", + MetaXDR: "meta2", + LedgerNumber: 2, + LedgerCreatedAt: time.Now(), + } + + dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7), ($8, $9, $10, $11, $12, $13, $14)`, + tx1.Hash, tx1.ToID, tx1.EnvelopeXDR, tx1.ResultXDR, tx1.MetaXDR, tx1.LedgerNumber, tx1.LedgerCreatedAt, + tx2.Hash, tx2.ToID, tx2.EnvelopeXDR, tx2.ResultXDR, tx2.MetaXDR, tx2.LedgerNumber, tx2.LedgerCreatedAt) + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + t.Run("get all", func(t *testing.T) { + txs, err := resolver.Transactions(ctx, nil) + require.NoError(t, err) + assert.Len(t, txs, 2) + }) + + t.Run("get with limit", func(t *testing.T) { + limit := int32(1) + txs, err := resolver.Transactions(ctx, &limit) + require.NoError(t, err) + assert.Len(t, txs, 1) + }) + + cleanUpDB() +} + +func TestQueryResolver_Account(t *testing.T) { + ctx := context.Background() + + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + cleanUpDB := func() { + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM accounts`) + require.NoError(t, err) + } + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &queryResolver{ + &Resolver{ + models: &data.Models{ + Account: &data.AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }, + } + + expectedAccount := &types.Account{ + StellarAddress: "GC...", + } + + dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO accounts (stellar_address) VALUES ($1)`, + expectedAccount.StellarAddress) + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + t.Run("success", func(t *testing.T) { + acc, err := resolver.Account(ctx, expectedAccount.StellarAddress) + require.NoError(t, err) + assert.Equal(t, expectedAccount.StellarAddress, acc.StellarAddress) + }) + + cleanUpDB() +} + +func TestQueryResolver_Operations(t *testing.T) { + ctx := context.Background() + + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + cleanUpDB := func() { + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM operations`) + require.NoError(t, err) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) + require.NoError(t, err) + } + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &queryResolver{ + &Resolver{ + models: &data.Models{ + Operations: &data.OperationModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }, + } + + // Insert a transaction to satisfy the foreign key constraint + expectedTx := &types.Transaction{ + Hash: "tx1", + ToID: 1, + EnvelopeXDR: "envelope1", + ResultXDR: "result1", + MetaXDR: "meta1", + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + } + dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + expectedTx.Hash, expectedTx.ToID, expectedTx.EnvelopeXDR, expectedTx.ResultXDR, expectedTx.MetaXDR, expectedTx.LedgerNumber, expectedTx.LedgerCreatedAt) + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + op1 := &types.Operation{ + ID: 1, + OperationType: types.OperationTypePayment, + OperationXDR: "op1_xdr", + TxHash: "tx1", + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + } + op2 := &types.Operation{ + ID: 2, + OperationType: types.OperationTypeCreateAccount, + OperationXDR: "op2_xdr", + TxHash: "tx1", + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + } + + dbErr = db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO operations (id, operation_type, operation_xdr, tx_hash, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6), ($7, $8, $9, $10, $11, $12)`, + op1.ID, op1.OperationType, op1.OperationXDR, op1.TxHash, op1.LedgerNumber, op1.LedgerCreatedAt, + op2.ID, op2.OperationType, op2.OperationXDR, op2.TxHash, op2.LedgerNumber, op2.LedgerCreatedAt) + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + t.Run("get all", func(t *testing.T) { + ops, err := resolver.Operations(ctx, nil) + require.NoError(t, err) + assert.Len(t, ops, 2) + }) + + t.Run("get with limit", func(t *testing.T) { + limit := int32(1) + ops, err := resolver.Operations(ctx, &limit) + require.NoError(t, err) + assert.Len(t, ops, 1) + }) + + cleanUpDB() +} + +func TestQueryResolver_StateChanges(t *testing.T) { + ctx := context.Background() + + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + cleanUpDB := func() { + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM state_changes`) + require.NoError(t, err) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM operations`) + require.NoError(t, err) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) + require.NoError(t, err) + } + + resolver := &queryResolver{ + &Resolver{ + models: &data.Models{ + StateChanges: &data.StateChangeModel{ + DB: dbConnectionPool, + }, + }, + }, + } + + // Insert a transaction to satisfy the foreign key constraint + expectedTx := &types.Transaction{ + Hash: "tx1", + ToID: 1, + EnvelopeXDR: "envelope1", + ResultXDR: "result1", + MetaXDR: "meta1", + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + } + dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + expectedTx.Hash, expectedTx.ToID, expectedTx.EnvelopeXDR, expectedTx.ResultXDR, expectedTx.MetaXDR, expectedTx.LedgerNumber, expectedTx.LedgerCreatedAt) + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + // Insert accounts to satisfy the foreign key constraint + dbErr = db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO accounts (stellar_address) VALUES ($1), ($2)`, + "account1", "account2") + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + sc1 := &types.StateChange{ + ID: "sc1", + StateChangeCategory: types.StateChangeCategoryCredit, + TxHash: "tx1", + OperationID: 1, + AccountID: "account1", + LedgerCreatedAt: time.Now(), + LedgerNumber: 1, + } + sc2 := &types.StateChange{ + ID: "sc2", + StateChangeCategory: types.StateChangeCategoryDebit, + TxHash: "tx1", + OperationID: 1, + AccountID: "account2", + LedgerCreatedAt: time.Now(), + LedgerNumber: 1, + } + + dbErr = db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO state_changes (id, state_change_category, tx_hash, operation_id, account_id, ledger_created_at, ledger_number) VALUES ($1, $2, $3, $4, $5, $6, $7), ($8, $9, $10, $11, $12, $13, $14)`, + sc1.ID, sc1.StateChangeCategory, sc1.TxHash, sc1.OperationID, sc1.AccountID, sc1.LedgerCreatedAt, sc1.LedgerNumber, + sc2.ID, sc2.StateChangeCategory, sc2.TxHash, sc2.OperationID, sc2.AccountID, sc2.LedgerCreatedAt, sc2.LedgerNumber) + require.NoError(t, err) + return nil + }) + require.NoError(t, dbErr) + + t.Run("get all", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + resolver.models.StateChanges.MetricsService = mockMetricsService + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + scs, err := resolver.StateChanges(ctx, nil) + require.NoError(t, err) + assert.Len(t, scs, 2) + assert.Contains(t, []string{"sc1", "sc2"}, scs[0].ID) + assert.Contains(t, []string{"sc1", "sc2"}, scs[1].ID) + mockMetricsService.AssertExpectations(t) + }) + + t.Run("get with limit", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + resolver.models.StateChanges.MetricsService = mockMetricsService + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + limit := int32(1) + scs, err := resolver.StateChanges(ctx, &limit) + require.NoError(t, err) + assert.Len(t, scs, 1) + mockMetricsService.AssertExpectations(t) + }) + + cleanUpDB() +} diff --git a/internal/serve/graphql/resolvers/resolver.go b/internal/serve/graphql/resolvers/resolver.go new file mode 100644 index 00000000..fad6ea90 --- /dev/null +++ b/internal/serve/graphql/resolvers/resolver.go @@ -0,0 +1,28 @@ +// GraphQL resolvers package - implements resolver functions for GraphQL schema +// This package contains the business logic for handling GraphQL queries and field resolution +package resolvers + +// This file will not be regenerated automatically. +// +// It serves as dependency injection for your app, add any dependencies you require here. +// This is the main resolver struct that gqlgen uses to resolve GraphQL queries. + +import ( + "github.com/stellar/wallet-backend/internal/data" +) + +// Resolver is the main resolver struct for gqlgen +// It holds dependencies needed by all resolver functions +// gqlgen will embed this struct in generated resolver interfaces +type Resolver struct { + // models provides access to data layer for database operations + // This follows dependency injection pattern - resolvers don't create their own DB connections + models *data.Models +} + +// NewResolver creates a new resolver instance with required dependencies +// This constructor is called during server startup to initialize the resolver +// Dependencies are injected here and available to all resolver functions. +func NewResolver(models *data.Models) *Resolver { + return &Resolver{models: models} +} diff --git a/internal/serve/graphql/resolvers/statechange.resolvers.go b/internal/serve/graphql/resolvers/statechange.resolvers.go new file mode 100644 index 00000000..8744c430 --- /dev/null +++ b/internal/serve/graphql/resolvers/statechange.resolvers.go @@ -0,0 +1,172 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.76 + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +// TokenID is the resolver for the tokenId field. +// This resolver handles nullable string fields from the database +// GraphQL nullable fields return null when the database value is not valid +func (r *stateChangeResolver) TokenID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.TokenID.Valid { + return &obj.TokenID.String, nil + } + return nil, nil +} + +// Amount is the resolver for the amount field. +func (r *stateChangeResolver) Amount(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.Amount.Valid { + return &obj.Amount.String, nil + } + return nil, nil +} + +// ClaimableBalanceID is the resolver for the claimableBalanceId field. +func (r *stateChangeResolver) ClaimableBalanceID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.ClaimableBalanceID.Valid { + return &obj.ClaimableBalanceID.String, nil + } + return nil, nil +} + +// LiquidityPoolID is the resolver for the liquidityPoolId field. +func (r *stateChangeResolver) LiquidityPoolID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.LiquidityPoolID.Valid { + return &obj.LiquidityPoolID.String, nil + } + return nil, nil +} + +// OfferID is the resolver for the offerId field. +func (r *stateChangeResolver) OfferID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.OfferID.Valid { + return &obj.OfferID.String, nil + } + return nil, nil +} + +// SignerAccountID is the resolver for the signerAccountId field. +func (r *stateChangeResolver) SignerAccountID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.SignerAccountID.Valid { + return &obj.SignerAccountID.String, nil + } + return nil, nil +} + +// SpenderAccountID is the resolver for the spenderAccountId field. +func (r *stateChangeResolver) SpenderAccountID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.SpenderAccountID.Valid { + return &obj.SpenderAccountID.String, nil + } + return nil, nil +} + +// SponsoredAccountID is the resolver for the sponsoredAccountId field. +func (r *stateChangeResolver) SponsoredAccountID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.SponsoredAccountID.Valid { + return &obj.SponsoredAccountID.String, nil + } + return nil, nil +} + +// SponsorAccountID is the resolver for the sponsorAccountId field. +func (r *stateChangeResolver) SponsorAccountID(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.SponsorAccountID.Valid { + return &obj.SponsorAccountID.String, nil + } + return nil, nil +} + +// SignerWeights is the resolver for the signerWeights field. +// This resolver handles JSONB fields from the database +// Converts Go struct/map to JSON string for GraphQL +func (r *stateChangeResolver) SignerWeights(ctx context.Context, obj *types.StateChange) (*string, error) { + // Check if JSONB field has data + if obj.SignerWeights == nil { + return nil, nil + } + // Marshal Go object to JSON bytes + jsonBytes, err := json.Marshal(obj.SignerWeights) + if err != nil { + return nil, fmt.Errorf("failed to marshal signerWeights: %w", err) + } + jsonString := string(jsonBytes) + return &jsonString, nil +} + +// Thresholds is the resolver for the thresholds field. +// Handles JSONB threshold data conversion to JSON string +func (r *stateChangeResolver) Thresholds(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.Thresholds == nil { + return nil, nil + } + // Marshal Go object to JSON for GraphQL + jsonBytes, err := json.Marshal(obj.Thresholds) + if err != nil { + return nil, fmt.Errorf("failed to marshal thresholds: %w", err) + } + jsonString := string(jsonBytes) + return &jsonString, nil +} + +// Flags is the resolver for the flags field. +// Converts Go string slice to GraphQL string array +// This field uses a non-nullable array return type +func (r *stateChangeResolver) Flags(ctx context.Context, obj *types.StateChange) ([]string, error) { + if obj.Flags == nil { + return []string{}, nil + } + return []string(obj.Flags), nil +} + +// KeyValue is the resolver for the keyValue field. +// Handles JSONB key-value data conversion to JSON string +func (r *stateChangeResolver) KeyValue(ctx context.Context, obj *types.StateChange) (*string, error) { + if obj.KeyValue == nil { + return nil, nil + } + // Marshal Go object to JSON for GraphQL + jsonBytes, err := json.Marshal(obj.KeyValue) + if err != nil { + return nil, fmt.Errorf("failed to marshal keyValue: %w", err) + } + jsonString := string(jsonBytes) + return &jsonString, nil +} + +// Operation is the resolver for the operation field. +func (r *stateChangeResolver) Operation(ctx context.Context, obj *types.StateChange) (*types.Operation, error) { + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + operations, err := loaders.OperationByStateChangeIDLoader.Load(ctx, obj.ID) + if err != nil { + return nil, err + } + return operations, nil +} + +// Transaction is the resolver for the transaction field. +func (r *stateChangeResolver) Transaction(ctx context.Context, obj *types.StateChange) (*types.Transaction, error) { + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + transactions, err := loaders.TransactionByStateChangeIDLoader.Load(ctx, obj.ID) + if err != nil { + return nil, err + } + return transactions, nil +} + +// StateChange returns graphql1.StateChangeResolver implementation. +func (r *Resolver) StateChange() graphql1.StateChangeResolver { return &stateChangeResolver{r} } + +type stateChangeResolver struct{ *Resolver } diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go new file mode 100644 index 00000000..0fb0cfb7 --- /dev/null +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -0,0 +1,224 @@ +package resolvers + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +func TestStateChangeResolver_NullableStringFields(t *testing.T) { + resolver := &stateChangeResolver{&Resolver{}} + ctx := context.Background() + + t.Run("all valid", func(t *testing.T) { + obj := &types.StateChange{ + TokenID: sql.NullString{String: "token1", Valid: true}, + Amount: sql.NullString{String: "100.5", Valid: true}, + ClaimableBalanceID: sql.NullString{String: "cb1", Valid: true}, + LiquidityPoolID: sql.NullString{String: "lp1", Valid: true}, + OfferID: sql.NullString{String: "offer1", Valid: true}, + SignerAccountID: sql.NullString{String: "G-SIGNER", Valid: true}, + SpenderAccountID: sql.NullString{String: "G-SPENDER", Valid: true}, + SponsoredAccountID: sql.NullString{String: "G-SPONSORED", Valid: true}, + SponsorAccountID: sql.NullString{String: "G-SPONSOR", Valid: true}, + } + + tokenID, err := resolver.TokenID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "token1", *tokenID) + + amount, err := resolver.Amount(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "100.5", *amount) + + cbID, err := resolver.ClaimableBalanceID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "cb1", *cbID) + + lpID, err := resolver.LiquidityPoolID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "lp1", *lpID) + + offerID, err := resolver.OfferID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "offer1", *offerID) + + signer, err := resolver.SignerAccountID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "G-SIGNER", *signer) + + spender, err := resolver.SpenderAccountID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "G-SPENDER", *spender) + + sponsored, err := resolver.SponsoredAccountID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "G-SPONSORED", *sponsored) + + sponsor, err := resolver.SponsorAccountID(ctx, obj) + require.NoError(t, err) + assert.Equal(t, "G-SPONSOR", *sponsor) + }) + + t.Run("all null", func(t *testing.T) { + obj := &types.StateChange{} // All fields are zero-valued (Valid: false) + + tokenID, err := resolver.TokenID(ctx, obj) + require.NoError(t, err) + assert.Nil(t, tokenID) + + amount, err := resolver.Amount(ctx, obj) + require.NoError(t, err) + assert.Nil(t, amount) + + cbID, err := resolver.ClaimableBalanceID(ctx, obj) + require.NoError(t, err) + assert.Nil(t, cbID) + }) +} + +func TestStateChangeResolver_JSONFields(t *testing.T) { + resolver := &stateChangeResolver{&Resolver{}} + ctx := context.Background() + + t.Run("signer weights", func(t *testing.T) { + obj := &types.StateChange{ + SignerWeights: types.NullableJSONB{"weight": 1}, + } + expectedJSON, err := json.Marshal(obj.SignerWeights) + require.NoError(t, err) + + jsonStr, err := resolver.SignerWeights(ctx, obj) + require.NoError(t, err) + assert.JSONEq(t, string(expectedJSON), *jsonStr) + + obj.SignerWeights = nil + jsonStr, err = resolver.SignerWeights(ctx, obj) + require.NoError(t, err) + assert.Nil(t, jsonStr) + }) + + t.Run("thresholds", func(t *testing.T) { + obj := &types.StateChange{ + Thresholds: types.NullableJSONB{"low": 1, "med": 2}, + } + expectedJSON, err := json.Marshal(obj.Thresholds) + require.NoError(t, err) + + jsonStr, err := resolver.Thresholds(ctx, obj) + require.NoError(t, err) + assert.JSONEq(t, string(expectedJSON), *jsonStr) + }) + + t.Run("flags", func(t *testing.T) { + obj := &types.StateChange{ + Flags: types.NullableJSON{"auth_required", "auth_revocable"}, + } + flags, err := resolver.Flags(ctx, obj) + require.NoError(t, err) + assert.Equal(t, []string{"auth_required", "auth_revocable"}, flags) + + obj.Flags = nil + flags, err = resolver.Flags(ctx, obj) + require.NoError(t, err) + assert.Empty(t, flags) + }) + + t.Run("key value", func(t *testing.T) { + obj := &types.StateChange{ + KeyValue: types.NullableJSONB{"key": "value"}, + } + expectedJSON, err := json.Marshal(obj.KeyValue) + require.NoError(t, err) + + jsonStr, err := resolver.KeyValue(ctx, obj) + require.NoError(t, err) + assert.JSONEq(t, string(expectedJSON), *jsonStr) + }) +} + +func TestStateChangeResolver_Operation(t *testing.T) { + resolver := &stateChangeResolver{&Resolver{}} + parentSC := &types.StateChange{ID: "test-sc-id"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([]*types.Operation, []error) { + assert.Equal(t, []string{"test-sc-id"}, keys) + return []*types.Operation{{ID: 99}}, nil + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + OperationByStateChangeIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + op, err := resolver.Operation(ctx, parentSC) + require.NoError(t, err) + assert.Equal(t, int64(99), op.ID) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([]*types.Operation, []error) { + return nil, []error{errors.New("op fetch error")} + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + OperationByStateChangeIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Operation(ctx, parentSC) + require.Error(t, err) + assert.EqualError(t, err, "op fetch error") + }) +} + +func TestStateChangeResolver_Transaction(t *testing.T) { + resolver := &stateChangeResolver{&Resolver{}} + parentSC := &types.StateChange{ID: "test-sc-id"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([]*types.Transaction, []error) { + assert.Equal(t, []string{"test-sc-id"}, keys) + return []*types.Transaction{{Hash: "tx-abc"}}, nil + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + TransactionByStateChangeIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + tx, err := resolver.Transaction(ctx, parentSC) + require.NoError(t, err) + assert.Equal(t, "tx-abc", tx.Hash) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([]*types.Transaction, []error) { + return nil, []error{errors.New("tx fetch error")} + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + TransactionByStateChangeIDLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Transaction(ctx, parentSC) + require.Error(t, err) + assert.EqualError(t, err, "tx fetch error") + }) +} diff --git a/internal/serve/graphql/resolvers/transaction.resolvers.go b/internal/serve/graphql/resolvers/transaction.resolvers.go new file mode 100644 index 00000000..12da7a2e --- /dev/null +++ b/internal/serve/graphql/resolvers/transaction.resolvers.go @@ -0,0 +1,63 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.76 + +import ( + "context" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +// Operations is the resolver for the operations field. +// This is a field resolver for the "operations" field on a Transaction object +// It's called when a GraphQL query requests the operations within a transaction +func (r *transactionResolver) Operations(ctx context.Context, obj *types.Transaction) ([]*types.Operation, error) { + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to batch-load operations for this transaction + operations, err := loaders.OperationsByTxHashLoader.Load(ctx, obj.Hash) + if err != nil { + return nil, err + } + return operations, nil +} + +// Accounts is the resolver for the accounts field. +// This is a field resolver for the "accounts" field on a Transaction object +// It's called when a GraphQL query requests the accounts within a transaction +func (r *transactionResolver) Accounts(ctx context.Context, obj *types.Transaction) ([]*types.Account, error) { + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to batch-load accounts for this transaction + // This prevents N+1 queries when multiple transactions request their operations + // The loader groups multiple requests and executes them in a single database query + accounts, err := loaders.AccountsByTxHashLoader.Load(ctx, obj.Hash) + if err != nil { + return nil, err + } + return accounts, nil +} + +// StateChanges is the resolver for the stateChanges field. +// This is a field resolver for the "stateChanges" field on a Transaction object +// It's called when a GraphQL query requests the state changes within a transaction +func (r *transactionResolver) StateChanges(ctx context.Context, obj *types.Transaction) ([]*types.StateChange, error) { + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + + // Use dataloader to batch-load state changes for this transaction + stateChanges, err := loaders.StateChangesByTxHashLoader.Load(ctx, obj.Hash) + if err != nil { + return nil, err + } + return stateChanges, nil +} + +// Transaction returns graphql1.TransactionResolver implementation. +func (r *Resolver) Transaction() graphql1.TransactionResolver { return &transactionResolver{r} } + +type transactionResolver struct{ *Resolver } diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go new file mode 100644 index 00000000..11367d6d --- /dev/null +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -0,0 +1,155 @@ +package resolvers + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" + "github.com/stellar/wallet-backend/internal/serve/middleware" +) + +func TestTransactionResolver_Operations(t *testing.T) { + resolver := &transactionResolver{&Resolver{}} + parentTx := &types.Transaction{Hash: "test-tx-hash"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { + assert.Equal(t, []string{"test-tx-hash"}, keys) + results := [][]*types.Operation{ + { + {ID: 1}, + {ID: 2}, + }, + } + return results, nil + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + OperationsByTxHashLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + operations, err := resolver.Operations(ctx, parentTx) + + require.NoError(t, err) + require.Len(t, operations, 2) + assert.Equal(t, int64(1), operations[0].ID) + assert.Equal(t, int64(2), operations[1].ID) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { + return nil, []error{errors.New("op fetch error")} + } + + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + OperationsByTxHashLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Operations(ctx, parentTx) + + require.Error(t, err) + assert.EqualError(t, err, "op fetch error") + }) +} + +func TestTransactionResolver_Accounts(t *testing.T) { + resolver := &transactionResolver{&Resolver{}} + parentTx := &types.Transaction{Hash: "test-tx-hash"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Account, []error) { + assert.Equal(t, []string{"test-tx-hash"}, keys) + results := [][]*types.Account{ + { + {StellarAddress: "G-ACCOUNT1"}, + {StellarAddress: "G-ACCOUNT2"}, + }, + } + return results, nil + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + AccountsByTxHashLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + accounts, err := resolver.Accounts(ctx, parentTx) + + require.NoError(t, err) + require.Len(t, accounts, 2) + assert.Equal(t, "G-ACCOUNT1", accounts[0].StellarAddress) + assert.Equal(t, "G-ACCOUNT2", accounts[1].StellarAddress) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.Account, []error) { + return nil, []error{errors.New("account fetch error")} + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + AccountsByTxHashLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.Accounts(ctx, parentTx) + + require.Error(t, err) + assert.EqualError(t, err, "account fetch error") + }) +} + +func TestTransactionResolver_StateChanges(t *testing.T) { + resolver := &transactionResolver{&Resolver{}} + parentTx := &types.Transaction{Hash: "test-tx-hash"} + + t.Run("success", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { + assert.Equal(t, []string{"test-tx-hash"}, keys) + results := [][]*types.StateChange{ + { + {ID: "sc1"}, + {ID: "sc2"}, + }, + } + return results, nil + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + StateChangesByTxHashLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + stateChanges, err := resolver.StateChanges(ctx, parentTx) + + require.NoError(t, err) + require.Len(t, stateChanges, 2) + assert.Equal(t, "sc1", stateChanges[0].ID) + assert.Equal(t, "sc2", stateChanges[1].ID) + }) + + t.Run("dataloader error", func(t *testing.T) { + mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { + return nil, []error{errors.New("sc fetch error")} + } + loader := dataloadgen.NewLoader(mockFetch) + loaders := &dataloaders.Dataloaders{ + StateChangesByTxHashLoader: loader, + } + ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + + _, err := resolver.StateChanges(ctx, parentTx) + + require.Error(t, err) + assert.EqualError(t, err, "sc fetch error") + }) +} diff --git a/internal/serve/graphql/scalars/uint32.go b/internal/serve/graphql/scalars/uint32.go new file mode 100644 index 00000000..58e1fee9 --- /dev/null +++ b/internal/serve/graphql/scalars/uint32.go @@ -0,0 +1,40 @@ +// GraphQL custom scalars package - implements custom scalar types for gqlgen +// Custom scalars extend GraphQL's built-in types with application-specific data types +// This package handles marshaling between Go types and GraphQL representations +package scalars + +import ( + "fmt" + "io" + + graphql "github.com/99designs/gqlgen/graphql" +) + +// MarshalUInt32 converts a Go uint32 to GraphQL output +// This function is called by gqlgen when serializing UInt32 fields to GraphQL responses +// GraphQL doesn't have native uint32 support, so we serialize as string representation +func MarshalUInt32(i uint32) graphql.Marshaler { + // Return a marshaler that writes the uint32 as a string + return graphql.WriterFunc(func(w io.Writer) { + // Convert uint32 to string representation for GraphQL + _, err := io.WriteString(w, fmt.Sprintf("%d", i)) + if err != nil { + // Panic on write error - this should not happen in normal operation + panic(err) + } + }) +} + +// UnmarshalUInt32 converts GraphQL input to Go uint32 +// This function is called by gqlgen when parsing UInt32 arguments and input fields +// GraphQL clients can send integers, and we convert them to uint32 +func UnmarshalUInt32(v any) (uint32, error) { + // Handle different input types that GraphQL clients might send + switch v := v.(type) { + case int: + // Convert int to uint32 - this is the most common case + return uint32(v), nil + default: + return 0, fmt.Errorf("%T is not a UInt32", v) + } +} diff --git a/internal/serve/graphql/schema/account.graphqls b/internal/serve/graphql/schema/account.graphqls new file mode 100644 index 00000000..bb1ee321 --- /dev/null +++ b/internal/serve/graphql/schema/account.graphqls @@ -0,0 +1,20 @@ +# GraphQL Account type - represents a blockchain account +# In GraphQL, types define the shape of data that can be queried +type Account{ + address: String! + + # GraphQL Relationships - these fields use resolvers for data fetching + # Each relationship resolver will be called when the field is requested + + # All transactions associated with this account + # Uses dataloader for efficient batching to prevent N+1 queries + transactions: [Transaction!]! + + # All operations associated with this account + # Uses dataloader for efficient batching to prevent N+1 queries + operations: [Operation!]! + + # All state changes associated with this account + # Uses resolver to fetch related state changes + stateChanges: [StateChange!]! +} diff --git a/internal/serve/graphql/schema/directives.graphqls b/internal/serve/graphql/schema/directives.graphqls new file mode 100644 index 00000000..8c53c33f --- /dev/null +++ b/internal/serve/graphql/schema/directives.graphqls @@ -0,0 +1,23 @@ +# GraphQL Directive - provides metadata to control gqlgen code generation +# Directives are like annotations that modify how GraphQL processes fields + +# @goField directive - controls how gqlgen generates Go code for fields +# This is a gqlgen-specific directive for customizing field resolution +directive @goField( + # forceResolver: Boolean - forces gqlgen to generate a resolver function + # even if the Go struct has a matching field name + # Useful when you need custom logic for field resolution + forceResolver: Boolean + + # name: String - specifies the Go struct field name to map to + # Allows mapping GraphQL field names to different Go field names + name: String + + # omittable: Boolean - indicates if the field can be omitted from queries + # Used for optional fields in input types + omittable: Boolean + + # type: String - specifies the Go type for the field + # Overrides gqlgen's default type inference + type: String +) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION diff --git a/internal/serve/graphql/schema/enums.graphqls b/internal/serve/graphql/schema/enums.graphqls new file mode 100644 index 00000000..0cd9bcc3 --- /dev/null +++ b/internal/serve/graphql/schema/enums.graphqls @@ -0,0 +1,74 @@ +# GraphQL Enums - provide type safety and restrict values to predefined options +# These enums match Go constants and provide better GraphQL introspection + +# OperationType enum - defines all possible operation types +# GraphQL enums are validated at query time, preventing invalid values +enum OperationType { + CREATE_ACCOUNT + PAYMENT + PATH_PAYMENT_STRICT_RECEIVE + PATH_PAYMENT_STRICT_SEND + MANAGE_SELL_OFFER + CREATE_PASSIVE_SELL_OFFER + MANAGE_BUY_OFFER + SET_OPTIONS + CHANGE_TRUST + ALLOW_TRUST + ACCOUNT_MERGE + INFLATION + MANAGE_DATA + BUMP_SEQUENCE + CREATE_CLAIMABLE_BALANCE + CLAIM_CLAIMABLE_BALANCE + BEGIN_SPONSORING_FUTURE_RESERVES + END_SPONSORING_FUTURE_RESERVES + REVOKE_SPONSORSHIP + CLAWBACK + CLAWBACK_CLAIMABLE_BALANCE + SET_TRUST_LINE_FLAGS + LIQUIDITY_POOL_DEPOSIT + LIQUIDITY_POOL_WITHDRAW + INVOKE_HOST_FUNCTION + EXTEND_FOOTPRINT_TTL + RESTORE_FOOTPRINT +} + +# StateChangeCategory enum - categorizes the type of state change +# Used in GraphQL queries to filter state changes by category +enum StateChangeCategory { + DEBIT + CREDIT + MINT + BURN + SIGNER + SIGNATURE_THRESHOLD + METADATA + FLAGS + LIABILITY + TRUSTLINE_FLAGS + ALLOWANCE + SPONSORSHIP + CONTRACT + AUTHORIZATION + UNSUPPORTED +} + +# StateChangeReason enum - provides specific reason for the state change +# Used in GraphQL queries to understand why a state change occurred +enum StateChangeReason { + ADD + REMOVE + UPDATE + LOW + MEDIUM + HIGH + HOME_DOMAIN + SET + CLEAR + SELL + BUY + DATA_ENTRY + CONSUME + DEPLOY + INVOKE +} diff --git a/internal/serve/graphql/schema/operation.graphqls b/internal/serve/graphql/schema/operation.graphqls new file mode 100644 index 00000000..99dad66d --- /dev/null +++ b/internal/serve/graphql/schema/operation.graphqls @@ -0,0 +1,20 @@ +# GraphQL Operation type - represents a blockchain operation +# Operations are the individual actions within a transaction +type Operation{ + id: Int64! + operationType: OperationType! + operationXdr: String! + ledgerNumber: UInt32! + ledgerCreatedAt: Time! + ingestedAt: Time! + + # GraphQL Relationships - these fields use resolvers + # Parent transaction + transaction: Transaction! @goField(forceResolver: true) + + # Related accounts - uses resolver with dataloader for efficiency + accounts: [Account!]! @goField(forceResolver: true) + + # Related state changes - uses resolver to fetch associated changes + stateChanges: [StateChange!]! @goField(forceResolver: true) +} diff --git a/internal/serve/graphql/schema/queries.graphqls b/internal/serve/graphql/schema/queries.graphqls new file mode 100644 index 00000000..88179c3a --- /dev/null +++ b/internal/serve/graphql/schema/queries.graphqls @@ -0,0 +1,9 @@ +# GraphQL Query root type - defines all available queries in the API +# In GraphQL, the Query type is the entry point for read operations +type Query { + transactionByHash(hash: String!): Transaction + transactions(limit: Int): [Transaction!]! + account(address: String!): Account + operations(limit: Int): [Operation!]! + stateChanges(limit: Int): [StateChange!]! +} diff --git a/internal/serve/graphql/schema/scalars.graphqls b/internal/serve/graphql/schema/scalars.graphqls new file mode 100644 index 00000000..c2260c60 --- /dev/null +++ b/internal/serve/graphql/schema/scalars.graphqls @@ -0,0 +1,18 @@ +# GraphQL Custom Scalars - extend GraphQL's built-in scalar types +# Custom scalars provide type safety for specific data formats +# gqlgen requires custom marshal/unmarshal functions for these types + +# Time scalar - represents timestamps +# Handles conversion between Go time.Time and GraphQL string/int representations +# Used for createdAt, ingestedAt, and other timestamp fields +scalar Time + +# UInt32 scalar - represents unsigned 32-bit integers +# GraphQL doesn't have native uint32, so we define a custom scalar +# Used for ledger numbers and other positive integer values +scalar UInt32 + +# Int64 scalar - represents 64-bit integers +# GraphQL's Int type is 32-bit, so we need custom scalar for larger values +# Used for database IDs and other large integer values +scalar Int64 diff --git a/internal/serve/graphql/schema/statechange.graphqls b/internal/serve/graphql/schema/statechange.graphqls new file mode 100644 index 00000000..1724ff63 --- /dev/null +++ b/internal/serve/graphql/schema/statechange.graphqls @@ -0,0 +1,38 @@ +# GraphQL StateChange type - represents changes to blockchain state +# This type has many nullable fields to handle various state change scenarios +# TODO: Break state change type into interface design and add sub types that implement the interface +type StateChange{ + id: String! + accountId: String! + stateChangeCategory: StateChangeCategory! + stateChangeReason: StateChangeReason + ingestedAt: Time! + ledgerCreatedAt: Time! + ledgerNumber: UInt32! + + # GraphQL Nullable fields - these map to sql.NullString in Go + # GraphQL handles nullable fields gracefully - they return null if not set + tokenId: String + amount: String + claimableBalanceId: String + liquidityPoolId: String + offerId: String + signerAccountId: String + spenderAccountId: String + sponsoredAccountId: String + sponsorAccountId: String + + # GraphQL fields for JSONB data - require custom resolvers + # These fields need special handling to convert between Go types and GraphQL + signerWeights: String + thresholds: String + flags: [String!] + keyValue: String + + # GraphQL Relationships - these fields use resolvers + # Related operation + operation: Operation! @goField(forceResolver: true) + + # Related transaction + transaction: Transaction! @goField(forceResolver: true) +} diff --git a/internal/serve/graphql/schema/transaction.graphqls b/internal/serve/graphql/schema/transaction.graphqls new file mode 100644 index 00000000..61b48f1b --- /dev/null +++ b/internal/serve/graphql/schema/transaction.graphqls @@ -0,0 +1,22 @@ +# GraphQL Transaction type - represents a blockchain transaction +# gqlgen generates Go structs from this schema definition +type Transaction{ + hash: String! + envelopeXdr: String! + resultXdr: String! + metaXdr: String! + ledgerNumber: UInt32! + ledgerCreatedAt: Time! + ingestedAt: Time! + + # GraphQL Relationships - these fields require resolvers + # @goField(forceResolver: true) tells gqlgen to always generate a resolver + # even if the Go struct has a matching field + operations: [Operation!]! @goField(forceResolver: true) + + # Related accounts - uses resolver with dataloader for efficiency + accounts: [Account!]! @goField(forceResolver: true) + + # Related state changes - uses resolver to fetch associated changes + stateChanges: [StateChange!]! @goField(forceResolver: true) +} diff --git a/internal/serve/middleware/dataloader_middleware.go b/internal/serve/middleware/dataloader_middleware.go new file mode 100644 index 00000000..c762e764 --- /dev/null +++ b/internal/serve/middleware/dataloader_middleware.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" +) + +type ctxKey string + +const ( + LoadersKey = ctxKey("dataloaders") +) + +func DataloaderMiddleware(models *data.Models) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Dataloaders are created per-request to avoid data sharing between requests. + // This ensures each request has a fresh view of the data. This also helps prevent + // inconsistent data view across horizontally scaled services. + // More info about this here: https://github.com/graphql/dataloader/issues/62#issue-193854091 + loaders := dataloaders.NewDataloaders(models) + ctx := context.WithValue(r.Context(), LoadersKey, loaders) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 3421dd1e..4ca47796 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -20,6 +20,8 @@ import ( "github.com/stellar/wallet-backend/internal/db" "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/metrics" + generated "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + resolvers "github.com/stellar/wallet-backend/internal/serve/graphql/resolvers" "github.com/stellar/wallet-backend/internal/serve/httperror" "github.com/stellar/wallet-backend/internal/serve/httphandler" "github.com/stellar/wallet-backend/internal/serve/middleware" @@ -29,6 +31,12 @@ import ( signingutils "github.com/stellar/wallet-backend/internal/signing/utils" txservices "github.com/stellar/wallet-backend/internal/transactions/services" "github.com/stellar/wallet-backend/pkg/wbclient/auth" + + gqlhandler "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/extension" + "github.com/99designs/gqlgen/graphql/handler/lru" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/vektah/gqlparser/v2/ast" ) // blockedOperationTypes is now empty but we're keeping it here in case we want to block specific operations again. @@ -225,6 +233,29 @@ func handler(deps handlerDeps) http.Handler { mux.Group(func(r chi.Router) { r.Use(middleware.AuthenticationMiddleware(deps.ServerHostname, deps.RequestAuthVerifier, deps.AppTracker, deps.MetricsService)) + r.Route("/graphql", func(r chi.Router) { + r.Use(middleware.DataloaderMiddleware(deps.Models)) + + resolver := resolvers.NewResolver(deps.Models) + + srv := gqlhandler.New( + generated.NewExecutableSchema( + generated.Config{ + Resolvers: resolver, + }, + ), + ) + srv.AddTransport(transport.Options{}) + srv.AddTransport(transport.GET{}) + srv.AddTransport(transport.POST{}) + srv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) + srv.Use(extension.Introspection{}) + srv.Use(extension.AutomaticPersistedQuery{ + Cache: lru.New[string](100), + }) + r.Handle("/query", srv) + }) + r.Route("/accounts", func(r chi.Router) { handler := &httphandler.AccountHandler{ AccountService: deps.AccountService, diff --git a/scripts/exclude_from_coverage.sh b/scripts/exclude_from_coverage.sh index 88c06096..78ea6ba1 100755 --- a/scripts/exclude_from_coverage.sh +++ b/scripts/exclude_from_coverage.sh @@ -19,3 +19,5 @@ exclude_terms "mock" "c.out" exclude_terms "mocks" "c.out" exclude_terms "internal/integrationtests" "c.out" exclude_terms "fixtures.go" "c.out" +exclude_terms "models_gen.go" "c.out" +exclude_terms "generated.go" "c.out" From c462e85599dc8764c2d98436e996548c8684a421 Mon Sep 17 00:00:00 2001 From: akcays Date: Fri, 25 Jul 2025 16:15:22 -0400 Subject: [PATCH 02/16] Move `/accounts/` REST APIs to GraphQL (#253) * Add registerAccount and deregisterAccount mutations * Remove the REST endpoints for registerAccount and deregisterAccount * Add tests for registerAccount and deregisterAccount mutations * RegisterAccount mutation should return error on conflict * Adjust existing tests to support the new behavior * Complete REST to GraphQL migration for /accounts/ endpoints * Remove ON CONFLICT DO NOTHING from account insertion to detect duplicates * Add custom errors: ErrAccountAlreadyExists and ErrAccountNotFound * Update Insert method to detect duplicate account errors using PostgreSQL error codes * Update Delete method to check RowsAffected() and return ErrAccountNotFound for non-existent accounts * Add tests for duplicate registration and non-existent account deregistration * Implement GraphQL error codes: - ACCOUNT_ALREADY_EXISTS for duplicate registrations - ACCOUNT_NOT_FOUND for deregistering non-existent accounts - ACCOUNT_REGISTRATION_FAILED for registration failures - ACCOUNT_DEREGISTRATION_FAILED for deregistration failures * Update resolver tests to expect GraphQL errors REST Cleanup: * Remove RegisterAccount and DeregisterAccount methods * Remove related REST handler tests for the removed routes * Update assertions to use assert.ErrorContains * Add address validation * Remove deadcode * Add success to registerAccountPayload for consistency * Rename isValidStellarAddress method to isValidAddress * Update assertion * Update the method to return wrapped error * Fix goimports errors --- go.mod | 2 +- internal/data/accounts.go | 30 +- internal/data/accounts_test.go | 136 ++-- internal/serve/graphql/generated/generated.go | 698 +++++++++++++++++- .../serve/graphql/generated/models_gen.go | 25 + .../graphql/resolvers/mutations.resolvers.go | 90 +++ .../resolvers/mutations_resolvers_test.go | 264 +++++++ internal/serve/graphql/resolvers/resolver.go | 10 +- .../serve/graphql/schema/mutations.graphqls | 27 + internal/serve/httphandler/account_handler.go | 42 -- .../serve/httphandler/account_handler_test.go | 211 ------ .../httphandler/request_params_validator.go | 9 - internal/serve/serve.go | 14 +- internal/services/account_service.go | 13 + internal/services/account_service_test.go | 151 +++- internal/services/ingest_test.go | 20 +- 16 files changed, 1368 insertions(+), 374 deletions(-) create mode 100644 internal/serve/graphql/resolvers/mutations.resolvers.go create mode 100644 internal/serve/graphql/resolvers/mutations_resolvers_test.go create mode 100644 internal/serve/graphql/schema/mutations.graphqls diff --git a/go.mod b/go.mod index 19588284..770badb1 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/go-chi/chi v4.1.2+incompatible github.com/go-playground/validator/v10 v10.27.0 github.com/golang-jwt/jwt/v5 v5.2.3 - github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.28 @@ -96,6 +95,7 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.8 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/schema v1.4.1 // indirect diff --git a/internal/data/accounts.go b/internal/data/accounts.go index f8a4b117..61b0f53f 100644 --- a/internal/data/accounts.go +++ b/internal/data/accounts.go @@ -2,6 +2,7 @@ package data import ( "context" + "errors" "fmt" "time" @@ -12,11 +13,22 @@ import ( "github.com/stellar/wallet-backend/internal/metrics" ) +var ( + ErrAccountAlreadyExists = errors.New("account already exists") + ErrAccountNotFound = errors.New("account not found") +) + type AccountModel struct { DB db.ConnectionPool MetricsService metrics.MetricsService } +// isDuplicateError checks if the error is a PostgreSQL unique violation +func isDuplicateError(err error) bool { + var pqErr *pq.Error + return err != nil && errors.As(err, &pqErr) && pqErr.Code == "23505" +} + func (m *AccountModel) Get(ctx context.Context, address string) (*types.Account, error) { const query = `SELECT * FROM accounts WHERE stellar_address = $1` var account types.Account @@ -32,28 +44,40 @@ func (m *AccountModel) Get(ctx context.Context, address string) (*types.Account, } func (m *AccountModel) Insert(ctx context.Context, address string) error { - const query = `INSERT INTO accounts (stellar_address) VALUES ($1) ON CONFLICT DO NOTHING` + const query = `INSERT INTO accounts (stellar_address) VALUES ($1)` start := time.Now() _, err := m.DB.ExecContext(ctx, query, address) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("INSERT", "accounts", duration) if err != nil { + if isDuplicateError(err) { + return ErrAccountAlreadyExists + } return fmt.Errorf("inserting address %s: %w", address, err) } m.MetricsService.IncDBQuery("INSERT", "accounts") - return nil } func (m *AccountModel) Delete(ctx context.Context, address string) error { const query = `DELETE FROM accounts WHERE stellar_address = $1` start := time.Now() - _, err := m.DB.ExecContext(ctx, query, address) + result, err := m.DB.ExecContext(ctx, query, address) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("DELETE", "accounts", duration) if err != nil { return fmt.Errorf("deleting address %s: %w", address, err) } + + // Check if any rows were affected to determine if account existed + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected for address %s: %w", address, err) + } + if rowsAffected == 0 { + return ErrAccountNotFound + } + m.MetricsService.IncDBQuery("DELETE", "accounts") return nil } diff --git a/internal/data/accounts_test.go b/internal/data/accounts_test.go index 79cd85b8..e0a4a348 100644 --- a/internal/data/accounts_test.go +++ b/internal/data/accounts_test.go @@ -22,27 +22,53 @@ func TestAccountModelInsert(t *testing.T) { require.NoError(t, err) defer dbConnectionPool.Close() - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Return() - defer mockMetricsService.AssertExpectations(t) - - m := &AccountModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - ctx := context.Background() - address := keypair.MustRandom().Address() - err = m.Insert(ctx, address) - require.NoError(t, err) - - var dbAddress sql.NullString - err = m.DB.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts LIMIT 1") - require.NoError(t, err) - - assert.True(t, dbAddress.Valid) - assert.Equal(t, address, dbAddress.String) + t.Run("successful insert", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + address := keypair.MustRandom().Address() + err = m.Insert(ctx, address) + require.NoError(t, err) + + var dbAddress sql.NullString + err = m.DB.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts WHERE stellar_address = $1", address) + require.NoError(t, err) + + assert.True(t, dbAddress.Valid) + assert.Equal(t, address, dbAddress.String) + }) + + t.Run("duplicate insert fails", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.Anything).Return().Times(2) + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Return().Times(1) + defer mockMetricsService.AssertExpectations(t) + + m := &AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + address := keypair.MustRandom().Address() + + // First insert should succeed + err = m.Insert(ctx, address) + require.NoError(t, err) + + // Second insert should fail + err = m.Insert(ctx, address) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAccountAlreadyExists) + }) } func TestAccountModelDelete(t *testing.T) { @@ -52,30 +78,50 @@ func TestAccountModelDelete(t *testing.T) { require.NoError(t, err) defer dbConnectionPool.Close() - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "DELETE", "accounts", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "DELETE", "accounts").Return() - defer mockMetricsService.AssertExpectations(t) - - m := &AccountModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - ctx := context.Background() - address := keypair.MustRandom().Address() - result, err := m.DB.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) - require.NoError(t, err) - rowAffected, err := result.RowsAffected() - require.NoError(t, err) - require.Equal(t, int64(1), rowAffected) - - err = m.Delete(ctx, address) - require.NoError(t, err) - - var dbAddress sql.NullString - err = m.DB.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts LIMIT 1") - assert.ErrorIs(t, err, sql.ErrNoRows) + t.Run("successful deletion", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "DELETE", "accounts", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "DELETE", "accounts").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + address := keypair.MustRandom().Address() + result, err := m.DB.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + rowAffected, err := result.RowsAffected() + require.NoError(t, err) + require.Equal(t, int64(1), rowAffected) + + err = m.Delete(ctx, address) + require.NoError(t, err) + + var dbAddress sql.NullString + err = m.DB.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts LIMIT 1") + assert.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("delete non-existent account fails", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "DELETE", "accounts", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + m := &AccountModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + nonExistentAddress := keypair.MustRandom().Address() + + err = m.Delete(ctx, nonExistentAddress) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAccountNotFound) + }) } func TestAccountModelGet(t *testing.T) { diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 2f30b7f9..5c3bab6f 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -42,6 +42,7 @@ type Config struct { type ResolverRoot interface { Account() AccountResolver + Mutation() MutationResolver Operation() OperationResolver Query() QueryResolver StateChange() StateChangeResolver @@ -59,6 +60,16 @@ type ComplexityRoot struct { Transactions func(childComplexity int) int } + DeregisterAccountPayload struct { + Message func(childComplexity int) int + Success func(childComplexity int) int + } + + Mutation struct { + DeregisterAccount func(childComplexity int, input DeregisterAccountInput) int + RegisterAccount func(childComplexity int, input RegisterAccountInput) int + } + Operation struct { Accounts func(childComplexity int) int ID func(childComplexity int) int @@ -79,6 +90,11 @@ type ComplexityRoot struct { Transactions func(childComplexity int, limit *int32) int } + RegisterAccountPayload struct { + Account func(childComplexity int) int + Success func(childComplexity int) int + } + StateChange struct { AccountID func(childComplexity int) int Amount func(childComplexity int) int @@ -124,6 +140,10 @@ type AccountResolver interface { Operations(ctx context.Context, obj *types.Account) ([]*types.Operation, error) StateChanges(ctx context.Context, obj *types.Account) ([]*types.StateChange, error) } +type MutationResolver interface { + RegisterAccount(ctx context.Context, input RegisterAccountInput) (*RegisterAccountPayload, error) + DeregisterAccount(ctx context.Context, input DeregisterAccountInput) (*DeregisterAccountPayload, error) +} type OperationResolver interface { Transaction(ctx context.Context, obj *types.Operation) (*types.Transaction, error) Accounts(ctx context.Context, obj *types.Operation) ([]*types.Account, error) @@ -206,6 +226,44 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Account.Transactions(childComplexity), true + case "DeregisterAccountPayload.message": + if e.complexity.DeregisterAccountPayload.Message == nil { + break + } + + return e.complexity.DeregisterAccountPayload.Message(childComplexity), true + + case "DeregisterAccountPayload.success": + if e.complexity.DeregisterAccountPayload.Success == nil { + break + } + + return e.complexity.DeregisterAccountPayload.Success(childComplexity), true + + case "Mutation.deregisterAccount": + if e.complexity.Mutation.DeregisterAccount == nil { + break + } + + args, err := ec.field_Mutation_deregisterAccount_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DeregisterAccount(childComplexity, args["input"].(DeregisterAccountInput)), true + + case "Mutation.registerAccount": + if e.complexity.Mutation.RegisterAccount == nil { + break + } + + args, err := ec.field_Mutation_registerAccount_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.RegisterAccount(childComplexity, args["input"].(RegisterAccountInput)), true + case "Operation.accounts": if e.complexity.Operation.Accounts == nil { break @@ -329,6 +387,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.Transactions(childComplexity, args["limit"].(*int32)), true + case "RegisterAccountPayload.account": + if e.complexity.RegisterAccountPayload.Account == nil { + break + } + + return e.complexity.RegisterAccountPayload.Account(childComplexity), true + + case "RegisterAccountPayload.success": + if e.complexity.RegisterAccountPayload.Success == nil { + break + } + + return e.complexity.RegisterAccountPayload.Success(childComplexity), true + case "StateChange.accountId": if e.complexity.StateChange.AccountID == nil { break @@ -560,7 +632,10 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { opCtx := graphql.GetOperationContext(ctx) ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)} - inputUnmarshalMap := graphql.BuildUnmarshalerMap() + inputUnmarshalMap := graphql.BuildUnmarshalerMap( + ec.unmarshalInputDeregisterAccountInput, + ec.unmarshalInputRegisterAccountInput, + ) first := true switch opCtx.Operation.Operation { @@ -594,6 +669,21 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { return &response } + case ast.Mutation: + return func(ctx context.Context) *graphql.Response { + if !first { + return nil + } + first = false + ctx = graphql.WithUnmarshalerMap(ctx, inputUnmarshalMap) + data := ec._Mutation(ctx, opCtx.Operation.SelectionSet) + var buf bytes.Buffer + data.MarshalGQL(&buf) + + return &graphql.Response{ + Data: buf.Bytes(), + } + } default: return graphql.OneShot(graphql.ErrorResponse(ctx, "unsupported GraphQL operation")) @@ -761,6 +851,34 @@ enum StateChangeReason { DEPLOY INVOKE } +`, BuiltIn: false}, + {Name: "../schema/mutations.graphqls", Input: `# GraphQL Mutation root type - defines all available mutations in the API +# In GraphQL, the Mutation type is the entry point for write operations +type Mutation { + # Account management mutations + registerAccount(input: RegisterAccountInput!): RegisterAccountPayload! + deregisterAccount(input: DeregisterAccountInput!): DeregisterAccountPayload! +} + +# Input types for account mutations +input RegisterAccountInput { + address: String! +} + +input DeregisterAccountInput { + address: String! +} + +# Payload types for account mutations +type RegisterAccountPayload { + success: Boolean! + account: Account +} + +type DeregisterAccountPayload { + success: Boolean! + message: String +} `, BuiltIn: false}, {Name: "../schema/operation.graphqls", Input: `# GraphQL Operation type - represents a blockchain operation # Operations are the individual actions within a transaction @@ -881,6 +999,52 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...) // region ***************************** args.gotpl ***************************** +func (ec *executionContext) field_Mutation_deregisterAccount_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_deregisterAccount_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_deregisterAccount_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (DeregisterAccountInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNDeregisterAccountInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐDeregisterAccountInput(ctx, tmp) + } + + var zeroVal DeregisterAccountInput + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_registerAccount_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_registerAccount_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_registerAccount_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (RegisterAccountInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNRegisterAccountInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐRegisterAccountInput(ctx, tmp) + } + + var zeroVal RegisterAccountInput + return zeroVal, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1383,6 +1547,213 @@ func (ec *executionContext) fieldContext_Account_stateChanges(_ context.Context, return fc, nil } +func (ec *executionContext) _DeregisterAccountPayload_success(ctx context.Context, field graphql.CollectedField, obj *DeregisterAccountPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DeregisterAccountPayload_success(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Success, 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DeregisterAccountPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DeregisterAccountPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DeregisterAccountPayload_message(ctx context.Context, field graphql.CollectedField, obj *DeregisterAccountPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DeregisterAccountPayload_message(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Message, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DeregisterAccountPayload_message(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DeregisterAccountPayload", + Field: field, + IsMethod: false, + 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) _Mutation_registerAccount(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_registerAccount(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().RegisterAccount(rctx, fc.Args["input"].(RegisterAccountInput)) + }) + 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.(*RegisterAccountPayload) + fc.Result = res + return ec.marshalNRegisterAccountPayload2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐRegisterAccountPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_registerAccount(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "success": + return ec.fieldContext_RegisterAccountPayload_success(ctx, field) + case "account": + return ec.fieldContext_RegisterAccountPayload_account(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type RegisterAccountPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_registerAccount_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deregisterAccount(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_deregisterAccount(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DeregisterAccount(rctx, fc.Args["input"].(DeregisterAccountInput)) + }) + 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.(*DeregisterAccountPayload) + fc.Result = res + return ec.marshalNDeregisterAccountPayload2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐDeregisterAccountPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_deregisterAccount(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "success": + return ec.fieldContext_DeregisterAccountPayload_success(ctx, field) + case "message": + return ec.fieldContext_DeregisterAccountPayload_message(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DeregisterAccountPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deregisterAccount_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Operation_id(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Operation_id(ctx, field) if err != nil { @@ -2377,6 +2748,101 @@ func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field return fc, nil } +func (ec *executionContext) _RegisterAccountPayload_success(ctx context.Context, field graphql.CollectedField, obj *RegisterAccountPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_RegisterAccountPayload_success(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Success, 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_RegisterAccountPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "RegisterAccountPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _RegisterAccountPayload_account(ctx context.Context, field graphql.CollectedField, obj *RegisterAccountPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_RegisterAccountPayload_account(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Account, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*types.Account) + fc.Result = res + return ec.marshalOAccount2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccount(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_RegisterAccountPayload_account(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "RegisterAccountPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "address": + return ec.fieldContext_Account_address(ctx, field) + case "transactions": + return ec.fieldContext_Account_transactions(ctx, field) + case "operations": + return ec.fieldContext_Account_operations(ctx, field) + case "stateChanges": + return ec.fieldContext_Account_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Account", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _StateChange_id(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { fc, err := ec.fieldContext_StateChange_id(ctx, field) if err != nil { @@ -5812,6 +6278,60 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field // region **************************** input.gotpl ***************************** +func (ec *executionContext) unmarshalInputDeregisterAccountInput(ctx context.Context, obj any) (DeregisterAccountInput, error) { + var it DeregisterAccountInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"address"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "address": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("address")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Address = data + } + } + + return it, nil +} + +func (ec *executionContext) unmarshalInputRegisterAccountInput(ctx context.Context, obj any) (RegisterAccountInput, error) { + var it RegisterAccountInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"address"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "address": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("address")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Address = data + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -5998,6 +6518,103 @@ func (ec *executionContext) _Account(ctx context.Context, sel ast.SelectionSet, return out } +var deregisterAccountPayloadImplementors = []string{"DeregisterAccountPayload"} + +func (ec *executionContext) _DeregisterAccountPayload(ctx context.Context, sel ast.SelectionSet, obj *DeregisterAccountPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, deregisterAccountPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeregisterAccountPayload") + case "success": + out.Values[i] = ec._DeregisterAccountPayload_success(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "message": + out.Values[i] = ec._DeregisterAccountPayload_message(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var mutationImplementors = []string{"Mutation"} + +func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, mutationImplementors) + ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ + Object: "Mutation", + }) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + innerCtx := graphql.WithRootFieldContext(ctx, &graphql.RootFieldContext{ + Object: field.Name, + Field: field, + }) + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Mutation") + case "registerAccount": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_registerAccount(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deregisterAccount": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deregisterAccount(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var operationImplementors = []string{"Operation"} func (ec *executionContext) _Operation(ctx context.Context, sel ast.SelectionSet, obj *types.Operation) graphql.Marshaler { @@ -6324,6 +6941,47 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr return out } +var registerAccountPayloadImplementors = []string{"RegisterAccountPayload"} + +func (ec *executionContext) _RegisterAccountPayload(ctx context.Context, sel ast.SelectionSet, obj *RegisterAccountPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, registerAccountPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("RegisterAccountPayload") + case "success": + out.Values[i] = ec._RegisterAccountPayload_success(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "account": + out.Values[i] = ec._RegisterAccountPayload_account(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var stateChangeImplementors = []string{"StateChange"} func (ec *executionContext) _StateChange(ctx context.Context, sel ast.SelectionSet, obj *types.StateChange) graphql.Marshaler { @@ -7473,6 +8131,25 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) unmarshalNDeregisterAccountInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐDeregisterAccountInput(ctx context.Context, v any) (DeregisterAccountInput, error) { + res, err := ec.unmarshalInputDeregisterAccountInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNDeregisterAccountPayload2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐDeregisterAccountPayload(ctx context.Context, sel ast.SelectionSet, v DeregisterAccountPayload) graphql.Marshaler { + return ec._DeregisterAccountPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDeregisterAccountPayload2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐDeregisterAccountPayload(ctx context.Context, sel ast.SelectionSet, v *DeregisterAccountPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DeregisterAccountPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNInt642int64(ctx context.Context, v any) (int64, error) { res, err := graphql.UnmarshalInt64(v) return res, graphql.ErrorOnPath(ctx, err) @@ -7564,6 +8241,25 @@ func (ec *executionContext) marshalNOperationType2githubᚗcomᚋstellarᚋwalle return res } +func (ec *executionContext) unmarshalNRegisterAccountInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐRegisterAccountInput(ctx context.Context, v any) (RegisterAccountInput, error) { + res, err := ec.unmarshalInputRegisterAccountInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNRegisterAccountPayload2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐRegisterAccountPayload(ctx context.Context, sel ast.SelectionSet, v RegisterAccountPayload) graphql.Marshaler { + return ec._RegisterAccountPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNRegisterAccountPayload2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐRegisterAccountPayload(ctx context.Context, sel ast.SelectionSet, v *RegisterAccountPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._RegisterAccountPayload(ctx, sel, v) +} + func (ec *executionContext) marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.StateChange) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup diff --git a/internal/serve/graphql/generated/models_gen.go b/internal/serve/graphql/generated/models_gen.go index 0d529b78..17332957 100644 --- a/internal/serve/graphql/generated/models_gen.go +++ b/internal/serve/graphql/generated/models_gen.go @@ -2,5 +2,30 @@ package graphql +import ( + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +type DeregisterAccountInput struct { + Address string `json:"address"` +} + +type DeregisterAccountPayload struct { + Success bool `json:"success"` + Message *string `json:"message,omitempty"` +} + +type Mutation struct { +} + type Query struct { } + +type RegisterAccountInput struct { + Address string `json:"address"` +} + +type RegisterAccountPayload struct { + Success bool `json:"success"` + Account *types.Account `json:"account,omitempty"` +} diff --git a/internal/serve/graphql/resolvers/mutations.resolvers.go b/internal/serve/graphql/resolvers/mutations.resolvers.go new file mode 100644 index 00000000..20a543c2 --- /dev/null +++ b/internal/serve/graphql/resolvers/mutations.resolvers.go @@ -0,0 +1,90 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.76 + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" + graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + "github.com/stellar/wallet-backend/internal/services" +) + +// RegisterAccount is the resolver for the registerAccount field. +func (r *mutationResolver) RegisterAccount(ctx context.Context, input graphql1.RegisterAccountInput) (*graphql1.RegisterAccountPayload, error) { + err := r.accountService.RegisterAccount(ctx, input.Address) + if err != nil { + if errors.Is(err, data.ErrAccountAlreadyExists) { + return nil, &gqlerror.Error{ + Message: "Account is already registered", + Extensions: map[string]interface{}{ + "code": "ACCOUNT_ALREADY_EXISTS", + }, + } + } + if errors.Is(err, services.ErrInvalidAddress) { + return nil, &gqlerror.Error{ + Message: "Invalid address: must be a valid Stellar public key or contract address", + Extensions: map[string]interface{}{ + "code": "INVALID_ADDRESS", + }, + } + } + return nil, &gqlerror.Error{ + Message: fmt.Sprintf("Failed to register account: %s", err.Error()), + Extensions: map[string]interface{}{ + "code": "ACCOUNT_REGISTRATION_FAILED", + }, + } + } + + // Return the account data directly since we know the address + account := &types.Account{ + StellarAddress: input.Address, + CreatedAt: time.Now(), + } + + return &graphql1.RegisterAccountPayload{ + Success: true, + Account: account, + }, nil +} + +// DeregisterAccount is the resolver for the deregisterAccount field. +func (r *mutationResolver) DeregisterAccount(ctx context.Context, input graphql1.DeregisterAccountInput) (*graphql1.DeregisterAccountPayload, error) { + err := r.accountService.DeregisterAccount(ctx, input.Address) + if err != nil { + if errors.Is(err, data.ErrAccountNotFound) { + return nil, &gqlerror.Error{ + Message: "Account not found", + Extensions: map[string]interface{}{ + "code": "ACCOUNT_NOT_FOUND", + }, + } + } + return nil, &gqlerror.Error{ + Message: fmt.Sprintf("Failed to deregister account: %s", err.Error()), + Extensions: map[string]interface{}{ + "code": "ACCOUNT_DEREGISTRATION_FAILED", + }, + } + } + + return &graphql1.DeregisterAccountPayload{ + Success: true, + Message: &[]string{"Account deregistered successfully"}[0], + }, nil +} + +// Mutation returns graphql1.MutationResolver implementation. +func (r *Resolver) Mutation() graphql1.MutationResolver { return &mutationResolver{r} } + +type mutationResolver struct{ *Resolver } diff --git a/internal/serve/graphql/resolvers/mutations_resolvers_test.go b/internal/serve/graphql/resolvers/mutations_resolvers_test.go new file mode 100644 index 00000000..c673bcb9 --- /dev/null +++ b/internal/serve/graphql/resolvers/mutations_resolvers_test.go @@ -0,0 +1,264 @@ +package resolvers + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/data" + graphql "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + "github.com/stellar/wallet-backend/internal/services" +) + +type mockAccountService struct { + mock.Mock +} + +func (m *mockAccountService) RegisterAccount(ctx context.Context, address string) error { + args := m.Called(ctx, address) + return args.Error(0) +} + +func (m *mockAccountService) DeregisterAccount(ctx context.Context, address string) error { + args := m.Called(ctx, address) + return args.Error(0) +} + +func TestMutationResolver_RegisterAccount(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.RegisterAccountInput{ + Address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + } + + mockService.On("RegisterAccount", ctx, input.Address).Return(nil) + + result, err := resolver.RegisterAccount(ctx, input) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.Success) + assert.NotNil(t, result.Account) + assert.Equal(t, input.Address, result.Account.StellarAddress) + + mockService.AssertExpectations(t) + }) + + t.Run("registration fails", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, // empty models for error case + }, + } + + input := graphql.RegisterAccountInput{ + Address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + } + + mockService.On("RegisterAccount", ctx, input.Address).Return(errors.New("registration failed")) + + result, err := resolver.RegisterAccount(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Failed to register account: registration failed") + + mockService.AssertExpectations(t) + }) + + t.Run("duplicate registration fails", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.RegisterAccountInput{ + Address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + } + + mockService.On("RegisterAccount", ctx, input.Address).Return(data.ErrAccountAlreadyExists) + + result, err := resolver.RegisterAccount(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Account is already registered") + + mockService.AssertExpectations(t) + }) + + t.Run("empty address", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.RegisterAccountInput{ + Address: "", + } + + mockService.On("RegisterAccount", ctx, "").Return(errors.New("invalid address")) + + result, err := resolver.RegisterAccount(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Failed to register account: invalid address") + + mockService.AssertExpectations(t) + }) + + t.Run("invalid address format", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.RegisterAccountInput{ + Address: "invalid-stellar-address", + } + + mockService.On("RegisterAccount", ctx, input.Address).Return(services.ErrInvalidAddress) + + result, err := resolver.RegisterAccount(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Invalid address: must be a valid Stellar public key or contract address") + + mockService.AssertExpectations(t) + }) +} + +func TestMutationResolver_DeregisterAccount(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.DeregisterAccountInput{ + Address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + } + + mockService.On("DeregisterAccount", ctx, input.Address).Return(nil) + + result, err := resolver.DeregisterAccount(ctx, input) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "Account deregistered successfully", *result.Message) + + mockService.AssertExpectations(t) + }) + + t.Run("deregistration fails", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.DeregisterAccountInput{ + Address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + } + + mockService.On("DeregisterAccount", ctx, input.Address).Return(errors.New("deregistration failed")) + + result, err := resolver.DeregisterAccount(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Failed to deregister account: deregistration failed") + + mockService.AssertExpectations(t) + }) + + t.Run("empty address", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.DeregisterAccountInput{ + Address: "", + } + + mockService.On("DeregisterAccount", ctx, "").Return(errors.New("invalid address")) + + result, err := resolver.DeregisterAccount(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Failed to deregister account: invalid address") + + mockService.AssertExpectations(t) + }) + + t.Run("account not found", func(t *testing.T) { + mockService := &mockAccountService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockService, + models: &data.Models{}, + }, + } + + input := graphql.DeregisterAccountInput{ + Address: "GNONEXISTENTACCOUNTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + } + + mockService.On("DeregisterAccount", ctx, input.Address).Return(data.ErrAccountNotFound) + + result, err := resolver.DeregisterAccount(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Account not found") + + mockService.AssertExpectations(t) + }) +} diff --git a/internal/serve/graphql/resolvers/resolver.go b/internal/serve/graphql/resolvers/resolver.go index fad6ea90..a4553117 100644 --- a/internal/serve/graphql/resolvers/resolver.go +++ b/internal/serve/graphql/resolvers/resolver.go @@ -9,6 +9,7 @@ package resolvers import ( "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/services" ) // Resolver is the main resolver struct for gqlgen @@ -18,11 +19,16 @@ type Resolver struct { // models provides access to data layer for database operations // This follows dependency injection pattern - resolvers don't create their own DB connections models *data.Models + // accountService provides account management operations + accountService services.AccountService } // NewResolver creates a new resolver instance with required dependencies // This constructor is called during server startup to initialize the resolver // Dependencies are injected here and available to all resolver functions. -func NewResolver(models *data.Models) *Resolver { - return &Resolver{models: models} +func NewResolver(models *data.Models, accountService services.AccountService) *Resolver { + return &Resolver{ + models: models, + accountService: accountService, + } } diff --git a/internal/serve/graphql/schema/mutations.graphqls b/internal/serve/graphql/schema/mutations.graphqls new file mode 100644 index 00000000..16bc8f0c --- /dev/null +++ b/internal/serve/graphql/schema/mutations.graphqls @@ -0,0 +1,27 @@ +# GraphQL Mutation root type - defines all available mutations in the API +# In GraphQL, the Mutation type is the entry point for write operations +type Mutation { + # Account management mutations + registerAccount(input: RegisterAccountInput!): RegisterAccountPayload! + deregisterAccount(input: DeregisterAccountInput!): DeregisterAccountPayload! +} + +# Input types for account mutations +input RegisterAccountInput { + address: String! +} + +input DeregisterAccountInput { + address: String! +} + +# Payload types for account mutations +type RegisterAccountPayload { + success: Boolean! + account: Account +} + +type DeregisterAccountPayload { + success: Boolean! + message: String +} diff --git a/internal/serve/httphandler/account_handler.go b/internal/serve/httphandler/account_handler.go index 7fe14ac5..b8b65ac5 100644 --- a/internal/serve/httphandler/account_handler.go +++ b/internal/serve/httphandler/account_handler.go @@ -21,48 +21,6 @@ type AccountHandler struct { AppTracker apptracker.AppTracker } -type AccountRegistrationRequest struct { - Address string `json:"address" validate:"required,public_key"` -} - -func (h AccountHandler) RegisterAccount(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var reqParams AccountRegistrationRequest - httpErr := DecodePathAndValidate(ctx, r, &reqParams, h.AppTracker) - if httpErr != nil { - httpErr.Render(w) - return - } - - err := h.AccountService.RegisterAccount(ctx, reqParams.Address) - if err != nil { - httperror.InternalServerError(ctx, "", err, nil, h.AppTracker).Render(w) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (h AccountHandler) DeregisterAccount(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var reqParams AccountRegistrationRequest - httpErr := DecodePathAndValidate(ctx, r, &reqParams, h.AppTracker) - if httpErr != nil { - httpErr.Render(w) - return - } - - err := h.AccountService.DeregisterAccount(ctx, reqParams.Address) - if err != nil { - httperror.InternalServerError(ctx, "", err, nil, h.AppTracker).Render(w) - return - } - - w.WriteHeader(http.StatusOK) -} - type SponsorAccountCreationRequest struct { Address string `json:"address" validate:"required,public_key"` Signers []entities.Signer `json:"signers" validate:"required,gt=0,dive"` diff --git a/internal/serve/httphandler/account_handler_test.go b/internal/serve/httphandler/account_handler_test.go index f3f45639..a8c96e48 100644 --- a/internal/serve/httphandler/account_handler_test.go +++ b/internal/serve/httphandler/account_handler_test.go @@ -1,235 +1,24 @@ package httphandler import ( - "context" - "database/sql" "fmt" "io" "net/http" "net/http/httptest" - "path" "strings" "testing" - "github.com/go-chi/chi" - "github.com/google/uuid" "github.com/stellar/go/keypair" "github.com/stellar/go/network" "github.com/stellar/go/txnbuild" "github.com/stellar/go/xdr" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/db/dbtest" "github.com/stellar/wallet-backend/internal/entities" - "github.com/stellar/wallet-backend/internal/metrics" "github.com/stellar/wallet-backend/internal/services" ) -func TestAccountHandlerRegisterAccount(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - mockMetricsService := metrics.NewMockMetricsService() - - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - accountService, err := services.NewAccountService(models, mockMetricsService) - require.NoError(t, err) - handler := &AccountHandler{ - AccountService: accountService, - } - - // Setup router - r := chi.NewRouter() - r.Post("/accounts/{address}", handler.RegisterAccount) - - clearAccounts := func(ctx context.Context) { - _, err = dbConnectionPool.ExecContext(ctx, "TRUNCATE accounts CASCADE") - require.NoError(t, err) - } - - t.Run("success_happy_path", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("IncActiveAccount").Once() - defer mockMetricsService.AssertExpectations(t) - - // Prepare request - address := keypair.MustRandom().Address() - var req *http.Request - req, err = http.NewRequest(http.MethodPost, path.Join("/accounts", address), nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 200 response - assert.Equal(t, http.StatusOK, rr.Code) - - ctx := context.Background() - var dbAddress sql.NullString - err = dbConnectionPool.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts") - require.NoError(t, err) - - // Assert address persisted in DB - assert.True(t, dbAddress.Valid) - assert.Equal(t, address, dbAddress.String) - - clearAccounts(ctx) - }) - - t.Run("address_already_exists", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("IncActiveAccount").Once() - defer mockMetricsService.AssertExpectations(t) - - address := keypair.MustRandom().Address() - ctx := context.Background() - - // Insert address in DB - _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) - require.NoError(t, err) - - // Prepare request - req, err := http.NewRequest(http.MethodPost, path.Join("/accounts", address), nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 200 response - assert.Equal(t, http.StatusOK, rr.Code) - - var dbAddress sql.NullString - err = dbConnectionPool.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts") - require.NoError(t, err) - - // Assert address persisted in DB - assert.True(t, dbAddress.Valid) - assert.Equal(t, address, dbAddress.String) - - clearAccounts(ctx) - }) - - t.Run("invalid_address", func(t *testing.T) { - // Prepare request - randomString := uuid.NewString() - req, err := http.NewRequest(http.MethodPost, path.Join("/accounts", randomString), nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - resp := rr.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error":"Validation error.", "extras": {"address":"Invalid public key provided"}}`, string(respBody)) - }) -} - -func TestAccountHandlerDeregisterAccount(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "DELETE", "accounts", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "DELETE", "accounts").Return().Times(2) - mockMetricsService.On("DecActiveAccount").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - accountService, err := services.NewAccountService(models, mockMetricsService) - require.NoError(t, err) - handler := &AccountHandler{ - AccountService: accountService, - } - - // Setup router - r := chi.NewRouter() - r.Delete("/accounts/{address}", handler.DeregisterAccount) - - t.Run("successHappyPath", func(t *testing.T) { - address := keypair.MustRandom().Address() - ctx := context.Background() - - // Insert address in DB - _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) - require.NoError(t, err) - - // Prepare request - var req *http.Request - req, err = http.NewRequest(http.MethodDelete, path.Join("/accounts", address), nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 200 response - assert.Equal(t, http.StatusOK, rr.Code) - - // Assert no address no longer in DB - var dbAddress sql.NullString - err = dbConnectionPool.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts") - assert.ErrorIs(t, err, sql.ErrNoRows) - }) - - t.Run("idempotency", func(t *testing.T) { - address := keypair.MustRandom().Address() - ctx := context.Background() - - // Make sure DB is empty - _, err = dbConnectionPool.ExecContext(ctx, "DELETE FROM accounts") - require.NoError(t, err) - - // Prepare request - req, err := http.NewRequest(http.MethodDelete, path.Join("/accounts", address), nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 200 response - assert.Equal(t, http.StatusOK, rr.Code) - }) - - t.Run("invalid_address", func(t *testing.T) { - // Prepare request - randomString := uuid.NewString() - req, err := http.NewRequest(http.MethodDelete, path.Join("/accounts", randomString), nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - resp := rr.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error":"Validation error.", "extras": {"address":"Invalid public key provided"}}`, string(respBody)) - }) -} - func TestAccountHandlerSponsorAccountCreation(t *testing.T) { asService := services.AccountSponsorshipServiceMock{} defer asService.AssertExpectations(t) diff --git a/internal/serve/httphandler/request_params_validator.go b/internal/serve/httphandler/request_params_validator.go index 8ec0e95c..53f3ced6 100644 --- a/internal/serve/httphandler/request_params_validator.go +++ b/internal/serve/httphandler/request_params_validator.go @@ -31,15 +31,6 @@ func DecodeQueryAndValidate(ctx context.Context, req *http.Request, reqQuery int return ValidateRequestParams(ctx, reqQuery, appTracker) } -func DecodePathAndValidate(ctx context.Context, req *http.Request, reqPath interface{}, appTracker apptracker.AppTracker) *httperror.ErrorResponse { - err := httpdecode.DecodePath(req, reqPath) - if err != nil { - return httperror.BadRequest("Invalid request path.", nil) - } - - return ValidateRequestParams(ctx, reqPath, appTracker) -} - func ValidateRequestParams(ctx context.Context, reqParams interface{}, appTracker apptracker.AppTracker) *httperror.ErrorResponse { val, err := validators.NewValidator() if err != nil { diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 4ca47796..08d56b73 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -236,7 +236,7 @@ func handler(deps handlerDeps) http.Handler { r.Route("/graphql", func(r chi.Router) { r.Use(middleware.DataloaderMiddleware(deps.Models)) - resolver := resolvers.NewResolver(deps.Models) + resolver := resolvers.NewResolver(deps.Models, deps.AccountService) srv := gqlhandler.New( generated.NewExecutableSchema( @@ -256,18 +256,6 @@ func handler(deps handlerDeps) http.Handler { r.Handle("/query", srv) }) - r.Route("/accounts", func(r chi.Router) { - handler := &httphandler.AccountHandler{ - AccountService: deps.AccountService, - AccountSponsorshipService: deps.AccountSponsorshipService, - SupportedAssets: deps.SupportedAssets, - AppTracker: deps.AppTracker, - } - - r.Post("/{address}", handler.RegisterAccount) - r.Delete("/{address}", handler.DeregisterAccount) - }) - r.Route("/payments", func(r chi.Router) { handler := &httphandler.PaymentHandler{ PaymentService: deps.PaymentService, diff --git a/internal/services/account_service.go b/internal/services/account_service.go index d1c8d9ad..e78e0b62 100644 --- a/internal/services/account_service.go +++ b/internal/services/account_service.go @@ -5,10 +5,14 @@ import ( "errors" "fmt" + "github.com/stellar/go/strkey" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/metrics" ) +var ErrInvalidAddress = errors.New("invalid address: must be a valid Stellar public key or contract address") + type AccountService interface { // RegisterAccount registers an externally created Stellar account to be sponsored, and tracked by ingestion RegisterAccount(ctx context.Context, address string) error @@ -34,7 +38,16 @@ func NewAccountService(models *data.Models, metricsService metrics.MetricsServic }, nil } +// isValidAddress validates that the address is either a valid Stellar public key or contract address +func isValidAddress(address string) bool { + return strkey.IsValidEd25519PublicKey(address) || strkey.IsValidContractAddress(address) +} + func (s *accountService) RegisterAccount(ctx context.Context, address string) error { + if !isValidAddress(address) { + return ErrInvalidAddress + } + err := s.models.Account.Insert(ctx, address) if err != nil { return fmt.Errorf("registering account %s: %w", address, err) diff --git a/internal/services/account_service_test.go b/internal/services/account_service_test.go index f31a27b6..e0fc0c8f 100644 --- a/internal/services/account_service_test.go +++ b/internal/services/account_service_test.go @@ -3,6 +3,7 @@ package services import ( "context" "database/sql" + "errors" "testing" "github.com/stellar/go/keypair" @@ -23,28 +24,79 @@ func TestAccountRegister(t *testing.T) { dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("IncActiveAccount").Return().Once() - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.Anything).Return().Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Return().Once() - defer mockMetricsService.AssertExpectations(t) - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - accountService, err := NewAccountService(models, mockMetricsService) - require.NoError(t, err) + t.Run("successful registration", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("IncActiveAccount").Return().Once() + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.Anything).Return().Once() + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Return().Once() + defer mockMetricsService.AssertExpectations(t) - ctx := context.Background() - address := keypair.MustRandom().Address() - err = accountService.RegisterAccount(ctx, address) - require.NoError(t, err) + models, err := data.NewModels(dbConnectionPool, mockMetricsService) + require.NoError(t, err) + accountService, err := NewAccountService(models, mockMetricsService) + require.NoError(t, err) - var dbAddress sql.NullString - err = dbConnectionPool.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts LIMIT 1") - require.NoError(t, err) + ctx := context.Background() + address := keypair.MustRandom().Address() + err = accountService.RegisterAccount(ctx, address) + require.NoError(t, err) + + var dbAddress sql.NullString + err = dbConnectionPool.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts WHERE stellar_address = $1", address) + require.NoError(t, err) + + assert.True(t, dbAddress.Valid) + assert.Equal(t, address, dbAddress.String) + }) + + t.Run("duplicate registration fails", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + // First registration succeeds + mockMetricsService.On("IncActiveAccount").Return().Once() + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.Anything).Return().Times(2) + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Return().Times(1) + defer mockMetricsService.AssertExpectations(t) + + models, err := data.NewModels(dbConnectionPool, mockMetricsService) + require.NoError(t, err) + accountService, err := NewAccountService(models, mockMetricsService) + require.NoError(t, err) + + ctx := context.Background() + address := keypair.MustRandom().Address() + + // First registration should succeed + err = accountService.RegisterAccount(ctx, address) + require.NoError(t, err) + + // Second registration should fail + err = accountService.RegisterAccount(ctx, address) + require.Error(t, err) + assert.True(t, errors.Is(err, data.ErrAccountAlreadyExists)) + }) + + t.Run("invalid address fails", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + defer mockMetricsService.AssertExpectations(t) + + models, err := data.NewModels(dbConnectionPool, mockMetricsService) + require.NoError(t, err) + accountService, err := NewAccountService(models, mockMetricsService) + require.NoError(t, err) - assert.True(t, dbAddress.Valid) - assert.Equal(t, address, dbAddress.String) + ctx := context.Background() + + // Test with invalid address + err = accountService.RegisterAccount(ctx, "invalid-address") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidAddress)) + + // Test with empty address + err = accountService.RegisterAccount(ctx, "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidAddress)) + }) } func TestAccountDeregister(t *testing.T) { @@ -54,29 +106,50 @@ func TestAccountDeregister(t *testing.T) { dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("DecActiveAccount").Return().Once() - mockMetricsService.On("ObserveDBQueryDuration", "DELETE", "accounts", mock.Anything).Return().Once() - mockMetricsService.On("IncDBQuery", "DELETE", "accounts").Return().Once() - defer mockMetricsService.AssertExpectations(t) - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - accountService, err := NewAccountService(models, mockMetricsService) - require.NoError(t, err) + t.Run("successful deregistration", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("DecActiveAccount").Return().Once() + mockMetricsService.On("ObserveDBQueryDuration", "DELETE", "accounts", mock.Anything).Return().Once() + mockMetricsService.On("IncDBQuery", "DELETE", "accounts").Return().Once() + defer mockMetricsService.AssertExpectations(t) - ctx := context.Background() - address := keypair.MustRandom().Address() - result, err := dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) - require.NoError(t, err) - rowAffected, err := result.RowsAffected() - require.NoError(t, err) - require.Equal(t, int64(1), rowAffected) + models, err := data.NewModels(dbConnectionPool, mockMetricsService) + require.NoError(t, err) + accountService, err := NewAccountService(models, mockMetricsService) + require.NoError(t, err) - err = accountService.DeregisterAccount(ctx, address) - require.NoError(t, err) + ctx := context.Background() + address := keypair.MustRandom().Address() + result, err := dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + rowAffected, err := result.RowsAffected() + require.NoError(t, err) + require.Equal(t, int64(1), rowAffected) + + err = accountService.DeregisterAccount(ctx, address) + require.NoError(t, err) + + var dbAddress sql.NullString + err = dbConnectionPool.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts LIMIT 1") + assert.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("deregister non-existent account fails", func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "DELETE", "accounts", mock.Anything).Return().Once() + defer mockMetricsService.AssertExpectations(t) + + models, err := data.NewModels(dbConnectionPool, mockMetricsService) + require.NoError(t, err) + accountService, err := NewAccountService(models, mockMetricsService) + require.NoError(t, err) + + ctx := context.Background() + nonExistentAddress := keypair.MustRandom().Address() - var dbAddress sql.NullString - err = dbConnectionPool.GetContext(ctx, &dbAddress, "SELECT stellar_address FROM accounts LIMIT 1") - assert.ErrorIs(t, err, sql.ErrNoRows) + err = accountService.DeregisterAccount(ctx, nonExistentAddress) + require.Error(t, err) + assert.True(t, errors.Is(err, data.ErrAccountNotFound)) + }) } diff --git a/internal/services/ingest_test.go b/internal/services/ingest_test.go index f2ca3a4c..9868c4c5 100644 --- a/internal/services/ingest_test.go +++ b/internal/services/ingest_test.go @@ -291,7 +291,9 @@ func TestIngestPayments(t *testing.T) { mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_receive", 0).Once() defer mockMetricsService.AssertExpectations(t) - err = models.Account.Insert(context.Background(), srcAccount) + // Use unique account for this test case + testSrcAccount := keypair.MustRandom().Address() + err = models.Account.Insert(context.Background(), testSrcAccount) require.NoError(t, err) path := []txnbuild.Asset{ @@ -300,7 +302,7 @@ func TestIngestPayments(t *testing.T) { } pathPaymentOp := txnbuild.PathPaymentStrictSend{ - SourceAccount: srcAccount, + SourceAccount: testSrcAccount, Destination: destAccount, DestMin: "9", SendAmount: "10", @@ -345,14 +347,14 @@ func TestIngestPayments(t *testing.T) { require.NoError(t, err) var payments []data.Payment - payments, _, _, err = models.Payments.GetPaymentsPaginated(context.Background(), srcAccount, "", "", data.ASC, 1) + payments, _, _, err = models.Payments.GetPaymentsPaginated(context.Background(), testSrcAccount, "", "", data.ASC, 1) require.NoError(t, err) require.NotEmpty(t, payments, "Expected at least one payment") assert.Equal(t, payments[0].TransactionHash, ledgerTransaction.Hash) assert.Equal(t, payments[0].SrcAmount, int64(100000000)) assert.Equal(t, payments[0].SrcAssetType, xdr.AssetTypeAssetTypeNative.String()) assert.Equal(t, payments[0].ToAddress, destAccount) - assert.Equal(t, payments[0].FromAddress, srcAccount) + assert.Equal(t, payments[0].FromAddress, testSrcAccount) assert.Equal(t, payments[0].SrcAssetCode, "XLM") assert.Equal(t, payments[0].DestAssetCode, "XLM") }) @@ -369,7 +371,9 @@ func TestIngestPayments(t *testing.T) { mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_receive", 1).Once() defer mockMetricsService.AssertExpectations(t) - err = models.Account.Insert(context.Background(), srcAccount) + // Use unique account for this test case + testSrcAccount := keypair.MustRandom().Address() + err = models.Account.Insert(context.Background(), testSrcAccount) require.NoError(t, err) path := []txnbuild.Asset{ @@ -378,7 +382,7 @@ func TestIngestPayments(t *testing.T) { } pathPaymentOp := txnbuild.PathPaymentStrictReceive{ - SourceAccount: srcAccount, + SourceAccount: testSrcAccount, Destination: destAccount, SendMax: "11", DestAmount: "10", @@ -420,13 +424,13 @@ func TestIngestPayments(t *testing.T) { err = ingestService.ingestPayments(context.Background(), ledgerTransactions) require.NoError(t, err) - payments, _, _, err := models.Payments.GetPaymentsPaginated(context.Background(), srcAccount, "", "", data.ASC, 1) + payments, _, _, err := models.Payments.GetPaymentsPaginated(context.Background(), testSrcAccount, "", "", data.ASC, 1) require.NoError(t, err) require.NotEmpty(t, payments, "Expected at least one payment") assert.Equal(t, payments[0].TransactionHash, ledgerTransaction.Hash) assert.Equal(t, payments[0].SrcAssetType, xdr.AssetTypeAssetTypeNative.String()) assert.Equal(t, payments[0].ToAddress, destAccount) - assert.Equal(t, payments[0].FromAddress, srcAccount) + assert.Equal(t, payments[0].FromAddress, testSrcAccount) assert.Equal(t, payments[0].SrcAssetCode, "XLM") assert.Equal(t, payments[0].DestAssetCode, "XLM") }) From 4f38d87e3be9c95278723545478d9b712685716c Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Mon, 4 Aug 2025 11:33:01 -0400 Subject: [PATCH 03/16] Build dynamic sql query based on columns requested in GraphQL (#261) --- internal/data/accounts.go | 22 +- internal/data/accounts_test.go | 4 +- internal/data/operations.go | 52 ++-- internal/data/operations_test.go | 10 +- internal/data/statechanges.go | 41 ++- internal/data/statechanges_test.go | 10 +- internal/data/transactions.go | 53 ++-- internal/data/transactions_test.go | 12 +- internal/indexer/types/types.go | 22 +- .../graphql/dataloaders/account_loaders.go | 66 +++++ internal/serve/graphql/dataloaders/loaders.go | 253 +++--------------- .../graphql/dataloaders/operation_loaders.go | 92 +++++++ .../dataloaders/statechange_loaders.go | 90 +++++++ .../dataloaders/transaction_loaders.go | 92 +++++++ .../graphql/resolvers/account.resolvers.go | 24 +- .../resolvers/account_resolvers_test.go | 63 +++-- .../graphql/resolvers/operation.resolvers.go | 29 +- ...rs_test.go => operation_resolvers_test.go} | 30 +-- .../graphql/resolvers/queries.resolvers.go | 13 +- .../resolvers/queries_resolvers_test.go | 7 + .../resolvers/statechange.resolvers.go | 19 +- .../resolvers/statechange_resolvers_test.go | 20 +- .../resolvers/transaction.resolvers.go | 24 +- .../resolvers/transaction_resolvers_test.go | 36 +-- internal/serve/graphql/resolvers/utils.go | 52 ++++ 25 files changed, 758 insertions(+), 378 deletions(-) create mode 100644 internal/serve/graphql/dataloaders/account_loaders.go create mode 100644 internal/serve/graphql/dataloaders/operation_loaders.go create mode 100644 internal/serve/graphql/dataloaders/statechange_loaders.go create mode 100644 internal/serve/graphql/dataloaders/transaction_loaders.go rename internal/serve/graphql/resolvers/{operation.resolvers_test.go => operation_resolvers_test.go} (67%) create mode 100644 internal/serve/graphql/resolvers/utils.go diff --git a/internal/data/accounts.go b/internal/data/accounts.go index 61b0f53f..86782b5d 100644 --- a/internal/data/accounts.go +++ b/internal/data/accounts.go @@ -106,13 +106,16 @@ func (m *AccountModel) IsAccountFeeBumpEligible(ctx context.Context, address str } // BatchGetByTxHashes gets the accounts that are associated with the given transaction hashes. -func (m *AccountModel) BatchGetByTxHashes(ctx context.Context, txHashes []string) ([]*types.AccountWithTxHash, error) { - const query = ` - SELECT accounts.*, transactions_accounts.tx_hash +func (m *AccountModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string) ([]*types.AccountWithTxHash, error) { + if columns == "" { + columns = "accounts.*" + } + query := fmt.Sprintf(` + SELECT %s, transactions_accounts.tx_hash FROM transactions_accounts INNER JOIN accounts ON transactions_accounts.account_id = accounts.stellar_address - WHERE transactions_accounts.tx_hash = ANY($1)` + WHERE transactions_accounts.tx_hash = ANY($1)`, columns) var accounts []*types.AccountWithTxHash start := time.Now() err := m.DB.SelectContext(ctx, &accounts, query, pq.Array(txHashes)) @@ -126,13 +129,16 @@ func (m *AccountModel) BatchGetByTxHashes(ctx context.Context, txHashes []string } // BatchGetByOperationIDs gets the accounts that are associated with the given operation IDs. -func (m *AccountModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64) ([]*types.AccountWithOperationID, error) { - const query = ` - SELECT accounts.*, operations_accounts.operation_id +func (m *AccountModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string) ([]*types.AccountWithOperationID, error) { + if columns == "" { + columns = "accounts.*" + } + query := fmt.Sprintf(` + SELECT %s, operations_accounts.operation_id FROM operations_accounts INNER JOIN accounts ON operations_accounts.account_id = accounts.stellar_address - WHERE operations_accounts.operation_id = ANY($1)` + WHERE operations_accounts.operation_id = ANY($1)`, columns) var accounts []*types.AccountWithOperationID start := time.Now() err := m.DB.SelectContext(ctx, &accounts, query, pq.Array(operationIDs)) diff --git a/internal/data/accounts_test.go b/internal/data/accounts_test.go index e0a4a348..046f8e9f 100644 --- a/internal/data/accounts_test.go +++ b/internal/data/accounts_test.go @@ -193,7 +193,7 @@ func TestAccountModelBatchGetByTxHashes(t *testing.T) { require.NoError(t, err) // Test BatchGetByTxHash function - accounts, err := m.BatchGetByTxHashes(ctx, []string{txHash1, txHash2}) + accounts, err := m.BatchGetByTxHashes(ctx, []string{txHash1, txHash2}, "") require.NoError(t, err) assert.Len(t, accounts, 2) @@ -246,7 +246,7 @@ func TestAccountModelBatchGetByOperationIDs(t *testing.T) { require.NoError(t, err) // Test BatchGetByOperationID function - accounts, err := m.BatchGetByOperationIDs(ctx, []int64{operationID1, operationID2}) + accounts, err := m.BatchGetByOperationIDs(ctx, []int64{operationID1, operationID2}, "") require.NoError(t, err) assert.Len(t, accounts, 2) diff --git a/internal/data/operations.go b/internal/data/operations.go index 3ffc6835..3bae9758 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -18,8 +18,11 @@ type OperationModel struct { MetricsService metrics.MetricsService } -func (m *OperationModel) GetAll(ctx context.Context, limit *int32) ([]*types.Operation, error) { - query := `SELECT * FROM operations ORDER BY ledger_created_at DESC` +func (m *OperationModel) GetAll(ctx context.Context, limit *int32, columns string) ([]*types.Operation, error) { + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(`SELECT %s FROM operations ORDER BY ledger_created_at DESC`, columns) var args []interface{} if limit != nil && *limit > 0 { query += ` LIMIT $1` @@ -38,8 +41,11 @@ func (m *OperationModel) GetAll(ctx context.Context, limit *int32) ([]*types.Ope } // BatchGetByTxHashes gets the operations that are associated with the given transaction hashes. -func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []string) ([]*types.Operation, error) { - const query = `SELECT * FROM operations WHERE tx_hash = ANY($1)` +func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string) ([]*types.Operation, error) { + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(`SELECT %s, tx_hash FROM operations WHERE tx_hash = ANY($1)`, columns) var operations []*types.Operation start := time.Now() err := m.DB.SelectContext(ctx, &operations, query, pq.Array(txHashes)) @@ -53,14 +59,17 @@ func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []stri } // BatchGetByAccountAddresses gets the operations that are associated with the given account addresses. -func (m *OperationModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string) ([]*types.OperationWithAccountID, error) { - const query = ` - SELECT o.*, oa.account_id - FROM operations o - INNER JOIN operations_accounts oa ON o.id = oa.operation_id - WHERE oa.account_id = ANY($1) - ORDER BY o.ledger_created_at DESC - ` +func (m *OperationModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string, columns string) ([]*types.OperationWithAccountID, error) { + if columns == "" { + columns = "operations.*" + } + query := fmt.Sprintf(` + SELECT %s, operations_accounts.account_id + FROM operations + INNER JOIN operations_accounts ON operations.id = operations_accounts.operation_id + WHERE operations_accounts.account_id = ANY($1) + ORDER BY operations.ledger_created_at DESC + `, columns) var operationsWithAccounts []*types.OperationWithAccountID start := time.Now() @@ -76,14 +85,17 @@ func (m *OperationModel) BatchGetByAccountAddresses(ctx context.Context, account } // BatchGetByStateChangeIDs gets the operations that are associated with the given state change IDs. -func (m *OperationModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string) ([]*types.OperationWithStateChangeID, error) { - const query = ` - SELECT o.*, sc.id as state_change_id - FROM operations o - INNER JOIN state_changes sc ON o.id = sc.operation_id - WHERE sc.id = ANY($1) - ORDER BY o.ledger_created_at DESC - ` +func (m *OperationModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string, columns string) ([]*types.OperationWithStateChangeID, error) { + if columns == "" { + columns = "operations.*" + } + query := fmt.Sprintf(` + SELECT %s, state_changes.id AS state_change_id + FROM operations + INNER JOIN state_changes ON operations.id = state_changes.operation_id + WHERE state_changes.id = ANY($1) + ORDER BY operations.ledger_created_at DESC + `, columns) var operationsWithStateChanges []*types.OperationWithStateChangeID start := time.Now() err := m.DB.SelectContext(ctx, &operationsWithStateChanges, query, pq.Array(stateChangeIDs)) diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index 34afddc9..a1d4edca 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -257,13 +257,13 @@ func TestOperationModel_GetAll(t *testing.T) { require.NoError(t, err) // Test GetAll without limit - operations, err := m.GetAll(ctx, nil) + operations, err := m.GetAll(ctx, nil, "") require.NoError(t, err) assert.Len(t, operations, 3) // Test GetAll with limit limit := int32(2) - operations, err = m.GetAll(ctx, &limit) + operations, err = m.GetAll(ctx, &limit, "") require.NoError(t, err) assert.Len(t, operations, 2) } @@ -308,7 +308,7 @@ func TestOperationModel_BatchGetByTxHashes(t *testing.T) { require.NoError(t, err) // Test BatchGetByTxHash - operations, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}) + operations, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}, "") require.NoError(t, err) assert.Len(t, operations, 3) @@ -378,7 +378,7 @@ func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { require.NoError(t, err) // Test BatchGetByAccount - operations, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}) + operations, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}, "") require.NoError(t, err) assert.Len(t, operations, 3) @@ -447,7 +447,7 @@ func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { require.NoError(t, err) // Test BatchGetByStateChangeID - operations, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}) + operations, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}, "") require.NoError(t, err) assert.Len(t, operations, 3) diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index a4714294..6f7dc4ce 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -21,10 +21,14 @@ type StateChangeModel struct { func (m *StateChangeModel) BatchGetByAccountAddresses( ctx context.Context, accountAddresses []string, + columns string, ) ([]*types.StateChange, error) { - query := ` - SELECT * FROM state_changes WHERE account_id = ANY($1) - ` + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(` + SELECT %s FROM state_changes WHERE account_id = ANY($1) + `, columns) var stateChanges []*types.StateChange err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(accountAddresses)) if err != nil { @@ -33,15 +37,18 @@ func (m *StateChangeModel) BatchGetByAccountAddresses( return stateChanges, nil } -func (m *StateChangeModel) GetAll(ctx context.Context, limit *int32) ([]*types.StateChange, error) { - start := time.Now() - query := `SELECT * FROM state_changes ORDER BY ledger_created_at DESC` +func (m *StateChangeModel) GetAll(ctx context.Context, limit *int32, columns string) ([]*types.StateChange, error) { + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(`SELECT %s FROM state_changes ORDER BY ledger_created_at DESC`, columns) var args []interface{} if limit != nil && *limit > 0 { query += ` LIMIT $1` args = append(args, *limit) } var stateChanges []*types.StateChange + start := time.Now() err := m.DB.SelectContext(ctx, &stateChanges, query, args...) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) @@ -236,10 +243,13 @@ func (m *StateChangeModel) BatchInsert( } // BatchGetByTxHashes gets the state changes that are associated with the given transaction hashes. -func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []string) ([]*types.StateChange, error) { - const query = ` - SELECT * FROM state_changes WHERE tx_hash = ANY($1) - ` +func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string) ([]*types.StateChange, error) { + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(` + SELECT %s, tx_hash FROM state_changes WHERE tx_hash = ANY($1) + `, columns) var stateChanges []*types.StateChange start := time.Now() err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(txHashes)) @@ -253,10 +263,13 @@ func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []st } // BatchGetByOperationIDs gets the state changes that are associated with the given operation IDs. -func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64) ([]*types.StateChange, error) { - const query = ` - SELECT * FROM state_changes WHERE operation_id = ANY($1) - ` +func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string) ([]*types.StateChange, error) { + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(` + SELECT %s, operation_id FROM state_changes WHERE operation_id = ANY($1) + `, columns) var stateChanges []*types.StateChange start := time.Now() err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(operationIDs)) diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index a6ba29a1..105c45e8 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -238,7 +238,7 @@ func TestStateChangeModel_BatchGetByAccountAddresses(t *testing.T) { } // Test BatchGetByAccount - stateChanges, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}) + stateChanges, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}, "") require.NoError(t, err) assert.Len(t, stateChanges, 3) @@ -297,13 +297,13 @@ func TestStateChangeModel_GetAll(t *testing.T) { require.NoError(t, err) // Test GetAll without limit - stateChanges, err := m.GetAll(ctx, nil) + stateChanges, err := m.GetAll(ctx, nil, "") require.NoError(t, err) assert.Len(t, stateChanges, 3) // Test GetAll with limit limit := int32(2) - stateChanges, err = m.GetAll(ctx, &limit) + stateChanges, err = m.GetAll(ctx, &limit, "") require.NoError(t, err) assert.Len(t, stateChanges, 2) } @@ -353,7 +353,7 @@ func TestStateChangeModel_BatchGetByTxHashes(t *testing.T) { require.NoError(t, err) // Test BatchGetByTxHash - stateChanges, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}) + stateChanges, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}, "") require.NoError(t, err) assert.Len(t, stateChanges, 3) @@ -412,7 +412,7 @@ func TestStateChangeModel_BatchGetByOperationIDs(t *testing.T) { require.NoError(t, err) // Test BatchGetByOperationID - stateChanges, err := m.BatchGetByOperationIDs(ctx, []int64{123, 456}) + stateChanges, err := m.BatchGetByOperationIDs(ctx, []int64{123, 456}, "") require.NoError(t, err) assert.Len(t, stateChanges, 3) diff --git a/internal/data/transactions.go b/internal/data/transactions.go index 4a27be90..4e61814e 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -18,8 +18,11 @@ type TransactionModel struct { MetricsService metrics.MetricsService } -func (m *TransactionModel) GetByHash(ctx context.Context, hash string) (*types.Transaction, error) { - const query = `SELECT * FROM transactions WHERE hash = $1` +func (m *TransactionModel) GetByHash(ctx context.Context, hash string, columns string) (*types.Transaction, error) { + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(`SELECT %s FROM transactions WHERE hash = $1`, columns) var transaction types.Transaction start := time.Now() err := m.DB.GetContext(ctx, &transaction, query, hash) @@ -32,8 +35,11 @@ func (m *TransactionModel) GetByHash(ctx context.Context, hash string) (*types.T return &transaction, nil } -func (m *TransactionModel) GetAll(ctx context.Context, limit *int32) ([]*types.Transaction, error) { - query := `SELECT * FROM transactions ORDER BY ledger_created_at DESC` +func (m *TransactionModel) GetAll(ctx context.Context, limit *int32, columns string) ([]*types.Transaction, error) { + if columns == "" { + columns = "*" + } + query := fmt.Sprintf(`SELECT %s FROM transactions ORDER BY ledger_created_at DESC`, columns) args := []interface{}{} if limit != nil && *limit > 0 { @@ -54,13 +60,16 @@ func (m *TransactionModel) GetAll(ctx context.Context, limit *int32) ([]*types.T } // BatchGetByAccountAddresses gets the transactions that are associated with the given account addresses. -func (m *TransactionModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string) ([]*types.TransactionWithAccountID, error) { - const query = ` - SELECT transactions.*, transactions_accounts.account_id +func (m *TransactionModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string, columns string) ([]*types.TransactionWithAccountID, error) { + if columns == "" { + columns = "transactions.*" + } + query := fmt.Sprintf(` + SELECT %s, transactions_accounts.account_id FROM transactions_accounts INNER JOIN transactions ON transactions_accounts.tx_hash = transactions.hash - WHERE transactions_accounts.account_id = ANY($1)` + WHERE transactions_accounts.account_id = ANY($1)`, columns) var transactions []*types.TransactionWithAccountID start := time.Now() err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(accountAddresses)) @@ -74,13 +83,16 @@ func (m *TransactionModel) BatchGetByAccountAddresses(ctx context.Context, accou } // BatchGetByOperationIDs gets the transactions that are associated with the given operation IDs. -func (m *TransactionModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64) ([]*types.TransactionWithOperationID, error) { - const query = ` - SELECT t.*, o.id as operation_id +func (m *TransactionModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string) ([]*types.TransactionWithOperationID, error) { + if columns == "" { + columns = "transactions.*" + } + query := fmt.Sprintf(` + SELECT %s, o.id as operation_id FROM operations o - INNER JOIN transactions t - ON o.tx_hash = t.hash - WHERE o.id = ANY($1)` + INNER JOIN transactions + ON o.tx_hash = transactions.hash + WHERE o.id = ANY($1)`, columns) var transactions []*types.TransactionWithOperationID start := time.Now() err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(operationIDs)) @@ -94,13 +106,16 @@ func (m *TransactionModel) BatchGetByOperationIDs(ctx context.Context, operation } // BatchGetByStateChangeIDs gets the transactions that are associated with the given state changes -func (m *TransactionModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string) ([]*types.TransactionWithStateChangeID, error) { - const query = ` - SELECT t.*, sc.id as state_change_id +func (m *TransactionModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string, columns string) ([]*types.TransactionWithStateChangeID, error) { + if columns == "" { + columns = "transactions.*" + } + query := fmt.Sprintf(` + SELECT %s, sc.id as state_change_id FROM state_changes sc - INNER JOIN transactions t ON t.hash = sc.tx_hash + INNER JOIN transactions ON transactions.hash = sc.tx_hash WHERE sc.id = ANY($1) - ` + `, columns) var transactions []*types.TransactionWithStateChangeID start := time.Now() err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(stateChangeIDs)) diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index f5debf6a..a7d7d7fb 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -219,7 +219,7 @@ func TestTransactionModel_GetByHash(t *testing.T) { require.NoError(t, err) // Test GetByHash - transaction, err := m.GetByHash(ctx, txHash) + transaction, err := m.GetByHash(ctx, txHash, "") require.NoError(t, err) assert.Equal(t, txHash, transaction.Hash) assert.Equal(t, int64(1), transaction.ToID) @@ -257,13 +257,13 @@ func TestTransactionModel_GetAll(t *testing.T) { require.NoError(t, err) // Test GetAll without limit - transactions, err := m.GetAll(ctx, nil) + transactions, err := m.GetAll(ctx, nil, "") require.NoError(t, err) assert.Len(t, transactions, 3) // Test GetAll with limit limit := int32(2) - transactions, err = m.GetAll(ctx, &limit) + transactions, err = m.GetAll(ctx, &limit, "") require.NoError(t, err) assert.Len(t, transactions, 2) } @@ -315,7 +315,7 @@ func TestTransactionModel_BatchGetByAccountAddresses(t *testing.T) { require.NoError(t, err) // Test BatchGetByAccount - transactions, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}) + transactions, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}, "") require.NoError(t, err) assert.Len(t, transactions, 3) @@ -369,7 +369,7 @@ func TestTransactionModel_BatchGetByOperationIDs(t *testing.T) { require.NoError(t, err) // Test BatchGetByOperationID - transactions, err := m.BatchGetByOperationIDs(ctx, []int64{1, 2, 3}) + transactions, err := m.BatchGetByOperationIDs(ctx, []int64{1, 2, 3}, "") require.NoError(t, err) assert.Len(t, transactions, 3) @@ -429,7 +429,7 @@ func TestTransactionModel_BatchGetByStateChangeIDs(t *testing.T) { require.NoError(t, err) // Test BatchGetByStateChangeID - transactions, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}) + transactions, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}, "") require.NoError(t, err) assert.Len(t, transactions, 3) diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index 0df9d846..b6377774 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -11,7 +11,7 @@ import ( ) type Account struct { - StellarAddress string `json:"stellarAddress,omitempty" db:"stellar_address"` + StellarAddress string `json:"address,omitempty" db:"stellar_address"` CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` } @@ -27,7 +27,7 @@ type AccountWithOperationID struct { type Transaction struct { Hash string `json:"hash,omitempty" db:"hash"` - ToID int64 `json:"to_id,omitempty" db:"to_id"` + ToID int64 `json:"toId,omitempty" db:"to_id"` EnvelopeXDR string `json:"envelopeXdr,omitempty" db:"envelope_xdr"` ResultXDR string `json:"resultXdr,omitempty" db:"result_xdr"` MetaXDR string `json:"metaXdr,omitempty" db:"meta_xdr"` @@ -35,9 +35,9 @@ type Transaction struct { LedgerCreatedAt time.Time `json:"ledgerCreatedAt,omitempty" db:"ledger_created_at"` IngestedAt time.Time `json:"ingestedAt,omitempty" db:"ingested_at"` // Relationships: - Operations []Operation `json:"operations,omitempty" db:"operations"` - Accounts []Account `json:"accounts,omitempty" db:"accounts"` - StateChanges []StateChange `json:"stateChanges,omitempty" db:"state_changes"` + Operations []Operation `json:"operations,omitempty"` + Accounts []Account `json:"accounts,omitempty"` + StateChanges []StateChange `json:"stateChanges,omitempty"` } type TransactionWithAccountID struct { @@ -134,9 +134,9 @@ type Operation struct { IngestedAt time.Time `json:"ingestedAt,omitempty" db:"ingested_at"` // Relationships: TxHash string `json:"txHash,omitempty" db:"tx_hash"` - Transaction *Transaction `json:"transaction,omitempty" db:"transaction"` - Accounts []Account `json:"accounts,omitempty" db:"accounts"` - StateChanges []StateChange `json:"stateChanges,omitempty" db:"state_changes"` + Transaction *Transaction `json:"transaction,omitempty"` + Accounts []Account `json:"accounts,omitempty"` + StateChanges []StateChange `json:"stateChanges,omitempty"` } type OperationWithAccountID struct { @@ -213,11 +213,11 @@ type StateChange struct { KeyValue NullableJSONB `json:"keyValue,omitempty" db:"key_value"` // Relationships: AccountID string `json:"accountId,omitempty" db:"account_id"` - Account *Account `json:"account,omitempty" db:"account"` + Account *Account `json:"account,omitempty"` OperationID int64 `json:"operationId,omitempty" db:"operation_id"` - Operation *Operation `json:"operation,omitempty" db:"operation"` + Operation *Operation `json:"operation,omitempty"` TxHash string `json:"txHash,omitempty" db:"tx_hash"` - Transaction *Transaction `json:"transaction,omitempty" db:"transaction"` + Transaction *Transaction `json:"transaction,omitempty"` } type NullableJSONB map[string]any diff --git a/internal/serve/graphql/dataloaders/account_loaders.go b/internal/serve/graphql/dataloaders/account_loaders.go new file mode 100644 index 00000000..3ee69c21 --- /dev/null +++ b/internal/serve/graphql/dataloaders/account_loaders.go @@ -0,0 +1,66 @@ +package dataloaders + +import ( + "context" + + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +type AccountColumnsKey struct { + TxHash string + OperationID int64 + Columns string +} + +// accountsByTxHashLoader creates a dataloader for fetching accounts by transaction hash +// This prevents N+1 queries when multiple transactions request their accounts +// The loader batches multiple transaction hashes into a single database query +func accountsByTxHashLoader(models *data.Models) *dataloadgen.Loader[AccountColumnsKey, []*types.Account] { + return newOneToManyLoader( + func(ctx context.Context, keys []AccountColumnsKey) ([]*types.AccountWithTxHash, error) { + txHashes := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + txHashes[i] = key.TxHash + } + return models.Account.BatchGetByTxHashes(ctx, txHashes, columns) + }, + func(item *types.AccountWithTxHash) string { + return item.TxHash + }, + func(key AccountColumnsKey) string { + return key.TxHash + }, + func(item *types.AccountWithTxHash) types.Account { + return item.Account + }, + ) +} + +// accountsByOperationIDLoader creates a dataloader for fetching accounts by operation ID +// This prevents N+1 queries when multiple operations request their accounts +// The loader batches multiple operation IDs into a single database query +func accountsByOperationIDLoader(models *data.Models) *dataloadgen.Loader[AccountColumnsKey, []*types.Account] { + return newOneToManyLoader( + func(ctx context.Context, keys []AccountColumnsKey) ([]*types.AccountWithOperationID, error) { + operationIDs := make([]int64, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + operationIDs[i] = key.OperationID + } + return models.Account.BatchGetByOperationIDs(ctx, operationIDs, columns) + }, + func(item *types.AccountWithOperationID) int64 { + return item.OperationID + }, + func(key AccountColumnsKey) int64 { + return key.OperationID + }, + func(item *types.AccountWithOperationID) types.Account { + return item.Account + }, + ) +} diff --git a/internal/serve/graphql/dataloaders/loaders.go b/internal/serve/graphql/dataloaders/loaders.go index 4f71cd7a..1363668c 100644 --- a/internal/serve/graphql/dataloaders/loaders.go +++ b/internal/serve/graphql/dataloaders/loaders.go @@ -19,47 +19,47 @@ import ( type Dataloaders struct { // OperationsByTxHashLoader batches requests for operations by transaction hash // Used by Transaction.operations field resolver to prevent N+1 queries - OperationsByTxHashLoader *dataloadgen.Loader[string, []*types.Operation] + OperationsByTxHashLoader *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] // TransactionsByAccountLoader batches requests for transactions by account address // Used by Account.transactions field resolver to prevent N+1 queries - TransactionsByAccountLoader *dataloadgen.Loader[string, []*types.Transaction] + TransactionsByAccountLoader *dataloadgen.Loader[TransactionColumnsKey, []*types.Transaction] // OperationsByAccountLoader batches requests for operations by account address // Used by Account.operations field resolver to prevent N+1 queries - OperationsByAccountLoader *dataloadgen.Loader[string, []*types.Operation] + OperationsByAccountLoader *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] // StateChangesByAccountLoader batches requests for state changes by account address // Used by Account.statechanges field resolver to prevent N+1 queries - StateChangesByAccountLoader *dataloadgen.Loader[string, []*types.StateChange] + StateChangesByAccountLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] // AccountsByTxHashLoader batches requests for accounts by transaction hash // Used by Transaction.accounts field resolver to prevent N+1 queries - AccountsByTxHashLoader *dataloadgen.Loader[string, []*types.Account] + AccountsByTxHashLoader *dataloadgen.Loader[AccountColumnsKey, []*types.Account] // StateChangesByTxHashLoader batches requests for state changes by transaction hash // Used by Transaction.stateChanges field resolver to prevent N+1 queries - StateChangesByTxHashLoader *dataloadgen.Loader[string, []*types.StateChange] + StateChangesByTxHashLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] // TransactionsByOperationIDLoader batches requests for transactions by operation ID // Used by Operation.transaction field resolver to prevent N+1 queries - TransactionsByOperationIDLoader *dataloadgen.Loader[int64, *types.Transaction] + TransactionsByOperationIDLoader *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] // AccountsByOperationIDLoader batches requests for accounts by operation ID // Used by Operation.accounts field resolver to prevent N+1 queries - AccountsByOperationIDLoader *dataloadgen.Loader[int64, []*types.Account] + AccountsByOperationIDLoader *dataloadgen.Loader[AccountColumnsKey, []*types.Account] // StateChangesByOperationIDLoader batches requests for state changes by operation ID // Used by Operation.stateChanges field resolver to prevent N+1 queries - StateChangesByOperationIDLoader *dataloadgen.Loader[int64, []*types.StateChange] + StateChangesByOperationIDLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] // OperationByStateChangeIDLoader batches requests for operations by state change ID // Used by StateChange.operation field resolver to prevent N+1 queries - OperationByStateChangeIDLoader *dataloadgen.Loader[string, *types.Operation] + OperationByStateChangeIDLoader *dataloadgen.Loader[OperationColumnsKey, *types.Operation] // TransactionByStateChangeIDLoader batches requests for transactions by state change ID // Used by StateChange.transaction field resolver to prevent N+1 queries - TransactionByStateChangeIDLoader *dataloadgen.Loader[string, *types.Transaction] + TransactionByStateChangeIDLoader *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] } // NewDataloaders creates a new instance of all dataloaders @@ -69,16 +69,16 @@ type Dataloaders struct { func NewDataloaders(models *data.Models) *Dataloaders { return &Dataloaders{ OperationsByTxHashLoader: operationsByTxHashLoader(models), - TransactionsByAccountLoader: transactionsByAccountLoader(models), OperationsByAccountLoader: operationsByAccountLoader(models), + OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models), + TransactionsByAccountLoader: transactionsByAccountLoader(models), + TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models), + TransactionsByOperationIDLoader: transactionByOperationIDLoader(models), StateChangesByAccountLoader: stateChangesByAccountLoader(models), - AccountsByTxHashLoader: accountsByTxHashLoader(models), StateChangesByTxHashLoader: stateChangesByTxHashLoader(models), - TransactionsByOperationIDLoader: txByOperationIDLoader(models), - AccountsByOperationIDLoader: accountsByOperationIDLoader(models), StateChangesByOperationIDLoader: stateChangesByOperationIDLoader(models), - OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models), - TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models), + AccountsByTxHashLoader: accountsByTxHashLoader(models), + AccountsByOperationIDLoader: accountsByOperationIDLoader(models), } } @@ -92,9 +92,10 @@ func NewDataloaders(models *data.Models) *Dataloaders { // // Returns: // - A configured dataloadgen.Loader for one-to-many relationships. -func newOneToManyLoader[K comparable, V any, T any]( +func newOneToManyLoader[K comparable, PK comparable, V any, T any]( fetcher func(ctx context.Context, keys []K) ([]T, error), - getKey func(item T) K, + getPKFromItem func(item T) PK, + getPKFromKey func(key K) PK, transform func(item T) V, ) *dataloadgen.Loader[K, []*V] { return dataloadgen.NewLoader( @@ -110,16 +111,25 @@ func newOneToManyLoader[K comparable, V any, T any]( return nil, errors } - itemsByKey := make(map[K][]*V) + // An item is the actual data from the database that we want to return. + // The key contains the primary key and the set of columns to return. + // + // For e.g. if we want to get all operations for a transaction, the key will + // be the a struct containing the transaction hash and the columns to return. + // The items will be a slice of operations which will be grouped by the primary key, which is the transaction hash. + // We can do this by creating a map with the primary key as the key and the items as the value. + // We can then return the items in the order of the keys. + itemsByPK := make(map[PK][]*V) for _, item := range items { - key := getKey(item) + key := getPKFromItem(item) transformedItem := transform(item) - itemsByKey[key] = append(itemsByKey[key], &transformedItem) + itemsByPK[key] = append(itemsByPK[key], &transformedItem) } + // Build result in the order of the keys. result := make([][]*V, len(keys)) for i, key := range keys { - result[i] = itemsByKey[key] + result[i] = itemsByPK[getPKFromKey(key)] } return result, nil @@ -141,9 +151,10 @@ func newOneToManyLoader[K comparable, V any, T any]( // // Returns: // - A configured dataloadgen.Loader for one-to-one relationships. -func newOneToOneLoader[K comparable, V any, T any]( +func newOneToOneLoader[K comparable, PK comparable, V any, T any]( fetcher func(ctx context.Context, keys []K) ([]T, error), - getKey func(item T) K, + getPKFromItem func(item T) PK, + getPKFromKey func(key K) PK, transform func(item T) V, ) *dataloadgen.Loader[K, *V] { return dataloadgen.NewLoader( @@ -157,15 +168,16 @@ func newOneToOneLoader[K comparable, V any, T any]( return nil, errors } - itemsByKey := make(map[K]*V) + itemsByKey := make(map[PK]*V) for _, item := range items { - key := getKey(item) + key := getPKFromItem(item) transformedItem := transform(item) itemsByKey[key] = &transformedItem } + result := make([]*V, len(keys)) for i, key := range keys { - result[i] = itemsByKey[key] + result[i] = itemsByKey[getPKFromKey(key)] } return result, nil @@ -174,188 +186,3 @@ func newOneToOneLoader[K comparable, V any, T any]( dataloadgen.WithWait(5*time.Millisecond), ) } - -// opByTxHashLoader creates a dataloader for fetching operations by transaction hash -// This prevents N+1 queries when multiple transactions request their operations -// The loader batches multiple transaction hashes into a single database query -func operationsByTxHashLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Operation] { - return newOneToManyLoader( - func(ctx context.Context, keys []string) ([]*types.Operation, error) { - return models.Operations.BatchGetByTxHashes(ctx, keys) - }, - func(item *types.Operation) string { - return item.TxHash - }, - func(item *types.Operation) types.Operation { - return *item - }, - ) -} - -// txByAccountLoader creates a dataloader for fetching transactions by account address -// This prevents N+1 queries when multiple accounts request their transactions -// The loader batches multiple account addresses into a single database query -func transactionsByAccountLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Transaction] { - return newOneToManyLoader( - func(ctx context.Context, keys []string) ([]*types.TransactionWithAccountID, error) { - return models.Transactions.BatchGetByAccountAddresses(ctx, keys) - }, - func(item *types.TransactionWithAccountID) string { - return item.AccountID - }, - func(item *types.TransactionWithAccountID) types.Transaction { - return item.Transaction - }, - ) -} - -// opByAccountLoader creates a dataloader for fetching operations by account address -// This prevents N+1 queries when multiple accounts request their operations -// The loader batches multiple account addresses into a single database query -func operationsByAccountLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Operation] { - return newOneToManyLoader( - func(ctx context.Context, keys []string) ([]*types.OperationWithAccountID, error) { - return models.Operations.BatchGetByAccountAddresses(ctx, keys) - }, - func(item *types.OperationWithAccountID) string { - return item.AccountID - }, - func(item *types.OperationWithAccountID) types.Operation { - return item.Operation - }, - ) -} - -// stateChangesByAccountLoader creates a dataloader for fetching state changes by account address -func stateChangesByAccountLoader(models *data.Models) *dataloadgen.Loader[string, []*types.StateChange] { - return newOneToManyLoader( - func(ctx context.Context, keys []string) ([]*types.StateChange, error) { - return models.StateChanges.BatchGetByAccountAddresses(ctx, keys) - }, - func(item *types.StateChange) string { - return item.AccountID - }, - func(item *types.StateChange) types.StateChange { - return *item - }, - ) -} - -// accountsByTxHashLoader creates a dataloader for fetching accounts by transaction hash -// This prevents N+1 queries when multiple transactions request their accounts -// The loader batches multiple transaction hashes into a single database query -func accountsByTxHashLoader(models *data.Models) *dataloadgen.Loader[string, []*types.Account] { - return newOneToManyLoader( - func(ctx context.Context, keys []string) ([]*types.AccountWithTxHash, error) { - return models.Account.BatchGetByTxHashes(ctx, keys) - }, - func(item *types.AccountWithTxHash) string { - return item.TxHash - }, - func(item *types.AccountWithTxHash) types.Account { - return item.Account - }, - ) -} - -// stateChangesByTxHashLoader creates a dataloader for fetching state changes by transaction hash -// This prevents N+1 queries when multiple transactions request their state changes -// The loader batches multiple transaction hashes into a single database query -func stateChangesByTxHashLoader(models *data.Models) *dataloadgen.Loader[string, []*types.StateChange] { - return newOneToManyLoader( - func(ctx context.Context, keys []string) ([]*types.StateChange, error) { - return models.StateChanges.BatchGetByTxHashes(ctx, keys) - }, - func(item *types.StateChange) string { - return item.TxHash - }, - func(item *types.StateChange) types.StateChange { - return *item - }, - ) -} - -// txByOperationIDLoader creates a dataloader for fetching transactions by operation ID -// This prevents N+1 queries when multiple operations request their transaction -// The loader batches multiple operation IDs into a single database query -func txByOperationIDLoader(models *data.Models) *dataloadgen.Loader[int64, *types.Transaction] { - return newOneToOneLoader( - func(ctx context.Context, keys []int64) ([]*types.TransactionWithOperationID, error) { - return models.Transactions.BatchGetByOperationIDs(ctx, keys) - }, - func(item *types.TransactionWithOperationID) int64 { - return item.OperationID - }, - func(item *types.TransactionWithOperationID) types.Transaction { - return item.Transaction - }, - ) -} - -// accountsByOperationIDLoader creates a dataloader for fetching accounts by operation ID -// This prevents N+1 queries when multiple operations request their accounts -// The loader batches multiple operation IDs into a single database query -func accountsByOperationIDLoader(models *data.Models) *dataloadgen.Loader[int64, []*types.Account] { - return newOneToManyLoader( - func(ctx context.Context, keys []int64) ([]*types.AccountWithOperationID, error) { - return models.Account.BatchGetByOperationIDs(ctx, keys) - }, - func(item *types.AccountWithOperationID) int64 { - return item.OperationID - }, - func(item *types.AccountWithOperationID) types.Account { - return item.Account - }, - ) -} - -// stateChangesByOperationIDLoader creates a dataloader for fetching state changes by operation ID -// This prevents N+1 queries when multiple operations request their state changes -// The loader batches multiple operation IDs into a single database query -func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[int64, []*types.StateChange] { - return newOneToManyLoader( - func(ctx context.Context, keys []int64) ([]*types.StateChange, error) { - return models.StateChanges.BatchGetByOperationIDs(ctx, keys) - }, - func(item *types.StateChange) int64 { - return item.OperationID - }, - func(item *types.StateChange) types.StateChange { - return *item - }, - ) -} - -// operationByStateChangeIDLoader creates a dataloader for fetching operations by state change ID -// This prevents N+1 queries when multiple state changes request their operations -// The loader batches multiple state change IDs into a single database query -func operationByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[string, *types.Operation] { - return newOneToOneLoader( - func(ctx context.Context, keys []string) ([]*types.OperationWithStateChangeID, error) { - return models.Operations.BatchGetByStateChangeIDs(ctx, keys) - }, - func(item *types.OperationWithStateChangeID) string { - return item.StateChangeID - }, - func(item *types.OperationWithStateChangeID) types.Operation { - return item.Operation - }, - ) -} - -// transactionByStateChangeIDLoader creates a dataloader for fetching transactions by state change ID -// This prevents N+1 queries when multiple state changes request their transactions -// The loader batches multiple state change IDs into a single database query -func transactionByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[string, *types.Transaction] { - return newOneToOneLoader( - func(ctx context.Context, keys []string) ([]*types.TransactionWithStateChangeID, error) { - return models.Transactions.BatchGetByStateChangeIDs(ctx, keys) - }, - func(item *types.TransactionWithStateChangeID) string { - return item.StateChangeID - }, - func(item *types.TransactionWithStateChangeID) types.Transaction { - return item.Transaction - }, - ) -} diff --git a/internal/serve/graphql/dataloaders/operation_loaders.go b/internal/serve/graphql/dataloaders/operation_loaders.go new file mode 100644 index 00000000..4e3c74ec --- /dev/null +++ b/internal/serve/graphql/dataloaders/operation_loaders.go @@ -0,0 +1,92 @@ +package dataloaders + +import ( + "context" + + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +type OperationColumnsKey struct { + TxHash string + AccountID string + StateChangeID string + Columns string +} + +// opByTxHashLoader creates a dataloader for fetching operations by transaction hash +// This prevents N+1 queries when multiple transactions request their operations +// The loader batches multiple transaction hashes into a single database query +func operationsByTxHashLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { + return newOneToManyLoader( + func(ctx context.Context, keys []OperationColumnsKey) ([]*types.Operation, error) { + txHashes := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + txHashes[i] = key.TxHash + } + return models.Operations.BatchGetByTxHashes(ctx, txHashes, columns) + }, + func(item *types.Operation) string { + return item.TxHash + }, + func(key OperationColumnsKey) string { + return key.TxHash + }, + func(item *types.Operation) types.Operation { + return *item + }, + ) +} + +// opByAccountLoader creates a dataloader for fetching operations by account address +// This prevents N+1 queries when multiple accounts request their operations +// The loader batches multiple account addresses into a single database query +func operationsByAccountLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { + return newOneToManyLoader( + func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithAccountID, error) { + accountIDs := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + accountIDs[i] = key.AccountID + } + return models.Operations.BatchGetByAccountAddresses(ctx, accountIDs, columns) + }, + func(item *types.OperationWithAccountID) string { + return item.AccountID + }, + func(key OperationColumnsKey) string { + return key.AccountID + }, + func(item *types.OperationWithAccountID) types.Operation { + return item.Operation + }, + ) +} + +// operationByStateChangeIDLoader creates a dataloader for fetching operations by state change ID +// This prevents N+1 queries when multiple state changes request their operations +// The loader batches multiple state change IDs into a single database query +func operationByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, *types.Operation] { + return newOneToOneLoader( + func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithStateChangeID, error) { + stateChangeIDs := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + stateChangeIDs[i] = key.StateChangeID + } + return models.Operations.BatchGetByStateChangeIDs(ctx, stateChangeIDs, columns) + }, + func(item *types.OperationWithStateChangeID) string { + return item.StateChangeID + }, + func(key OperationColumnsKey) string { + return key.StateChangeID + }, + func(item *types.OperationWithStateChangeID) types.Operation { + return item.Operation + }, + ) +} diff --git a/internal/serve/graphql/dataloaders/statechange_loaders.go b/internal/serve/graphql/dataloaders/statechange_loaders.go new file mode 100644 index 00000000..60bbcb2d --- /dev/null +++ b/internal/serve/graphql/dataloaders/statechange_loaders.go @@ -0,0 +1,90 @@ +package dataloaders + +import ( + "context" + + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +type StateChangeColumnsKey struct { + AccountID string + OperationID int64 + TxHash string + Columns string +} + +// stateChangesByAccountLoader creates a dataloader for fetching state changes by account address +func stateChangesByAccountLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { + return newOneToManyLoader( + func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChange, error) { + accountIDs := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + accountIDs[i] = key.AccountID + } + return models.StateChanges.BatchGetByAccountAddresses(ctx, accountIDs, columns) + }, + func(item *types.StateChange) string { + return item.AccountID + }, + func(key StateChangeColumnsKey) string { + return key.AccountID + }, + func(item *types.StateChange) types.StateChange { + return *item + }, + ) +} + +// stateChangesByTxHashLoader creates a dataloader for fetching state changes by transaction hash +// This prevents N+1 queries when multiple transactions request their state changes +// The loader batches multiple transaction hashes into a single database query +func stateChangesByTxHashLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { + return newOneToManyLoader( + func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChange, error) { + txHashes := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + txHashes[i] = key.TxHash + } + return models.StateChanges.BatchGetByTxHashes(ctx, txHashes, columns) + }, + func(item *types.StateChange) string { + return item.TxHash + }, + func(key StateChangeColumnsKey) string { + return key.TxHash + }, + func(item *types.StateChange) types.StateChange { + return *item + }, + ) +} + +// stateChangesByOperationIDLoader creates a dataloader for fetching state changes by operation ID +// This prevents N+1 queries when multiple operations request their state changes +// The loader batches multiple operation IDs into a single database query +func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { + return newOneToManyLoader( + func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChange, error) { + operationIDs := make([]int64, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + operationIDs[i] = key.OperationID + } + return models.StateChanges.BatchGetByOperationIDs(ctx, operationIDs, columns) + }, + func(item *types.StateChange) int64 { + return item.OperationID + }, + func(key StateChangeColumnsKey) int64 { + return key.OperationID + }, + func(item *types.StateChange) types.StateChange { + return *item + }, + ) +} diff --git a/internal/serve/graphql/dataloaders/transaction_loaders.go b/internal/serve/graphql/dataloaders/transaction_loaders.go new file mode 100644 index 00000000..d591eb69 --- /dev/null +++ b/internal/serve/graphql/dataloaders/transaction_loaders.go @@ -0,0 +1,92 @@ +package dataloaders + +import ( + "context" + + "github.com/vikstrous/dataloadgen" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +type TransactionColumnsKey struct { + AccountID string + OperationID int64 + StateChangeID string + Columns string +} + +// txByAccountLoader creates a dataloader for fetching transactions by account address +// This prevents N+1 queries when multiple accounts request their transactions +// The loader batches multiple account addresses into a single database query +func transactionsByAccountLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, []*types.Transaction] { + return newOneToManyLoader( + func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithAccountID, error) { + accountIDs := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + accountIDs[i] = key.AccountID + } + return models.Transactions.BatchGetByAccountAddresses(ctx, accountIDs, columns) + }, + func(item *types.TransactionWithAccountID) string { + return item.AccountID + }, + func(key TransactionColumnsKey) string { + return key.AccountID + }, + func(item *types.TransactionWithAccountID) types.Transaction { + return item.Transaction + }, + ) +} + +// txByOperationIDLoader creates a dataloader for fetching transactions by operation ID +// This prevents N+1 queries when multiple operations request their transaction +// The loader batches multiple operation IDs into a single database query +func transactionByOperationIDLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] { + return newOneToOneLoader( + func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithOperationID, error) { + operationIDs := make([]int64, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + operationIDs[i] = key.OperationID + } + return models.Transactions.BatchGetByOperationIDs(ctx, operationIDs, columns) + }, + func(item *types.TransactionWithOperationID) int64 { + return item.OperationID + }, + func(key TransactionColumnsKey) int64 { + return key.OperationID + }, + func(item *types.TransactionWithOperationID) types.Transaction { + return item.Transaction + }, + ) +} + +// transactionByStateChangeIDLoader creates a dataloader for fetching transactions by state change ID +// This prevents N+1 queries when multiple state changes request their transactions +// The loader batches multiple state change IDs into a single database query +func transactionByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] { + return newOneToOneLoader( + func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithStateChangeID, error) { + stateChangeIDs := make([]string, len(keys)) + columns := keys[0].Columns + for i, key := range keys { + stateChangeIDs[i] = key.StateChangeID + } + return models.Transactions.BatchGetByStateChangeIDs(ctx, stateChangeIDs, columns) + }, + func(item *types.TransactionWithStateChangeID) string { + return item.StateChangeID + }, + func(key TransactionColumnsKey) string { + return key.StateChangeID + }, + func(item *types.TransactionWithStateChangeID) types.Transaction { + return item.Transaction + }, + ) +} diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go index c7a95b45..d9e145d8 100644 --- a/internal/serve/graphql/resolvers/account.resolvers.go +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "strings" "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" @@ -26,10 +27,16 @@ func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account) // Extract dataloaders from GraphQL context // Dataloaders are injected by middleware to batch database queries loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + + loaderKey := dataloaders.TransactionColumnsKey{ + AccountID: obj.StellarAddress, + Columns: strings.Join(dbColumns, ", "), + } // Use dataloader to efficiently batch-load transactions for this account // This prevents N+1 queries when multiple accounts request their transactions - transactions, err := loaders.TransactionsByAccountLoader.Load(ctx, obj.StellarAddress) + transactions, err := loaders.TransactionsByAccountLoader.Load(ctx, loaderKey) if err != nil { return nil, err } @@ -41,9 +48,15 @@ func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account) // Demonstrates the same dataloader pattern as Transactions resolver func (r *accountResolver) Operations(ctx context.Context, obj *types.Account) ([]*types.Operation, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "operations") + + loaderKey := dataloaders.OperationColumnsKey{ + AccountID: obj.StellarAddress, + Columns: strings.Join(dbColumns, ", "), + } // Use dataloader to batch-load operations for this account - operations, err := loaders.OperationsByAccountLoader.Load(ctx, obj.StellarAddress) + operations, err := loaders.OperationsByAccountLoader.Load(ctx, loaderKey) if err != nil { return nil, err } @@ -53,9 +66,14 @@ func (r *accountResolver) Operations(ctx context.Context, obj *types.Account) ([ // StateChanges is the resolver for the stateChanges field. func (r *accountResolver) StateChanges(ctx context.Context, obj *types.Account) ([]*types.StateChange, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "state_changes") // Use dataloader to batch-load state changes for this account - stateChanges, err := loaders.StateChangesByAccountLoader.Load(ctx, obj.StellarAddress) + loaderKey := dataloaders.StateChangeColumnsKey{ + AccountID: obj.StellarAddress, + Columns: strings.Join(dbColumns, ", "), + } + stateChanges, err := loaders.StateChangesByAccountLoader.Load(ctx, loaderKey) if err != nil { return nil, err } diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index 482e049c..85f523b6 100644 --- a/internal/serve/graphql/resolvers/account_resolvers_test.go +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -5,8 +5,10 @@ import ( "errors" "testing" + "github.com/99designs/gqlgen/graphql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/vektah/gqlparser/v2/ast" "github.com/vikstrous/dataloadgen" "github.com/stellar/wallet-backend/internal/indexer/types" @@ -14,13 +16,40 @@ import ( "github.com/stellar/wallet-backend/internal/serve/middleware" ) +func GetTestCtx(table string, columns []string) context.Context { + opCtx := &graphql.OperationContext{ + Operation: &ast.OperationDefinition{ + SelectionSet: ast.SelectionSet{ + &ast.Field{ + Name: table, + SelectionSet: ast.SelectionSet{}, + }, + }, + }, + } + ctx := graphql.WithOperationContext(context.Background(), opCtx) + var selections ast.SelectionSet + for _, fieldName := range columns { + selections = append(selections, &ast.Field{Name: fieldName}) + } + fieldCtx := &graphql.FieldContext{ + Field: graphql.CollectedField{ + Selections: selections, + }, + } + ctx = graphql.WithFieldContext(ctx, fieldCtx) + return ctx +} + func TestAccountResolver_Transactions(t *testing.T) { resolver := &accountResolver{&Resolver{}} parentAccount := &types.Account{StellarAddress: "test-account"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Transaction, []error) { - assert.Equal(t, []string{"test-account"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([][]*types.Transaction, []error) { + assert.Equal(t, []dataloaders.TransactionColumnsKey{ + {AccountID: "test-account", Columns: "transactions.hash"}, + }, keys) results := [][]*types.Transaction{ { {Hash: "tx1"}, @@ -34,7 +63,7 @@ func TestAccountResolver_Transactions(t *testing.T) { loaders := &dataloaders.Dataloaders{ TransactionsByAccountLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) transactions, err := resolver.Transactions(ctx, parentAccount) @@ -45,7 +74,7 @@ func TestAccountResolver_Transactions(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Transaction, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([][]*types.Transaction, []error) { return nil, []error{errors.New("something went wrong")} } @@ -53,7 +82,7 @@ func TestAccountResolver_Transactions(t *testing.T) { loaders := &dataloaders.Dataloaders{ TransactionsByAccountLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) _, err := resolver.Transactions(ctx, parentAccount) @@ -67,8 +96,10 @@ func TestAccountResolver_Operations(t *testing.T) { parentAccount := &types.Account{StellarAddress: "test-account"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { - assert.Equal(t, []string{"test-account"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { + assert.Equal(t, []dataloaders.OperationColumnsKey{ + {AccountID: "test-account", Columns: "operations.id"}, + }, keys) results := [][]*types.Operation{ { {ID: 1, TxHash: "tx1"}, @@ -82,7 +113,7 @@ func TestAccountResolver_Operations(t *testing.T) { loaders := &dataloaders.Dataloaders{ OperationsByAccountLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) operations, err := resolver.Operations(ctx, parentAccount) @@ -92,7 +123,7 @@ func TestAccountResolver_Operations(t *testing.T) { assert.Equal(t, "tx2", operations[1].TxHash) }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { return nil, []error{errors.New("something went wrong")} } @@ -100,7 +131,7 @@ func TestAccountResolver_Operations(t *testing.T) { loaders := &dataloaders.Dataloaders{ OperationsByAccountLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) _, err := resolver.Operations(ctx, parentAccount) @@ -114,8 +145,10 @@ func TestAccountResolver_StateChanges(t *testing.T) { parentAccount := &types.Account{StellarAddress: "test-account"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { - assert.Equal(t, []string{"test-account"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { + assert.Equal(t, []dataloaders.StateChangeColumnsKey{ + {AccountID: "test-account", Columns: "state_changes.id"}, + }, keys) results := [][]*types.StateChange{ { {ID: "sc1", TxHash: "tx1"}, @@ -128,7 +161,7 @@ func TestAccountResolver_StateChanges(t *testing.T) { loaders := &dataloaders.Dataloaders{ StateChangesByAccountLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentAccount) @@ -139,14 +172,14 @@ func TestAccountResolver_StateChanges(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { return nil, []error{errors.New("sc fetch error")} } loader := dataloadgen.NewLoader(mockFetch) loaders := &dataloaders.Dataloaders{ StateChangesByAccountLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) _, err := resolver.StateChanges(ctx, parentAccount) diff --git a/internal/serve/graphql/resolvers/operation.resolvers.go b/internal/serve/graphql/resolvers/operation.resolvers.go index fa3c1ad7..9fcbd9d8 100644 --- a/internal/serve/graphql/resolvers/operation.resolvers.go +++ b/internal/serve/graphql/resolvers/operation.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "strings" "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" @@ -21,10 +22,16 @@ func (r *operationResolver) Transaction(ctx context.Context, obj *types.Operatio // Extract dataloaders from GraphQL context // Dataloaders are injected by middleware to batch database queries loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + + loaderKey := dataloaders.TransactionColumnsKey{ + OperationID: obj.ID, + Columns: strings.Join(dbColumns, ", "), + } // Use dataloader to efficiently batch-load transaction for this operation // This prevents N+1 queries when multiple operations request their transaction - transaction, err := loaders.TransactionsByOperationIDLoader.Load(ctx, obj.ID) + transaction, err := loaders.TransactionsByOperationIDLoader.Load(ctx, loaderKey) if err != nil { return nil, err } @@ -36,13 +43,17 @@ func (r *operationResolver) Transaction(ctx context.Context, obj *types.Operatio // gqlgen calls this when a GraphQL query requests the accounts field on an Operation // Field resolvers receive the parent object (Operation) and return the field value func (r *operationResolver) Accounts(ctx context.Context, obj *types.Operation) ([]*types.Account, error) { - // Extract dataloaders from GraphQL context - // Dataloaders are injected by middleware to batch database queries loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.Account{}, "accounts") + + loaderKey := dataloaders.AccountColumnsKey{ + OperationID: obj.ID, + Columns: strings.Join(dbColumns, ", "), + } // Use dataloader to efficiently batch-load accounts for this operation // This prevents N+1 queries when multiple operations request their accounts - accounts, err := loaders.AccountsByOperationIDLoader.Load(ctx, obj.ID) + accounts, err := loaders.AccountsByOperationIDLoader.Load(ctx, loaderKey) if err != nil { return nil, err } @@ -54,13 +65,17 @@ func (r *operationResolver) Accounts(ctx context.Context, obj *types.Operation) // gqlgen calls this when a GraphQL query requests the stateChanges field on an Operation // Field resolvers receive the parent object (Operation) and return the field value func (r *operationResolver) StateChanges(ctx context.Context, obj *types.Operation) ([]*types.StateChange, error) { - // Extract dataloaders from GraphQL context - // Dataloaders are injected by middleware to batch database queries loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "") + + loaderKey := dataloaders.StateChangeColumnsKey{ + OperationID: obj.ID, + Columns: strings.Join(dbColumns, ", "), + } // Use dataloader to efficiently batch-load state changes for this operation // This prevents N+1 queries when multiple operations request their state changes - stateChanges, err := loaders.StateChangesByOperationIDLoader.Load(ctx, obj.ID) + stateChanges, err := loaders.StateChangesByOperationIDLoader.Load(ctx, loaderKey) if err != nil { return nil, err } diff --git a/internal/serve/graphql/resolvers/operation.resolvers_test.go b/internal/serve/graphql/resolvers/operation_resolvers_test.go similarity index 67% rename from internal/serve/graphql/resolvers/operation.resolvers_test.go rename to internal/serve/graphql/resolvers/operation_resolvers_test.go index bbf1e7dc..d52de6b6 100644 --- a/internal/serve/graphql/resolvers/operation.resolvers_test.go +++ b/internal/serve/graphql/resolvers/operation_resolvers_test.go @@ -19,8 +19,8 @@ func TestOperationResolver_Transaction(t *testing.T) { parentOperation := &types.Operation{ID: 123} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []int64) ([]*types.Transaction, []error) { - assert.Equal(t, []int64{123}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { + assert.Equal(t, []dataloaders.TransactionColumnsKey{{OperationID: 123, Columns: "transactions.hash"}}, keys) results := []*types.Transaction{ {Hash: "tx1"}, } @@ -31,7 +31,7 @@ func TestOperationResolver_Transaction(t *testing.T) { loaders := &dataloaders.Dataloaders{ TransactionsByOperationIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) transaction, err := resolver.Transaction(ctx, parentOperation) @@ -41,7 +41,7 @@ func TestOperationResolver_Transaction(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []int64) ([]*types.Transaction, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { return nil, []error{errors.New("something went wrong")} } @@ -49,7 +49,7 @@ func TestOperationResolver_Transaction(t *testing.T) { loaders := &dataloaders.Dataloaders{ TransactionsByOperationIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) _, err := resolver.Transaction(ctx, parentOperation) @@ -63,8 +63,8 @@ func TestOperationResolver_Accounts(t *testing.T) { parentOperation := &types.Operation{ID: 123} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []int64) ([][]*types.Account, []error) { - assert.Equal(t, []int64{123}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { + assert.Equal(t, []dataloaders.AccountColumnsKey{{OperationID: 123, Columns: "accounts.stellar_address"}}, keys) results := [][]*types.Account{ { {StellarAddress: "G-ACCOUNT1"}, @@ -77,7 +77,7 @@ func TestOperationResolver_Accounts(t *testing.T) { loaders := &dataloaders.Dataloaders{ AccountsByOperationIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, parentOperation) @@ -88,14 +88,14 @@ func TestOperationResolver_Accounts(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []int64) ([][]*types.Account, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { return nil, []error{errors.New("account fetch error")} } loader := dataloadgen.NewLoader(mockFetch) loaders := &dataloaders.Dataloaders{ AccountsByOperationIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) _, err := resolver.Accounts(ctx, parentOperation) @@ -109,8 +109,8 @@ func TestOperationResolver_StateChanges(t *testing.T) { parentOperation := &types.Operation{ID: 123} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []int64) ([][]*types.StateChange, []error) { - assert.Equal(t, []int64{123}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { + assert.Equal(t, []dataloaders.StateChangeColumnsKey{{OperationID: 123, Columns: "id"}}, keys) results := [][]*types.StateChange{ { {ID: "sc1"}, @@ -123,7 +123,7 @@ func TestOperationResolver_StateChanges(t *testing.T) { loaders := &dataloaders.Dataloaders{ StateChangesByOperationIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentOperation) @@ -134,14 +134,14 @@ func TestOperationResolver_StateChanges(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []int64) ([][]*types.StateChange, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { return nil, []error{errors.New("sc fetch error")} } loader := dataloadgen.NewLoader(mockFetch) loaders := &dataloaders.Dataloaders{ StateChangesByOperationIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) _, err := resolver.StateChanges(ctx, parentOperation) diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go index 85efa808..b9c4f4b0 100644 --- a/internal/serve/graphql/resolvers/queries.resolvers.go +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "strings" "github.com/stellar/wallet-backend/internal/indexer/types" graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" @@ -15,14 +16,16 @@ import ( // This is a root query resolver - it handles the "transactionByHash" query. // gqlgen calls this function when a GraphQL query requests "transactionByHash" func (r *queryResolver) TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) { - return r.models.Transactions.GetByHash(ctx, hash) + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "") + return r.models.Transactions.GetByHash(ctx, hash, strings.Join(dbColumns, ", ")) } // Transactions is the resolver for the transactions field. // This resolver handles the "transactions" query. // It demonstrates handling optional arguments (limit can be nil) func (r *queryResolver) Transactions(ctx context.Context, limit *int32) ([]*types.Transaction, error) { - return r.models.Transactions.GetAll(ctx, limit) + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "") + return r.models.Transactions.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) } // Account is the resolver for the account field. @@ -35,12 +38,14 @@ func (r *queryResolver) Account(ctx context.Context, address string) (*types.Acc // Operations is the resolver for the operations field. // This resolver handles the "operations" query. func (r *queryResolver) Operations(ctx context.Context, limit *int32) ([]*types.Operation, error) { - return r.models.Operations.GetAll(ctx, limit) + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "") + return r.models.Operations.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) } // StateChanges is the resolver for the stateChanges field. func (r *queryResolver) StateChanges(ctx context.Context, limit *int32) ([]*types.StateChange, error) { - return r.models.StateChanges.GetAll(ctx, limit) + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "") + return r.models.StateChanges.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) } // Query returns graphql1.QueryResolver implementation. diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index f12b48d1..d6bc1c4e 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -66,6 +66,7 @@ func TestQueryResolver_TransactionByHash(t *testing.T) { }) require.NoError(t, dbErr) + ctx := GetTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) tx, err := resolver.TransactionByHash(ctx, "tx1") require.NoError(t, err) @@ -139,12 +140,14 @@ func TestQueryResolver_Transactions(t *testing.T) { require.NoError(t, dbErr) t.Run("get all", func(t *testing.T) { + ctx := GetTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) txs, err := resolver.Transactions(ctx, nil) require.NoError(t, err) assert.Len(t, txs, 2) }) t.Run("get with limit", func(t *testing.T) { + ctx := GetTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) limit := int32(1) txs, err := resolver.Transactions(ctx, &limit) require.NoError(t, err) @@ -285,12 +288,14 @@ func TestQueryResolver_Operations(t *testing.T) { require.NoError(t, dbErr) t.Run("get all", func(t *testing.T) { + ctx := GetTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) ops, err := resolver.Operations(ctx, nil) require.NoError(t, err) assert.Len(t, ops, 2) }) t.Run("get with limit", func(t *testing.T) { + ctx := GetTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) limit := int32(1) ops, err := resolver.Operations(ctx, &limit) require.NoError(t, err) @@ -391,6 +396,7 @@ func TestQueryResolver_StateChanges(t *testing.T) { resolver.models.StateChanges.MetricsService = mockMetricsService mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + ctx := GetTestCtx("state_changes", []string{"id", "stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) scs, err := resolver.StateChanges(ctx, nil) require.NoError(t, err) assert.Len(t, scs, 2) @@ -405,6 +411,7 @@ func TestQueryResolver_StateChanges(t *testing.T) { mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() limit := int32(1) + ctx := GetTestCtx("state_changes", []string{"id", "stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) scs, err := resolver.StateChanges(ctx, &limit) require.NoError(t, err) assert.Len(t, scs, 1) diff --git a/internal/serve/graphql/resolvers/statechange.resolvers.go b/internal/serve/graphql/resolvers/statechange.resolvers.go index 8744c430..53a90881 100644 --- a/internal/serve/graphql/resolvers/statechange.resolvers.go +++ b/internal/serve/graphql/resolvers/statechange.resolvers.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" @@ -149,7 +150,13 @@ func (r *stateChangeResolver) KeyValue(ctx context.Context, obj *types.StateChan // Operation is the resolver for the operation field. func (r *stateChangeResolver) Operation(ctx context.Context, obj *types.StateChange) (*types.Operation, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - operations, err := loaders.OperationByStateChangeIDLoader.Load(ctx, obj.ID) + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "operations") + + loaderKey := dataloaders.OperationColumnsKey{ + StateChangeID: obj.ID, + Columns: strings.Join(dbColumns, ", "), + } + operations, err := loaders.OperationByStateChangeIDLoader.Load(ctx, loaderKey) if err != nil { return nil, err } @@ -159,11 +166,17 @@ func (r *stateChangeResolver) Operation(ctx context.Context, obj *types.StateCha // Transaction is the resolver for the transaction field. func (r *stateChangeResolver) Transaction(ctx context.Context, obj *types.StateChange) (*types.Transaction, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - transactions, err := loaders.TransactionByStateChangeIDLoader.Load(ctx, obj.ID) + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + + loaderKey := dataloaders.TransactionColumnsKey{ + StateChangeID: obj.ID, + Columns: strings.Join(dbColumns, ", "), + } + transaction, err := loaders.TransactionByStateChangeIDLoader.Load(ctx, loaderKey) if err != nil { return nil, err } - return transactions, nil + return transaction, nil } // StateChange returns graphql1.StateChangeResolver implementation. diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go index 0fb0cfb7..7448cf29 100644 --- a/internal/serve/graphql/resolvers/statechange_resolvers_test.go +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -152,8 +152,8 @@ func TestStateChangeResolver_Operation(t *testing.T) { parentSC := &types.StateChange{ID: "test-sc-id"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([]*types.Operation, []error) { - assert.Equal(t, []string{"test-sc-id"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([]*types.Operation, []error) { + assert.Equal(t, []dataloaders.OperationColumnsKey{{StateChangeID: "test-sc-id", Columns: "operations.id"}}, keys) return []*types.Operation{{ID: 99}}, nil } @@ -161,7 +161,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { loaders := &dataloaders.Dataloaders{ OperationByStateChangeIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) op, err := resolver.Operation(ctx, parentSC) require.NoError(t, err) @@ -169,7 +169,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([]*types.Operation, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([]*types.Operation, []error) { return nil, []error{errors.New("op fetch error")} } @@ -177,7 +177,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { loaders := &dataloaders.Dataloaders{ OperationByStateChangeIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) _, err := resolver.Operation(ctx, parentSC) require.Error(t, err) @@ -190,8 +190,8 @@ func TestStateChangeResolver_Transaction(t *testing.T) { parentSC := &types.StateChange{ID: "test-sc-id"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([]*types.Transaction, []error) { - assert.Equal(t, []string{"test-sc-id"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { + assert.Equal(t, []dataloaders.TransactionColumnsKey{{StateChangeID: "test-sc-id", Columns: "transactions.hash"}}, keys) return []*types.Transaction{{Hash: "tx-abc"}}, nil } @@ -199,7 +199,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { loaders := &dataloaders.Dataloaders{ TransactionByStateChangeIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) tx, err := resolver.Transaction(ctx, parentSC) require.NoError(t, err) @@ -207,7 +207,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([]*types.Transaction, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { return nil, []error{errors.New("tx fetch error")} } @@ -215,7 +215,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { loaders := &dataloaders.Dataloaders{ TransactionByStateChangeIDLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) _, err := resolver.Transaction(ctx, parentSC) require.Error(t, err) diff --git a/internal/serve/graphql/resolvers/transaction.resolvers.go b/internal/serve/graphql/resolvers/transaction.resolvers.go index 12da7a2e..eb33e66c 100644 --- a/internal/serve/graphql/resolvers/transaction.resolvers.go +++ b/internal/serve/graphql/resolvers/transaction.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "strings" "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" @@ -18,9 +19,15 @@ import ( // It's called when a GraphQL query requests the operations within a transaction func (r *transactionResolver) Operations(ctx context.Context, obj *types.Transaction) ([]*types.Operation, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "") + + loaderKey := dataloaders.OperationColumnsKey{ + TxHash: obj.Hash, + Columns: strings.Join(dbColumns, ", "), + } // Use dataloader to batch-load operations for this transaction - operations, err := loaders.OperationsByTxHashLoader.Load(ctx, obj.Hash) + operations, err := loaders.OperationsByTxHashLoader.Load(ctx, loaderKey) if err != nil { return nil, err } @@ -32,11 +39,16 @@ func (r *transactionResolver) Operations(ctx context.Context, obj *types.Transac // It's called when a GraphQL query requests the accounts within a transaction func (r *transactionResolver) Accounts(ctx context.Context, obj *types.Transaction) ([]*types.Account, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.Account{}, "accounts") // Use dataloader to batch-load accounts for this transaction // This prevents N+1 queries when multiple transactions request their operations // The loader groups multiple requests and executes them in a single database query - accounts, err := loaders.AccountsByTxHashLoader.Load(ctx, obj.Hash) + loaderKey := dataloaders.AccountColumnsKey{ + TxHash: obj.Hash, + Columns: strings.Join(dbColumns, ", "), + } + accounts, err := loaders.AccountsByTxHashLoader.Load(ctx, loaderKey) if err != nil { return nil, err } @@ -48,9 +60,15 @@ func (r *transactionResolver) Accounts(ctx context.Context, obj *types.Transacti // It's called when a GraphQL query requests the state changes within a transaction func (r *transactionResolver) StateChanges(ctx context.Context, obj *types.Transaction) ([]*types.StateChange, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "") + + loaderKey := dataloaders.StateChangeColumnsKey{ + TxHash: obj.Hash, + Columns: strings.Join(dbColumns, ", "), + } // Use dataloader to batch-load state changes for this transaction - stateChanges, err := loaders.StateChangesByTxHashLoader.Load(ctx, obj.Hash) + stateChanges, err := loaders.StateChangesByTxHashLoader.Load(ctx, loaderKey) if err != nil { return nil, err } diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index 11367d6d..a5a8b311 100644 --- a/internal/serve/graphql/resolvers/transaction_resolvers_test.go +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -19,8 +19,10 @@ func TestTransactionResolver_Operations(t *testing.T) { parentTx := &types.Transaction{Hash: "test-tx-hash"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { - assert.Equal(t, []string{"test-tx-hash"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { + assert.Equal(t, []dataloaders.OperationColumnsKey{ + {TxHash: "test-tx-hash", Columns: "id"}, + }, keys) results := [][]*types.Operation{ { {ID: 1}, @@ -34,7 +36,7 @@ func TestTransactionResolver_Operations(t *testing.T) { loaders := &dataloaders.Dataloaders{ OperationsByTxHashLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) operations, err := resolver.Operations(ctx, parentTx) @@ -45,7 +47,7 @@ func TestTransactionResolver_Operations(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Operation, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { return nil, []error{errors.New("op fetch error")} } @@ -53,7 +55,7 @@ func TestTransactionResolver_Operations(t *testing.T) { loaders := &dataloaders.Dataloaders{ OperationsByTxHashLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) _, err := resolver.Operations(ctx, parentTx) @@ -67,8 +69,10 @@ func TestTransactionResolver_Accounts(t *testing.T) { parentTx := &types.Transaction{Hash: "test-tx-hash"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Account, []error) { - assert.Equal(t, []string{"test-tx-hash"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { + assert.Equal(t, []dataloaders.AccountColumnsKey{ + {TxHash: "test-tx-hash", Columns: "accounts.stellar_address"}, + }, keys) results := [][]*types.Account{ { {StellarAddress: "G-ACCOUNT1"}, @@ -81,7 +85,7 @@ func TestTransactionResolver_Accounts(t *testing.T) { loaders := &dataloaders.Dataloaders{ AccountsByTxHashLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, parentTx) @@ -92,14 +96,14 @@ func TestTransactionResolver_Accounts(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.Account, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { return nil, []error{errors.New("account fetch error")} } loader := dataloadgen.NewLoader(mockFetch) loaders := &dataloaders.Dataloaders{ AccountsByTxHashLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) _, err := resolver.Accounts(ctx, parentTx) @@ -113,8 +117,10 @@ func TestTransactionResolver_StateChanges(t *testing.T) { parentTx := &types.Transaction{Hash: "test-tx-hash"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { - assert.Equal(t, []string{"test-tx-hash"}, keys) + mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { + assert.Equal(t, []dataloaders.StateChangeColumnsKey{ + {TxHash: "test-tx-hash", Columns: "id"}, + }, keys) results := [][]*types.StateChange{ { {ID: "sc1"}, @@ -127,7 +133,7 @@ func TestTransactionResolver_StateChanges(t *testing.T) { loaders := &dataloaders.Dataloaders{ StateChangesByTxHashLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentTx) @@ -138,14 +144,14 @@ func TestTransactionResolver_StateChanges(t *testing.T) { }) t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []string) ([][]*types.StateChange, []error) { + mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { return nil, []error{errors.New("sc fetch error")} } loader := dataloadgen.NewLoader(mockFetch) loaders := &dataloaders.Dataloaders{ StateChangesByTxHashLoader: loader, } - ctx := context.WithValue(context.Background(), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) _, err := resolver.StateChanges(ctx, parentTx) diff --git a/internal/serve/graphql/resolvers/utils.go b/internal/serve/graphql/resolvers/utils.go new file mode 100644 index 00000000..9964adfa --- /dev/null +++ b/internal/serve/graphql/resolvers/utils.go @@ -0,0 +1,52 @@ +package resolvers + +import ( + "context" + "reflect" + "strings" + + "github.com/99designs/gqlgen/graphql" +) + +func GetDBColumnsForFields(ctx context.Context, model any, prefix string) []string { + fields := graphql.CollectFieldsCtx(ctx, nil) + return prefixDBColumns(prefix, getDBColumns(model, fields)) +} + +func getDBColumns(model any, fields []graphql.CollectedField) []string { + fieldToColumnMap := getColumnMap(model) + dbColumns := make([]string, 0, len(fields)) + for _, field := range fields { + if colName, ok := fieldToColumnMap[field.Name]; ok { + dbColumns = append(dbColumns, colName) + } + } + return dbColumns +} + +func prefixDBColumns(prefix string, cols []string) []string { + if prefix == "" { + return cols + } + prefixedCols := make([]string, len(cols)) + for i, col := range cols { + prefixedCols[i] = prefix + "." + col + } + return prefixedCols +} + +func getColumnMap(model any) map[string]string { + modelType := reflect.TypeOf(model) + fieldToColumnMap := make(map[string]string) + for i := 0; i < modelType.NumField(); i++ { + field := modelType.Field(i) + jsonTag := field.Tag.Get("json") + dbTag := field.Tag.Get("db") + + if jsonTag != "" && dbTag != "" && dbTag != "-" { + jsonFieldName := strings.Split(jsonTag, ",")[0] + fieldToColumnMap[jsonFieldName] = dbTag + } + } + return fieldToColumnMap +} From daa299ed96095ae44cd5ff440db0de3d4952ba1d Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Mon, 11 Aug 2025 14:11:56 -0400 Subject: [PATCH 04/16] Update resolvers to use state change's `toID` and `order` (#274) --- internal/data/operations.go | 20 +++- internal/data/operations_test.go | 16 ++-- internal/data/statechanges.go | 5 +- internal/data/statechanges_test.go | 32 +++---- internal/data/transactions.go | 20 +++- internal/data/transactions_test.go | 16 ++-- .../processors/state_change_builder.go | 5 +- .../graphql/dataloaders/operation_loaders.go | 11 ++- .../dataloaders/transaction_loaders.go | 11 ++- internal/serve/graphql/dataloaders/utils.go | 37 ++++++++ internal/serve/graphql/generated/generated.go | 91 +++---------------- .../resolvers/account_resolvers_test.go | 16 ++-- .../resolvers/operation_resolvers_test.go | 14 +-- .../resolvers/queries_resolvers_test.go | 20 ++-- .../resolvers/statechange.resolvers.go | 6 +- .../resolvers/statechange_resolvers_test.go | 10 +- .../resolvers/transaction_resolvers_test.go | 14 +-- .../serve/graphql/schema/statechange.graphqls | 5 +- 18 files changed, 182 insertions(+), 167 deletions(-) create mode 100644 internal/serve/graphql/dataloaders/utils.go diff --git a/internal/data/operations.go b/internal/data/operations.go index 3bae9758..f9474e63 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -3,6 +3,7 @@ package data import ( "context" "fmt" + "strings" "time" set "github.com/deckarep/golang-set/v2" @@ -85,20 +86,29 @@ func (m *OperationModel) BatchGetByAccountAddresses(ctx context.Context, account } // BatchGetByStateChangeIDs gets the operations that are associated with the given state change IDs. -func (m *OperationModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string, columns string) ([]*types.OperationWithStateChangeID, error) { +func (m *OperationModel) BatchGetByStateChangeIDs(ctx context.Context, scToIDs []int64, scOrders []int64, columns string) ([]*types.OperationWithStateChangeID, error) { if columns == "" { columns = "operations.*" } + + // Build tuples for the IN clause. Since (to_id, state_change_order) is the primary key of state_changes, + // it will be faster to search on this tuple. + tuples := make([]string, len(scOrders)) + for i := range scOrders { + tuples[i] = fmt.Sprintf("(%d, %d)", scToIDs[i], scOrders[i]) + } + query := fmt.Sprintf(` - SELECT %s, state_changes.id AS state_change_id + SELECT %s, CONCAT(state_changes.to_id, '-', state_changes.state_change_order) AS state_change_id FROM operations INNER JOIN state_changes ON operations.id = state_changes.operation_id - WHERE state_changes.id = ANY($1) + WHERE (state_changes.to_id, state_changes.state_change_order) IN (%s) ORDER BY operations.ledger_created_at DESC - `, columns) + `, columns, strings.Join(tuples, ", ")) + var operationsWithStateChanges []*types.OperationWithStateChangeID start := time.Now() - err := m.DB.SelectContext(ctx, &operationsWithStateChanges, query, pq.Array(stateChangeIDs)) + err := m.DB.SelectContext(ctx, &operationsWithStateChanges, query) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) if err != nil { diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index a1d4edca..4d719e15 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -438,16 +438,16 @@ func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { // Create test state changes _, err = dbConnectionPool.ExecContext(ctx, ` - INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) VALUES - ('sc1', 'credit', $1, 1, $2, 1, 'tx1'), - ('sc2', 'debit', $1, 2, $2, 2, 'tx2'), - ('sc3', 'credit', $1, 3, $2, 1, 'tx3') + (1, 1, 'credit', $1, 1, $2, 1, 'tx1'), + (2, 1, 'debit', $1, 2, $2, 2, 'tx2'), + (3, 1, 'credit', $1, 3, $2, 1, 'tx3') `, now, address) require.NoError(t, err) // Test BatchGetByStateChangeID - operations, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}, "") + operations, err := m.BatchGetByStateChangeIDs(ctx, []int64{1, 2, 3}, []int64{1, 1, 1}, "") require.NoError(t, err) assert.Len(t, operations, 3) @@ -456,7 +456,7 @@ func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { for _, op := range operations { stateChangeIDsFound[op.StateChangeID] = op.ID } - assert.Equal(t, int64(1), stateChangeIDsFound["sc1"]) - assert.Equal(t, int64(2), stateChangeIDsFound["sc2"]) - assert.Equal(t, int64(1), stateChangeIDsFound["sc3"]) + assert.Equal(t, int64(1), stateChangeIDsFound["1-1"]) + assert.Equal(t, int64(2), stateChangeIDsFound["2-1"]) + assert.Equal(t, int64(1), stateChangeIDsFound["3-1"]) } diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index d9de146e..30f7ef55 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -41,7 +41,10 @@ func (m *StateChangeModel) GetAll(ctx context.Context, limit *int32, columns str if columns == "" { columns = "*" } - query := fmt.Sprintf(`SELECT %s FROM state_changes ORDER BY ledger_created_at DESC`, columns) + + // We always return the to_id, state_change_order since those are the primary keys. + // This is used for subsequent queries for operation and transactions of a state change. + query := fmt.Sprintf(`SELECT to_id, state_change_order, %s FROM state_changes ORDER BY to_id DESC, state_change_order DESC`, columns) var args []interface{} if limit != nil && *limit > 0 { query += ` LIMIT $1` diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 92ac74ba..27c250ba 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -229,11 +229,11 @@ func TestStateChangeModel_BatchGetByAccountAddresses(t *testing.T) { // Create test state changes _, err = dbConnectionPool.ExecContext(ctx, ` - INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) VALUES - ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), - ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), - ('sc3', 'credit', $1, 3, $3, 789, 'tx3') + (1, 1, 'credit', $1, 1, $2, 123, 'tx1'), + (2, 1, 'debit', $1, 2, $2, 456, 'tx2'), + (3, 1, 'credit', $1, 3, $3, 789, 'tx3') `, now, address1, address2) require.NoError(t, err) @@ -293,11 +293,11 @@ func TestStateChangeModel_GetAll(t *testing.T) { // Create test state changes _, err = dbConnectionPool.ExecContext(ctx, ` - INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) VALUES - ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), - ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), - ('sc3', 'credit', $1, 3, $2, 789, 'tx3') + (1, 1, 'credit', $1, 1, $2, 123, 'tx1'), + (2, 1, 'debit', $1, 2, $2, 456, 'tx2'), + (3, 1, 'credit', $1, 3, $2, 789, 'tx3') `, now, address) require.NoError(t, err) @@ -349,11 +349,11 @@ func TestStateChangeModel_BatchGetByTxHashes(t *testing.T) { // Create test state changes _, err = dbConnectionPool.ExecContext(ctx, ` - INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) VALUES - ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), - ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), - ('sc3', 'credit', $1, 3, $2, 789, 'tx1') + (1, 1, 'credit', $1, 1, $2, 123, 'tx1'), + (2, 1, 'debit', $1, 2, $2, 456, 'tx2'), + (3, 1, 'credit', $1, 3, $2, 789, 'tx1') `, now, address) require.NoError(t, err) @@ -408,11 +408,11 @@ func TestStateChangeModel_BatchGetByOperationIDs(t *testing.T) { // Create test state changes _, err = dbConnectionPool.ExecContext(ctx, ` - INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) VALUES - ('sc1', 'credit', $1, 1, $2, 123, 'tx1'), - ('sc2', 'debit', $1, 2, $2, 456, 'tx2'), - ('sc3', 'credit', $1, 3, $2, 123, 'tx3') + (1, 1, 'credit', $1, 1, $2, 123, 'tx1'), + (2, 1, 'debit', $1, 2, $2, 456, 'tx2'), + (3, 1, 'credit', $1, 3, $2, 123, 'tx3') `, now, address) require.NoError(t, err) diff --git a/internal/data/transactions.go b/internal/data/transactions.go index 4e61814e..ceee2d12 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -3,6 +3,7 @@ package data import ( "context" "fmt" + "strings" "time" set "github.com/deckarep/golang-set/v2" @@ -106,19 +107,28 @@ func (m *TransactionModel) BatchGetByOperationIDs(ctx context.Context, operation } // BatchGetByStateChangeIDs gets the transactions that are associated with the given state changes -func (m *TransactionModel) BatchGetByStateChangeIDs(ctx context.Context, stateChangeIDs []string, columns string) ([]*types.TransactionWithStateChangeID, error) { +func (m *TransactionModel) BatchGetByStateChangeIDs(ctx context.Context, scToIDs []int64, scOrders []int64, columns string) ([]*types.TransactionWithStateChangeID, error) { if columns == "" { columns = "transactions.*" } + + // Build tuples for the IN clause. Since (to_id, state_change_order) is the primary key of state_changes, + // it will be faster to search on this tuple. + tuples := make([]string, len(scOrders)) + for i := range scOrders { + tuples[i] = fmt.Sprintf("(%d, %d)", scToIDs[i], scOrders[i]) + } + query := fmt.Sprintf(` - SELECT %s, sc.id as state_change_id + SELECT %s, CONCAT(sc.to_id, '-', sc.state_change_order) as state_change_id FROM state_changes sc INNER JOIN transactions ON transactions.hash = sc.tx_hash - WHERE sc.id = ANY($1) - `, columns) + WHERE (sc.to_id, sc.state_change_order) IN (%s) + `, columns, strings.Join(tuples, ", ")) + var transactions []*types.TransactionWithStateChangeID start := time.Now() - err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(stateChangeIDs)) + err := m.DB.SelectContext(ctx, &transactions, query) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) if err != nil { diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index a7d7d7fb..ab88cda3 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -420,16 +420,16 @@ func TestTransactionModel_BatchGetByStateChangeIDs(t *testing.T) { // Create test state changes _, err = dbConnectionPool.ExecContext(ctx, ` - INSERT INTO state_changes (id, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) VALUES - ('sc1', 'credit', $1, 1, $2, 1, 'tx1'), - ('sc2', 'debit', $1, 2, $2, 2, 'tx2'), - ('sc3', 'credit', $1, 3, $2, 3, 'tx1') + (1, 1, 'credit', $1, 1, $2, 1, 'tx1'), + (2, 1, 'debit', $1, 2, $2, 2, 'tx2'), + (3, 1, 'credit', $1, 3, $2, 3, 'tx1') `, now, address) require.NoError(t, err) // Test BatchGetByStateChangeID - transactions, err := m.BatchGetByStateChangeIDs(ctx, []string{"sc1", "sc2", "sc3"}, "") + transactions, err := m.BatchGetByStateChangeIDs(ctx, []int64{1, 2, 3}, []int64{1, 1, 1}, "") require.NoError(t, err) assert.Len(t, transactions, 3) @@ -438,7 +438,7 @@ func TestTransactionModel_BatchGetByStateChangeIDs(t *testing.T) { for _, tx := range transactions { stateChangeIDsFound[tx.StateChangeID] = tx.Hash } - assert.Equal(t, "tx1", stateChangeIDsFound["sc1"]) - assert.Equal(t, "tx2", stateChangeIDsFound["sc2"]) - assert.Equal(t, "tx1", stateChangeIDsFound["sc3"]) + assert.Equal(t, "tx1", stateChangeIDsFound["1-1"]) + assert.Equal(t, "tx2", stateChangeIDsFound["2-1"]) + assert.Equal(t, "tx1", stateChangeIDsFound["3-1"]) } diff --git a/internal/indexer/processors/state_change_builder.go b/internal/indexer/processors/state_change_builder.go index 3149bfdf..9341e2c7 100644 --- a/internal/indexer/processors/state_change_builder.go +++ b/internal/indexer/processors/state_change_builder.go @@ -173,6 +173,7 @@ func (b *StateChangeBuilder) generateSortKey() string { } func (b *StateChangeBuilder) Clone() *StateChangeBuilder { - clone := *b - return &clone + return &StateChangeBuilder{ + base: b.base, + } } diff --git a/internal/serve/graphql/dataloaders/operation_loaders.go b/internal/serve/graphql/dataloaders/operation_loaders.go index 4e3c74ec..c953762a 100644 --- a/internal/serve/graphql/dataloaders/operation_loaders.go +++ b/internal/serve/graphql/dataloaders/operation_loaders.go @@ -2,6 +2,7 @@ package dataloaders import ( "context" + "fmt" "github.com/vikstrous/dataloadgen" @@ -72,12 +73,16 @@ func operationsByAccountLoader(models *data.Models) *dataloadgen.Loader[Operatio func operationByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, *types.Operation] { return newOneToOneLoader( func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithStateChangeID, error) { - stateChangeIDs := make([]string, len(keys)) columns := keys[0].Columns + scIDs := make([]string, len(keys)) for i, key := range keys { - stateChangeIDs[i] = key.StateChangeID + scIDs[i] = key.StateChangeID } - return models.Operations.BatchGetByStateChangeIDs(ctx, stateChangeIDs, columns) + scToIDs, scOrders, err := parseStateChangeIDs(scIDs) + if err != nil { + return nil, fmt.Errorf("parsing state change IDs: %w", err) + } + return models.Operations.BatchGetByStateChangeIDs(ctx, scToIDs, scOrders, columns) }, func(item *types.OperationWithStateChangeID) string { return item.StateChangeID diff --git a/internal/serve/graphql/dataloaders/transaction_loaders.go b/internal/serve/graphql/dataloaders/transaction_loaders.go index d591eb69..13c405be 100644 --- a/internal/serve/graphql/dataloaders/transaction_loaders.go +++ b/internal/serve/graphql/dataloaders/transaction_loaders.go @@ -2,6 +2,7 @@ package dataloaders import ( "context" + "fmt" "github.com/vikstrous/dataloadgen" @@ -72,12 +73,16 @@ func transactionByOperationIDLoader(models *data.Models) *dataloadgen.Loader[Tra func transactionByStateChangeIDLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, *types.Transaction] { return newOneToOneLoader( func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithStateChangeID, error) { - stateChangeIDs := make([]string, len(keys)) columns := keys[0].Columns + scIDs := make([]string, len(keys)) for i, key := range keys { - stateChangeIDs[i] = key.StateChangeID + scIDs[i] = key.StateChangeID } - return models.Transactions.BatchGetByStateChangeIDs(ctx, stateChangeIDs, columns) + scToIDs, scOrders, err := parseStateChangeIDs(scIDs) + if err != nil { + return nil, fmt.Errorf("parsing state change IDs: %w", err) + } + return models.Transactions.BatchGetByStateChangeIDs(ctx, scToIDs, scOrders, columns) }, func(item *types.TransactionWithStateChangeID) string { return item.StateChangeID diff --git a/internal/serve/graphql/dataloaders/utils.go b/internal/serve/graphql/dataloaders/utils.go new file mode 100644 index 00000000..e9f2fb3e --- /dev/null +++ b/internal/serve/graphql/dataloaders/utils.go @@ -0,0 +1,37 @@ +// Shared utilities for the data package +package dataloaders + +import ( + "fmt" + "strconv" + "strings" +) + +// parseStateChangeIDs parses composite state change IDs (format: "to_id-state_change_order") +// into separate slices of to_id and state_change_order values. +func parseStateChangeIDs(stateChangeIDs []string) ([]int64, []int64, error) { + toIDs := make([]int64, len(stateChangeIDs)) + orders := make([]int64, len(stateChangeIDs)) + + for i, id := range stateChangeIDs { + parts := strings.Split(id, "-") + if len(parts) != 2 { + return nil, nil, fmt.Errorf("invalid state change ID format: %s (expected format: to_id-state_change_order)", id) + } + + toID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return nil, nil, fmt.Errorf("invalid to_id in state change ID %s: %w", id, err) + } + + order, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return nil, nil, fmt.Errorf("invalid state_change_order in state change ID %s: %w", id, err) + } + + toIDs[i] = toID + orders[i] = order + } + + return toIDs, orders, nil +} diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 5c3bab6f..6fa71825 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -100,7 +100,6 @@ type ComplexityRoot struct { Amount func(childComplexity int) int ClaimableBalanceID func(childComplexity int) int Flags func(childComplexity int) int - ID func(childComplexity int) int IngestedAt func(childComplexity int) int KeyValue func(childComplexity int) int LedgerCreatedAt func(childComplexity int) int @@ -429,13 +428,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.StateChange.Flags(childComplexity), true - case "StateChange.id": - if e.complexity.StateChange.ID == nil { - break - } - - return e.complexity.StateChange.ID(childComplexity), true - case "StateChange.ingestedAt": if e.complexity.StateChange.IngestedAt == nil { break @@ -934,7 +926,6 @@ scalar Int64 # This type has many nullable fields to handle various state change scenarios # TODO: Break state change type into interface design and add sub types that implement the interface type StateChange{ - id: String! accountId: String! stateChangeCategory: StateChangeCategory! stateChangeReason: StateChangeReason @@ -962,8 +953,8 @@ type StateChange{ keyValue: String # GraphQL Relationships - these fields use resolvers - # Related operation - operation: Operation! @goField(forceResolver: true) + # Related operation - nullable since fee state changes do not have operations associated with them + operation: Operation @goField(forceResolver: true) # Related transaction transaction: Transaction! @goField(forceResolver: true) @@ -1496,8 +1487,6 @@ func (ec *executionContext) fieldContext_Account_stateChanges(_ context.Context, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_StateChange_id(ctx, field) case "accountId": return ec.fieldContext_StateChange_accountId(ctx, field) case "stateChangeCategory": @@ -2177,8 +2166,6 @@ func (ec *executionContext) fieldContext_Operation_stateChanges(_ context.Contex IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_StateChange_id(ctx, field) case "accountId": return ec.fieldContext_StateChange_accountId(ctx, field) case "stateChangeCategory": @@ -2555,8 +2542,6 @@ func (ec *executionContext) fieldContext_Query_stateChanges(ctx context.Context, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_StateChange_id(ctx, field) case "accountId": return ec.fieldContext_StateChange_accountId(ctx, field) case "stateChangeCategory": @@ -2843,50 +2828,6 @@ func (ec *executionContext) fieldContext_RegisterAccountPayload_account(_ contex return fc, nil } -func (ec *executionContext) _StateChange_id(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_StateChange_id(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) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.ID, 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_StateChange_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "StateChange", - Field: field, - IsMethod: false, - 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) _StateChange_accountId(ctx context.Context, field graphql.CollectedField, obj *types.StateChange) (ret graphql.Marshaler) { fc, err := ec.fieldContext_StateChange_accountId(ctx, field) if err != nil { @@ -3702,14 +3643,11 @@ func (ec *executionContext) _StateChange_operation(ctx context.Context, field gr return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } res := resTmp.(*types.Operation) fc.Result = res - return ec.marshalNOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx, field.Selections, res) + return ec.marshalOOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_StateChange_operation(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -4276,8 +4214,6 @@ func (ec *executionContext) fieldContext_Transaction_stateChanges(_ context.Cont IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_StateChange_id(ctx, field) case "accountId": return ec.fieldContext_StateChange_accountId(ctx, field) case "stateChangeCategory": @@ -6993,11 +6929,6 @@ func (ec *executionContext) _StateChange(ctx context.Context, sel ast.SelectionS switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("StateChange") - case "id": - out.Values[i] = ec._StateChange_id(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } case "accountId": out.Values[i] = ec._StateChange_accountId(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -7457,16 +7388,13 @@ func (ec *executionContext) _StateChange(ctx context.Context, sel ast.SelectionS case "operation": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._StateChange_operation(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -8166,10 +8094,6 @@ func (ec *executionContext) marshalNInt642int64(ctx context.Context, sel ast.Sel return res } -func (ec *executionContext) marshalNOperation2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx context.Context, sel ast.SelectionSet, v types.Operation) graphql.Marshaler { - return ec._Operation(ctx, sel, &v) -} - func (ec *executionContext) marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.Operation) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup @@ -8745,6 +8669,13 @@ func (ec *executionContext) marshalOInt2ᚖint32(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) marshalOOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx context.Context, sel ast.SelectionSet, v *types.Operation) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Operation(ctx, sel, v) +} + func (ec *executionContext) unmarshalOStateChangeReason2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeReason(ctx context.Context, v any) (*types.StateChangeReason, error) { if v == nil { return nil, nil diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index 85f523b6..9f9531f1 100644 --- a/internal/serve/graphql/resolvers/account_resolvers_test.go +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -147,12 +147,12 @@ func TestAccountResolver_StateChanges(t *testing.T) { t.Run("success", func(t *testing.T) { mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { assert.Equal(t, []dataloaders.StateChangeColumnsKey{ - {AccountID: "test-account", Columns: "state_changes.id"}, + {AccountID: "test-account", Columns: "state_changes.account_id, state_changes.state_change_category"}, }, keys) results := [][]*types.StateChange{ { - {ID: "sc1", TxHash: "tx1"}, - {ID: "sc2", TxHash: "tx1"}, + {ToID: 1, StateChangeOrder: 1, TxHash: "tx1"}, + {ToID: 1, StateChangeOrder: 2, TxHash: "tx1"}, }, } return results, nil @@ -161,14 +161,16 @@ func TestAccountResolver_StateChanges(t *testing.T) { loaders := &dataloaders.Dataloaders{ StateChangesByAccountLoader: loader, } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentAccount) require.NoError(t, err) require.Len(t, stateChanges, 2) - assert.Equal(t, "sc1", stateChanges[0].ID) - assert.Equal(t, "sc2", stateChanges[1].ID) + assert.Equal(t, int64(1), stateChanges[0].ToID) + assert.Equal(t, int64(1), stateChanges[0].StateChangeOrder) + assert.Equal(t, int64(1), stateChanges[1].ToID) + assert.Equal(t, int64(2), stateChanges[1].StateChangeOrder) }) t.Run("dataloader error", func(t *testing.T) { @@ -179,7 +181,7 @@ func TestAccountResolver_StateChanges(t *testing.T) { loaders := &dataloaders.Dataloaders{ StateChangesByAccountLoader: loader, } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) _, err := resolver.StateChanges(ctx, parentAccount) diff --git a/internal/serve/graphql/resolvers/operation_resolvers_test.go b/internal/serve/graphql/resolvers/operation_resolvers_test.go index d52de6b6..277e7b52 100644 --- a/internal/serve/graphql/resolvers/operation_resolvers_test.go +++ b/internal/serve/graphql/resolvers/operation_resolvers_test.go @@ -110,11 +110,11 @@ func TestOperationResolver_StateChanges(t *testing.T) { t.Run("success", func(t *testing.T) { mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { - assert.Equal(t, []dataloaders.StateChangeColumnsKey{{OperationID: 123, Columns: "id"}}, keys) + assert.Equal(t, []dataloaders.StateChangeColumnsKey{{OperationID: 123, Columns: "account_id, state_change_category"}}, keys) results := [][]*types.StateChange{ { - {ID: "sc1"}, - {ID: "sc2"}, + {ToID: 1, StateChangeOrder: 1}, + {ToID: 1, StateChangeOrder: 2}, }, } return results, nil @@ -123,14 +123,16 @@ func TestOperationResolver_StateChanges(t *testing.T) { loaders := &dataloaders.Dataloaders{ StateChangesByOperationIDLoader: loader, } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentOperation) require.NoError(t, err) require.Len(t, stateChanges, 2) - assert.Equal(t, "sc1", stateChanges[0].ID) - assert.Equal(t, "sc2", stateChanges[1].ID) + assert.Equal(t, int64(1), stateChanges[0].ToID) + assert.Equal(t, int64(1), stateChanges[0].StateChangeOrder) + assert.Equal(t, int64(1), stateChanges[1].ToID) + assert.Equal(t, int64(2), stateChanges[1].StateChangeOrder) }) t.Run("dataloader error", func(t *testing.T) { diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index d6bc1c4e..4165eb53 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -363,7 +363,8 @@ func TestQueryResolver_StateChanges(t *testing.T) { require.NoError(t, dbErr) sc1 := &types.StateChange{ - ID: "sc1", + ToID: 1, + StateChangeOrder: 1, StateChangeCategory: types.StateChangeCategoryCredit, TxHash: "tx1", OperationID: 1, @@ -372,7 +373,8 @@ func TestQueryResolver_StateChanges(t *testing.T) { LedgerNumber: 1, } sc2 := &types.StateChange{ - ID: "sc2", + ToID: 1, + StateChangeOrder: 2, StateChangeCategory: types.StateChangeCategoryDebit, TxHash: "tx1", OperationID: 1, @@ -383,9 +385,9 @@ func TestQueryResolver_StateChanges(t *testing.T) { dbErr = db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { _, err := tx.ExecContext(ctx, - `INSERT INTO state_changes (id, state_change_category, tx_hash, operation_id, account_id, ledger_created_at, ledger_number) VALUES ($1, $2, $3, $4, $5, $6, $7), ($8, $9, $10, $11, $12, $13, $14)`, - sc1.ID, sc1.StateChangeCategory, sc1.TxHash, sc1.OperationID, sc1.AccountID, sc1.LedgerCreatedAt, sc1.LedgerNumber, - sc2.ID, sc2.StateChangeCategory, sc2.TxHash, sc2.OperationID, sc2.AccountID, sc2.LedgerCreatedAt, sc2.LedgerNumber) + `INSERT INTO state_changes (to_id, state_change_order, state_change_category, tx_hash, operation_id, account_id, ledger_created_at, ledger_number) VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16)`, + sc1.ToID, sc1.StateChangeOrder, sc1.StateChangeCategory, sc1.TxHash, sc1.OperationID, sc1.AccountID, sc1.LedgerCreatedAt, sc1.LedgerNumber, + sc2.ToID, sc2.StateChangeOrder, sc2.StateChangeCategory, sc2.TxHash, sc2.OperationID, sc2.AccountID, sc2.LedgerCreatedAt, sc2.LedgerNumber) require.NoError(t, err) return nil }) @@ -396,12 +398,14 @@ func TestQueryResolver_StateChanges(t *testing.T) { resolver.models.StateChanges.MetricsService = mockMetricsService mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() - ctx := GetTestCtx("state_changes", []string{"id", "stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) + ctx := GetTestCtx("state_changes", []string{"stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) scs, err := resolver.StateChanges(ctx, nil) require.NoError(t, err) assert.Len(t, scs, 2) - assert.Contains(t, []string{"sc1", "sc2"}, scs[0].ID) - assert.Contains(t, []string{"sc1", "sc2"}, scs[1].ID) + // Verify the state changes have the expected account IDs + accountIDs := []string{scs[0].AccountID, scs[1].AccountID} + assert.Contains(t, accountIDs, "account1") + assert.Contains(t, accountIDs, "account2") mockMetricsService.AssertExpectations(t) }) diff --git a/internal/serve/graphql/resolvers/statechange.resolvers.go b/internal/serve/graphql/resolvers/statechange.resolvers.go index 53a90881..796e1c01 100644 --- a/internal/serve/graphql/resolvers/statechange.resolvers.go +++ b/internal/serve/graphql/resolvers/statechange.resolvers.go @@ -152,8 +152,9 @@ func (r *stateChangeResolver) Operation(ctx context.Context, obj *types.StateCha loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "operations") + stateChangeID := fmt.Sprintf("%d-%d", obj.ToID, obj.StateChangeOrder) loaderKey := dataloaders.OperationColumnsKey{ - StateChangeID: obj.ID, + StateChangeID: stateChangeID, Columns: strings.Join(dbColumns, ", "), } operations, err := loaders.OperationByStateChangeIDLoader.Load(ctx, loaderKey) @@ -168,8 +169,9 @@ func (r *stateChangeResolver) Transaction(ctx context.Context, obj *types.StateC loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + stateChangeID := fmt.Sprintf("%d-%d", obj.ToID, obj.StateChangeOrder) loaderKey := dataloaders.TransactionColumnsKey{ - StateChangeID: obj.ID, + StateChangeID: stateChangeID, Columns: strings.Join(dbColumns, ", "), } transaction, err := loaders.TransactionByStateChangeIDLoader.Load(ctx, loaderKey) diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go index 7448cf29..2abf6b95 100644 --- a/internal/serve/graphql/resolvers/statechange_resolvers_test.go +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -149,11 +149,12 @@ func TestStateChangeResolver_JSONFields(t *testing.T) { func TestStateChangeResolver_Operation(t *testing.T) { resolver := &stateChangeResolver{&Resolver{}} - parentSC := &types.StateChange{ID: "test-sc-id"} + parentSC := &types.StateChange{ToID: 1, StateChangeOrder: 1} + expectedStateChangeID := "1-1" t.Run("success", func(t *testing.T) { mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([]*types.Operation, []error) { - assert.Equal(t, []dataloaders.OperationColumnsKey{{StateChangeID: "test-sc-id", Columns: "operations.id"}}, keys) + assert.Equal(t, []dataloaders.OperationColumnsKey{{StateChangeID: expectedStateChangeID, Columns: "operations.id"}}, keys) return []*types.Operation{{ID: 99}}, nil } @@ -187,11 +188,12 @@ func TestStateChangeResolver_Operation(t *testing.T) { func TestStateChangeResolver_Transaction(t *testing.T) { resolver := &stateChangeResolver{&Resolver{}} - parentSC := &types.StateChange{ID: "test-sc-id"} + parentSC := &types.StateChange{ToID: 2, StateChangeOrder: 3} + expectedStateChangeID := "2-3" t.Run("success", func(t *testing.T) { mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { - assert.Equal(t, []dataloaders.TransactionColumnsKey{{StateChangeID: "test-sc-id", Columns: "transactions.hash"}}, keys) + assert.Equal(t, []dataloaders.TransactionColumnsKey{{StateChangeID: expectedStateChangeID, Columns: "transactions.hash"}}, keys) return []*types.Transaction{{Hash: "tx-abc"}}, nil } diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index a5a8b311..117a96bc 100644 --- a/internal/serve/graphql/resolvers/transaction_resolvers_test.go +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -119,12 +119,12 @@ func TestTransactionResolver_StateChanges(t *testing.T) { t.Run("success", func(t *testing.T) { mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { assert.Equal(t, []dataloaders.StateChangeColumnsKey{ - {TxHash: "test-tx-hash", Columns: "id"}, + {TxHash: "test-tx-hash", Columns: "account_id, state_change_category"}, }, keys) results := [][]*types.StateChange{ { - {ID: "sc1"}, - {ID: "sc2"}, + {ToID: 1, StateChangeOrder: 1}, + {ToID: 1, StateChangeOrder: 2}, }, } return results, nil @@ -133,14 +133,16 @@ func TestTransactionResolver_StateChanges(t *testing.T) { loaders := &dataloaders.Dataloaders{ StateChangesByTxHashLoader: loader, } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentTx) require.NoError(t, err) require.Len(t, stateChanges, 2) - assert.Equal(t, "sc1", stateChanges[0].ID) - assert.Equal(t, "sc2", stateChanges[1].ID) + assert.Equal(t, int64(1), stateChanges[0].ToID) + assert.Equal(t, int64(1), stateChanges[0].StateChangeOrder) + assert.Equal(t, int64(1), stateChanges[1].ToID) + assert.Equal(t, int64(2), stateChanges[1].StateChangeOrder) }) t.Run("dataloader error", func(t *testing.T) { diff --git a/internal/serve/graphql/schema/statechange.graphqls b/internal/serve/graphql/schema/statechange.graphqls index 1724ff63..700bebe1 100644 --- a/internal/serve/graphql/schema/statechange.graphqls +++ b/internal/serve/graphql/schema/statechange.graphqls @@ -2,7 +2,6 @@ # This type has many nullable fields to handle various state change scenarios # TODO: Break state change type into interface design and add sub types that implement the interface type StateChange{ - id: String! accountId: String! stateChangeCategory: StateChangeCategory! stateChangeReason: StateChangeReason @@ -30,8 +29,8 @@ type StateChange{ keyValue: String # GraphQL Relationships - these fields use resolvers - # Related operation - operation: Operation! @goField(forceResolver: true) + # Related operation - nullable since fee state changes do not have operations associated with them + operation: Operation @goField(forceResolver: true) # Related transaction transaction: Transaction! @goField(forceResolver: true) From 61f019c5a7b95e1bb75c8c63f5771dc9c0f2d59c Mon Sep 17 00:00:00 2001 From: akcays Date: Tue, 12 Aug 2025 09:58:51 -0400 Subject: [PATCH 05/16] Move `/transactions/build/` from REST to GraphQL (#260) * Move /transactions/build endpoint from REST to GraphQL * Fix shadowing error * Add tests for soroban smart contract simulation * Fix imports check * Fix test error * Fix build transaction integration tests * Fix test * Fix lint check * Remove transactions http handler * Update type names to transaction * Update tests * Add custom errors to handle different tx validation failures Transaction Validation: * ErrInvalidTimeout - Timeout exceeds maximum allowed * ErrInvalidOperationChannelAccount - Operation uses channel account as source * ErrInvalidOperationMissingSource - Non-Soroban operation missing source account Soroban-Specific Validation: * ErrInvalidSorobanOperationCount - Must have exactly one operation * ErrInvalidSorobanSimulationEmpty - Missing simulation response * ErrInvalidSorobanSimulationFailed - Simulation failed with error * ErrInvalidSorobanOperationType - Unsupported operation type * Remove deadcode * Fix goimports check * Fix tests * Update errors add logs and todos * Update tests * Remove redundant error handling * Refactor --- internal/integrationtests/integrationtests.go | 10 +- internal/serve/graphql/generated/generated.go | 498 ++++++++++++++++++ .../serve/graphql/generated/models_gen.go | 24 + .../graphql/resolvers/mutations.resolvers.go | 140 +++++ .../resolvers/mutations_resolvers_test.go | 420 +++++++++++++++ internal/serve/graphql/resolvers/resolver.go | 12 +- .../serve/graphql/schema/mutations.graphqls | 31 ++ .../serve/httphandler/transactions_handler.go | 70 --- .../httphandler/transactions_handler_test.go | 127 ----- internal/serve/serve.go | 13 +- internal/transactions/services/mocks.go | 30 -- .../services/transaction_service.go | 24 +- .../services/transaction_service_test.go | 14 +- pkg/wbclient/client.go | 121 ++++- pkg/wbclient/types/types.go | 4 + 15 files changed, 1268 insertions(+), 270 deletions(-) delete mode 100644 internal/serve/httphandler/transactions_handler.go delete mode 100644 internal/serve/httphandler/transactions_handler_test.go delete mode 100644 internal/transactions/services/mocks.go diff --git a/internal/integrationtests/integrationtests.go b/internal/integrationtests/integrationtests.go index e5693f49..369d1603 100644 --- a/internal/integrationtests/integrationtests.go +++ b/internal/integrationtests/integrationtests.go @@ -20,7 +20,6 @@ import ( "github.com/stellar/wallet-backend/internal/signing/store" "github.com/stellar/wallet-backend/pkg/utils" "github.com/stellar/wallet-backend/pkg/wbclient" - "github.com/stellar/wallet-backend/pkg/wbclient/types" ) const txTimeout = 45 * time.Second @@ -86,17 +85,16 @@ func (it *IntegrationTests) Run(ctx context.Context) error { log.Ctx(ctx).Debugf("👀 useCases: %+v", useCases) - // Step 2: call /transactions/build + // Step 2: call GraphQL buildTransaction mutation fmt.Println("") log.Ctx(ctx).Info("===> 2️⃣ [WalletBackend] Building transactions...") for _, useCase := range useCases { - buildTxRequest := types.BuildTransactionsRequest{Transactions: []types.Transaction{useCase.requestedTransaction}} - builtTxResponse, err := it.WBClient.BuildTransactions(ctx, buildTxRequest.Transactions...) + builtTxResponse, err := it.WBClient.BuildTransaction(ctx, useCase.requestedTransaction) if err != nil { - return fmt.Errorf("calling buildTransactions: %w", err) + return fmt.Errorf("calling buildTransaction: %w", err) } log.Ctx(ctx).Debugf("✅ [%s] builtTxResponse: %+v", useCase.Name(), builtTxResponse) - useCase.builtTransactionXDR = builtTxResponse.TransactionXDRs[0] + useCase.builtTransactionXDR = builtTxResponse.TransactionXDR txString, err := txString(useCase.builtTransactionXDR) if err != nil { diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 6fa71825..3cb44551 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -60,12 +60,18 @@ type ComplexityRoot struct { Transactions func(childComplexity int) int } + BuildTransactionPayload struct { + Success func(childComplexity int) int + TransactionXdr func(childComplexity int) int + } + DeregisterAccountPayload struct { Message func(childComplexity int) int Success func(childComplexity int) int } Mutation struct { + BuildTransaction func(childComplexity int, input BuildTransactionInput) int DeregisterAccount func(childComplexity int, input DeregisterAccountInput) int RegisterAccount func(childComplexity int, input RegisterAccountInput) int } @@ -142,6 +148,7 @@ type AccountResolver interface { type MutationResolver interface { RegisterAccount(ctx context.Context, input RegisterAccountInput) (*RegisterAccountPayload, error) DeregisterAccount(ctx context.Context, input DeregisterAccountInput) (*DeregisterAccountPayload, error) + BuildTransaction(ctx context.Context, input BuildTransactionInput) (*BuildTransactionPayload, error) } type OperationResolver interface { Transaction(ctx context.Context, obj *types.Operation) (*types.Transaction, error) @@ -225,6 +232,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Account.Transactions(childComplexity), true + case "BuildTransactionPayload.success": + if e.complexity.BuildTransactionPayload.Success == nil { + break + } + + return e.complexity.BuildTransactionPayload.Success(childComplexity), true + + case "BuildTransactionPayload.transactionXdr": + if e.complexity.BuildTransactionPayload.TransactionXdr == nil { + break + } + + return e.complexity.BuildTransactionPayload.TransactionXdr(childComplexity), true + case "DeregisterAccountPayload.message": if e.complexity.DeregisterAccountPayload.Message == nil { break @@ -239,6 +260,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.DeregisterAccountPayload.Success(childComplexity), true + case "Mutation.buildTransaction": + if e.complexity.Mutation.BuildTransaction == nil { + break + } + + args, err := ec.field_Mutation_buildTransaction_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.BuildTransaction(childComplexity, args["input"].(BuildTransactionInput)), true + case "Mutation.deregisterAccount": if e.complexity.Mutation.DeregisterAccount == nil { break @@ -625,8 +658,11 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { opCtx := graphql.GetOperationContext(ctx) ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)} inputUnmarshalMap := graphql.BuildUnmarshalerMap( + ec.unmarshalInputBuildTransactionInput, ec.unmarshalInputDeregisterAccountInput, ec.unmarshalInputRegisterAccountInput, + ec.unmarshalInputSimulationResultInput, + ec.unmarshalInputTransactionInput, ) first := true @@ -850,6 +886,9 @@ type Mutation { # Account management mutations registerAccount(input: RegisterAccountInput!): RegisterAccountPayload! deregisterAccount(input: DeregisterAccountInput!): DeregisterAccountPayload! + + # Transaction mutations + buildTransaction(input: BuildTransactionInput!): BuildTransactionPayload! } # Input types for account mutations @@ -871,6 +910,34 @@ type DeregisterAccountPayload { success: Boolean! message: String } + +# Input types for transaction mutations +input BuildTransactionInput { + transaction: TransactionInput! +} + +# TODO: Update transaction input to include all attributes of the transaction. +input TransactionInput { + operations: [String!]! + timeout: Int! + simulationResult: SimulationResultInput +} + +# Optional simulation result input for Soroban transactions +input SimulationResultInput { + transactionData: String + events: [String!] + minResourceFee: String + results: [String!] + latestLedger: Int + error: String +} + +# Payload types for transaction mutations +type BuildTransactionPayload { + success: Boolean! + transactionXdr: String! +} `, BuiltIn: false}, {Name: "../schema/operation.graphqls", Input: `# GraphQL Operation type - represents a blockchain operation # Operations are the individual actions within a transaction @@ -990,6 +1057,29 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...) // region ***************************** args.gotpl ***************************** +func (ec *executionContext) field_Mutation_buildTransaction_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_buildTransaction_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_buildTransaction_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (BuildTransactionInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNBuildTransactionInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBuildTransactionInput(ctx, tmp) + } + + var zeroVal BuildTransactionInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_deregisterAccount_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1536,6 +1626,94 @@ func (ec *executionContext) fieldContext_Account_stateChanges(_ context.Context, return fc, nil } +func (ec *executionContext) _BuildTransactionPayload_success(ctx context.Context, field graphql.CollectedField, obj *BuildTransactionPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_BuildTransactionPayload_success(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Success, 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_BuildTransactionPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BuildTransactionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _BuildTransactionPayload_transactionXdr(ctx context.Context, field graphql.CollectedField, obj *BuildTransactionPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_BuildTransactionPayload_transactionXdr(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.TransactionXdr, 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_BuildTransactionPayload_transactionXdr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BuildTransactionPayload", + Field: field, + IsMethod: false, + 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) _DeregisterAccountPayload_success(ctx context.Context, field graphql.CollectedField, obj *DeregisterAccountPayload) (ret graphql.Marshaler) { fc, err := ec.fieldContext_DeregisterAccountPayload_success(ctx, field) if err != nil { @@ -1743,6 +1921,67 @@ func (ec *executionContext) fieldContext_Mutation_deregisterAccount(ctx context. return fc, nil } +func (ec *executionContext) _Mutation_buildTransaction(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_buildTransaction(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().BuildTransaction(rctx, fc.Args["input"].(BuildTransactionInput)) + }) + 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.(*BuildTransactionPayload) + fc.Result = res + return ec.marshalNBuildTransactionPayload2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBuildTransactionPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_buildTransaction(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "success": + return ec.fieldContext_BuildTransactionPayload_success(ctx, field) + case "transactionXdr": + return ec.fieldContext_BuildTransactionPayload_transactionXdr(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type BuildTransactionPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_buildTransaction_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Operation_id(ctx context.Context, field graphql.CollectedField, obj *types.Operation) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Operation_id(ctx, field) if err != nil { @@ -6214,6 +6453,33 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field // region **************************** input.gotpl ***************************** +func (ec *executionContext) unmarshalInputBuildTransactionInput(ctx context.Context, obj any) (BuildTransactionInput, error) { + var it BuildTransactionInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"transaction"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "transaction": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("transaction")) + data, err := ec.unmarshalNTransactionInput2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionInput(ctx, v) + if err != nil { + return it, err + } + it.Transaction = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputDeregisterAccountInput(ctx context.Context, obj any) (DeregisterAccountInput, error) { var it DeregisterAccountInput asMap := map[string]any{} @@ -6268,6 +6534,109 @@ func (ec *executionContext) unmarshalInputRegisterAccountInput(ctx context.Conte return it, nil } +func (ec *executionContext) unmarshalInputSimulationResultInput(ctx context.Context, obj any) (SimulationResultInput, error) { + var it SimulationResultInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"transactionData", "events", "minResourceFee", "results", "latestLedger", "error"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "transactionData": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("transactionData")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.TransactionData = data + case "events": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("events")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.Events = data + case "minResourceFee": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("minResourceFee")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.MinResourceFee = data + case "results": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("results")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.Results = data + case "latestLedger": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("latestLedger")) + data, err := ec.unmarshalOInt2ᚖint32(ctx, v) + if err != nil { + return it, err + } + it.LatestLedger = data + case "error": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("error")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Error = data + } + } + + return it, nil +} + +func (ec *executionContext) unmarshalInputTransactionInput(ctx context.Context, obj any) (TransactionInput, error) { + var it TransactionInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"operations", "timeout", "simulationResult"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "operations": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("operations")) + data, err := ec.unmarshalNString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.Operations = data + case "timeout": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("timeout")) + data, err := ec.unmarshalNInt2int32(ctx, v) + if err != nil { + return it, err + } + it.Timeout = data + case "simulationResult": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("simulationResult")) + data, err := ec.unmarshalOSimulationResultInput2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐSimulationResultInput(ctx, v) + if err != nil { + return it, err + } + it.SimulationResult = data + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -6454,6 +6823,50 @@ func (ec *executionContext) _Account(ctx context.Context, sel ast.SelectionSet, return out } +var buildTransactionPayloadImplementors = []string{"BuildTransactionPayload"} + +func (ec *executionContext) _BuildTransactionPayload(ctx context.Context, sel ast.SelectionSet, obj *BuildTransactionPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, buildTransactionPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("BuildTransactionPayload") + case "success": + out.Values[i] = ec._BuildTransactionPayload_success(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "transactionXdr": + out.Values[i] = ec._BuildTransactionPayload_transactionXdr(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var deregisterAccountPayloadImplementors = []string{"DeregisterAccountPayload"} func (ec *executionContext) _DeregisterAccountPayload(ctx context.Context, sel ast.SelectionSet, obj *DeregisterAccountPayload) graphql.Marshaler { @@ -6528,6 +6941,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "buildTransaction": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_buildTransaction(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8059,6 +8479,25 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) unmarshalNBuildTransactionInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBuildTransactionInput(ctx context.Context, v any) (BuildTransactionInput, error) { + res, err := ec.unmarshalInputBuildTransactionInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNBuildTransactionPayload2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBuildTransactionPayload(ctx context.Context, sel ast.SelectionSet, v BuildTransactionPayload) graphql.Marshaler { + return ec._BuildTransactionPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNBuildTransactionPayload2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBuildTransactionPayload(ctx context.Context, sel ast.SelectionSet, v *BuildTransactionPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._BuildTransactionPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNDeregisterAccountInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐDeregisterAccountInput(ctx context.Context, v any) (DeregisterAccountInput, error) { res, err := ec.unmarshalInputDeregisterAccountInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -8078,6 +8517,22 @@ func (ec *executionContext) marshalNDeregisterAccountPayload2ᚖgithubᚗcomᚋs return ec._DeregisterAccountPayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNInt2int32(ctx context.Context, v any) (int32, error) { + res, err := graphql.UnmarshalInt32(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNInt2int32(ctx context.Context, sel ast.SelectionSet, v int32) graphql.Marshaler { + _ = sel + res := graphql.MarshalInt32(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + func (ec *executionContext) unmarshalNInt642int64(ctx context.Context, v any) (int64, error) { res, err := graphql.UnmarshalInt64(v) return res, graphql.ErrorOnPath(ctx, err) @@ -8271,6 +8726,36 @@ func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.S return res } +func (ec *executionContext) unmarshalNString2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalNString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2string(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalNTime2timeᚐTime(ctx context.Context, v any) (time.Time, error) { res, err := graphql.UnmarshalTime(v) return res, graphql.ErrorOnPath(ctx, err) @@ -8345,6 +8830,11 @@ func (ec *executionContext) marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwall return ec._Transaction(ctx, sel, v) } +func (ec *executionContext) unmarshalNTransactionInput2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionInput(ctx context.Context, v any) (*TransactionInput, error) { + res, err := ec.unmarshalInputTransactionInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNUInt322uint32(ctx context.Context, v any) (uint32, error) { res, err := scalars.UnmarshalUInt32(v) return res, graphql.ErrorOnPath(ctx, err) @@ -8676,6 +9166,14 @@ func (ec *executionContext) marshalOOperation2ᚖgithubᚗcomᚋstellarᚋwallet return ec._Operation(ctx, sel, v) } +func (ec *executionContext) unmarshalOSimulationResultInput2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐSimulationResultInput(ctx context.Context, v any) (*SimulationResultInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputSimulationResultInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOStateChangeReason2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeReason(ctx context.Context, v any) (*types.StateChangeReason, error) { if v == nil { return nil, nil diff --git a/internal/serve/graphql/generated/models_gen.go b/internal/serve/graphql/generated/models_gen.go index 17332957..19fc5545 100644 --- a/internal/serve/graphql/generated/models_gen.go +++ b/internal/serve/graphql/generated/models_gen.go @@ -6,6 +6,15 @@ import ( "github.com/stellar/wallet-backend/internal/indexer/types" ) +type BuildTransactionInput struct { + Transaction *TransactionInput `json:"transaction"` +} + +type BuildTransactionPayload struct { + Success bool `json:"success"` + TransactionXdr string `json:"transactionXdr"` +} + type DeregisterAccountInput struct { Address string `json:"address"` } @@ -29,3 +38,18 @@ type RegisterAccountPayload struct { Success bool `json:"success"` Account *types.Account `json:"account,omitempty"` } + +type SimulationResultInput struct { + TransactionData *string `json:"transactionData,omitempty"` + Events []string `json:"events,omitempty"` + MinResourceFee *string `json:"minResourceFee,omitempty"` + Results []string `json:"results,omitempty"` + LatestLedger *int32 `json:"latestLedger,omitempty"` + Error *string `json:"error,omitempty"` +} + +type TransactionInput struct { + Operations []string `json:"operations"` + Timeout int32 `json:"timeout"` + SimulationResult *SimulationResultInput `json:"simulationResult,omitempty"` +} diff --git a/internal/serve/graphql/resolvers/mutations.resolvers.go b/internal/serve/graphql/resolvers/mutations.resolvers.go index 20a543c2..7a4b8444 100644 --- a/internal/serve/graphql/resolvers/mutations.resolvers.go +++ b/internal/serve/graphql/resolvers/mutations.resolvers.go @@ -10,12 +10,20 @@ import ( "fmt" "time" + "github.com/stellar/go/support/log" + "github.com/stellar/go/xdr" "github.com/vektah/gqlparser/v2/gqlerror" "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/indexer/types" graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" "github.com/stellar/wallet-backend/internal/services" + "github.com/stellar/wallet-backend/internal/signing" + "github.com/stellar/wallet-backend/internal/signing/store" + transactionservices "github.com/stellar/wallet-backend/internal/transactions/services" + transactionsUtils "github.com/stellar/wallet-backend/internal/transactions/utils" + "github.com/stellar/wallet-backend/pkg/sorobanauth" ) // RegisterAccount is the resolver for the registerAccount field. @@ -84,6 +92,138 @@ func (r *mutationResolver) DeregisterAccount(ctx context.Context, input graphql1 }, nil } +// BuildTransaction is the resolver for the buildTransaction field. +func (r *mutationResolver) BuildTransaction(ctx context.Context, input graphql1.BuildTransactionInput) (*graphql1.BuildTransactionPayload, error) { + transaction := input.Transaction + + ops, err := transactionsUtils.BuildOperations(transaction.Operations) + if err != nil { + return nil, &gqlerror.Error{ + Message: fmt.Sprintf("Invalid operations: %s", err.Error()), + Extensions: map[string]interface{}{ + "code": "INVALID_OPERATIONS", + }, + } + } + + // Convert simulation result if provided + var simulationResult entities.RPCSimulateTransactionResult + if transaction.SimulationResult != nil { + simulationResult = entities.RPCSimulateTransactionResult{ + Events: transaction.SimulationResult.Events, + } + + if transaction.SimulationResult.MinResourceFee != nil { + simulationResult.MinResourceFee = *transaction.SimulationResult.MinResourceFee + } + if transaction.SimulationResult.Error != nil { + simulationResult.Error = *transaction.SimulationResult.Error + } + if transaction.SimulationResult.LatestLedger != nil { + simulationResult.LatestLedger = int64(*transaction.SimulationResult.LatestLedger) + } + + // Handle TransactionData if provided + if transaction.SimulationResult.TransactionData != nil { + var txData xdr.SorobanTransactionData + if txDataErr := xdr.SafeUnmarshalBase64(*transaction.SimulationResult.TransactionData, &txData); txDataErr != nil { + return nil, &gqlerror.Error{ + Message: fmt.Sprintf("Invalid TransactionData: %s", txDataErr.Error()), + Extensions: map[string]interface{}{ + "code": "INVALID_TRANSACTION_DATA", + }, + } + } + simulationResult.TransactionData = txData + } + } + + tx, err := r.transactionService.BuildAndSignTransactionWithChannelAccount(ctx, ops, int64(transaction.Timeout), simulationResult) + if err != nil { + switch { + case errors.Is(err, transactionservices.ErrInvalidTimeout): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "INVALID_TIMEOUT", + }, + } + case errors.Is(err, transactionservices.ErrInvalidOperationChannelAccount): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "INVALID_OPERATION_CHANNEL_ACCOUNT", + }, + } + case errors.Is(err, transactionservices.ErrInvalidOperationMissingSource): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "INVALID_OPERATION_MISSING_SOURCE", + }, + } + case errors.Is(err, transactionservices.ErrInvalidSorobanOperationCount): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "INVALID_SOROBAN_OPERATION_COUNT", + }, + } + case errors.Is(err, transactionservices.ErrInvalidSorobanSimulationEmpty): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "INVALID_SOROBAN_SIMULATION_EMPTY", + }, + } + case errors.Is(err, transactionservices.ErrInvalidSorobanSimulationFailed): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "INVALID_SOROBAN_SIMULATION_FAILED", + }, + } + case errors.Is(err, transactionservices.ErrInvalidSorobanOperationType): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "INVALID_SOROBAN_OPERATION_TYPE", + }, + } + case errors.Is(err, signing.ErrUnavailableChannelAccounts), errors.Is(err, store.ErrNoIdleChannelAccountAvailable): + return nil, &gqlerror.Error{ + Message: "unable to assign a channel account", + Extensions: map[string]interface{}{ + "code": "CHANNEL_ACCOUNT_UNAVAILABLE", + }, + } + case errors.Is(err, sorobanauth.ErrForbiddenSigner): + return nil, &gqlerror.Error{ + Message: err.Error(), + Extensions: map[string]interface{}{ + "code": "FORBIDDEN_SIGNER", + }, + } + default: + log.Errorf("Failed to build transaction: %v", err) + return nil, &gqlerror.Error{ + Message: "Failed to build transaction", + Extensions: map[string]interface{}{ + "code": "TRANSACTION_BUILD_FAILED", + }, + } + } + } + + // Convert transaction to XDR string + txXdrStr, _ := tx.Base64() + + return &graphql1.BuildTransactionPayload{ + Success: true, + TransactionXdr: txXdrStr, + }, nil +} + // Mutation returns graphql1.MutationResolver implementation. func (r *Resolver) Mutation() graphql1.MutationResolver { return &mutationResolver{r} } diff --git a/internal/serve/graphql/resolvers/mutations_resolvers_test.go b/internal/serve/graphql/resolvers/mutations_resolvers_test.go index c673bcb9..7512ff57 100644 --- a/internal/serve/graphql/resolvers/mutations_resolvers_test.go +++ b/internal/serve/graphql/resolvers/mutations_resolvers_test.go @@ -2,14 +2,22 @@ package resolvers import ( "context" + "encoding/base64" "errors" + "strings" "testing" + xdr3 "github.com/stellar/go-xdr/xdr3" + "github.com/stellar/go/keypair" + "github.com/stellar/go/txnbuild" + "github.com/stellar/go/xdr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/vektah/gqlparser/v2/gqlerror" "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/entities" graphql "github.com/stellar/wallet-backend/internal/serve/graphql/generated" "github.com/stellar/wallet-backend/internal/services" ) @@ -28,6 +36,20 @@ func (m *mockAccountService) DeregisterAccount(ctx context.Context, address stri return args.Error(0) } +type mockTransactionService struct { + mock.Mock +} + +func (m *mockTransactionService) NetworkPassphrase() string { + args := m.Called() + return args.String(0) +} + +func (m *mockTransactionService) BuildAndSignTransactionWithChannelAccount(ctx context.Context, operations []txnbuild.Operation, timeoutInSecs int64, simulationResult entities.RPCSimulateTransactionResult) (*txnbuild.Transaction, error) { + args := m.Called(ctx, operations, timeoutInSecs, simulationResult) + return args.Get(0).(*txnbuild.Transaction), args.Error(1) +} + func TestMutationResolver_RegisterAccount(t *testing.T) { ctx := context.Background() @@ -262,3 +284,401 @@ func TestMutationResolver_DeregisterAccount(t *testing.T) { mockService.AssertExpectations(t) }) } + +func TestMutationResolver_BuildTransaction(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mockAccountService := &mockAccountService{} + mockTransactionService := &mockTransactionService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockAccountService, + transactionService: mockTransactionService, + models: &data.Models{}, + }, + } + + // Create a test transaction + sourceAccount := keypair.MustRandom() + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{AccountID: sourceAccount.Address()}, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Asset: txnbuild.NativeAsset{}, + Amount: "10", + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + // Create a valid operation XDR + srcAccount := keypair.MustRandom().Address() + p := txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + SourceAccount: srcAccount, + } + op, err := p.BuildXDR() + require.NoError(t, err) + + var buf strings.Builder + enc := xdr3.NewEncoder(&buf) + err = op.EncodeTo(enc) + require.NoError(t, err) + + opXDR := buf.String() + operationXDR := base64.StdEncoding.EncodeToString([]byte(opXDR)) + + input := graphql.BuildTransactionInput{ + Transaction: &graphql.TransactionInput{ + Operations: []string{operationXDR}, + Timeout: 30, + }, + } + + mockTransactionService.On("BuildAndSignTransactionWithChannelAccount", ctx, mock.AnythingOfType("[]txnbuild.Operation"), int64(30), mock.AnythingOfType("entities.RPCSimulateTransactionResult")).Return(tx, nil) + + result, err := resolver.BuildTransaction(ctx, input) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.Success) + assert.NotEmpty(t, result.TransactionXdr) + + mockTransactionService.AssertExpectations(t) + }) + + t.Run("invalid operations", func(t *testing.T) { + mockAccountService := &mockAccountService{} + mockTransactionService := &mockTransactionService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockAccountService, + transactionService: mockTransactionService, + models: &data.Models{}, + }, + } + + input := graphql.BuildTransactionInput{ + Transaction: &graphql.TransactionInput{ + Operations: []string{"invalid-xdr"}, + Timeout: 30, + }, + } + + result, err := resolver.BuildTransaction(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Invalid operations") + }) + + t.Run("transaction service error", func(t *testing.T) { + mockAccountService := &mockAccountService{} + mockTransactionService := &mockTransactionService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockAccountService, + transactionService: mockTransactionService, + models: &data.Models{}, + }, + } + + // Create a valid operation XDR + srcAccount := keypair.MustRandom().Address() + p := txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + SourceAccount: srcAccount, + } + op, err := p.BuildXDR() + require.NoError(t, err) + + var buf strings.Builder + enc := xdr3.NewEncoder(&buf) + err = op.EncodeTo(enc) + require.NoError(t, err) + + opXDR := buf.String() + operationXDR := base64.StdEncoding.EncodeToString([]byte(opXDR)) + + input := graphql.BuildTransactionInput{ + Transaction: &graphql.TransactionInput{ + Operations: []string{operationXDR}, + Timeout: 30, + }, + } + + mockTransactionService.On("BuildAndSignTransactionWithChannelAccount", ctx, mock.AnythingOfType("[]txnbuild.Operation"), int64(30), mock.AnythingOfType("entities.RPCSimulateTransactionResult")).Return((*txnbuild.Transaction)(nil), errors.New("transaction build failed")) + + result, err := resolver.BuildTransaction(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Failed to build transaction") + + mockTransactionService.AssertExpectations(t) + }) + + t.Run("with simulation result", func(t *testing.T) { + mockAccountService := &mockAccountService{} + mockTransactionService := &mockTransactionService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockAccountService, + transactionService: mockTransactionService, + models: &data.Models{}, + }, + } + + // Create a test transaction + sourceAccount := keypair.MustRandom() + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{AccountID: sourceAccount.Address()}, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Asset: txnbuild.NativeAsset{}, + Amount: "10", + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + // Create a valid operation XDR + srcAccount := keypair.MustRandom().Address() + p := txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + SourceAccount: srcAccount, + } + op, err := p.BuildXDR() + require.NoError(t, err) + + var buf strings.Builder + enc := xdr3.NewEncoder(&buf) + err = op.EncodeTo(enc) + require.NoError(t, err) + + opXDR := buf.String() + operationXDR := base64.StdEncoding.EncodeToString([]byte(opXDR)) + + // Create simulation result + latestLedger := int32(12345) + minResourceFee := "1000000" + errorMsg := "simulation error example" + events := []string{"event1", "event2"} + + transactionData := (*string)(nil) + + input := graphql.BuildTransactionInput{ + Transaction: &graphql.TransactionInput{ + Operations: []string{operationXDR}, + Timeout: 45, + SimulationResult: &graphql.SimulationResultInput{ + LatestLedger: &latestLedger, + MinResourceFee: &minResourceFee, + Error: &errorMsg, + Events: events, + TransactionData: transactionData, + }, + }, + } + + mockTransactionService.On("BuildAndSignTransactionWithChannelAccount", + ctx, + mock.AnythingOfType("[]txnbuild.Operation"), + int64(45), + mock.MatchedBy(func(simResult entities.RPCSimulateTransactionResult) bool { + // Verify all simulation result fields are properly converted + return simResult.LatestLedger == int64(latestLedger) && + simResult.MinResourceFee == minResourceFee && + simResult.Error == errorMsg && + len(simResult.Events) == 2 && + simResult.Events[0] == "event1" && + simResult.Events[1] == "event2" + })).Return(tx, nil) + + result, err := resolver.BuildTransaction(ctx, input) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.Success) + assert.NotEmpty(t, result.TransactionXdr) + + mockTransactionService.AssertExpectations(t) + }) + + t.Run("with valid transaction data", func(t *testing.T) { + mockAccountService := &mockAccountService{} + mockTransactionService := &mockTransactionService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockAccountService, + transactionService: mockTransactionService, + models: &data.Models{}, + }, + } + + sourceAccount := keypair.MustRandom() + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{AccountID: sourceAccount.Address()}, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Asset: txnbuild.NativeAsset{}, + Amount: "10", + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + // Create a valid operation XDR + srcAccount := keypair.MustRandom().Address() + p := txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + SourceAccount: srcAccount, + } + op, err := p.BuildXDR() + require.NoError(t, err) + + var buf strings.Builder + enc := xdr3.NewEncoder(&buf) + err = op.EncodeTo(enc) + require.NoError(t, err) + + opXDR := buf.String() + operationXDR := base64.StdEncoding.EncodeToString([]byte(opXDR)) + + // Create a valid SorobanTransactionData + validTxData := xdr.SorobanTransactionData{ + Ext: xdr.SorobanTransactionDataExt{ + V: 0, // Version 0 + }, + Resources: xdr.SorobanResources{ + Footprint: xdr.LedgerFootprint{ + ReadOnly: []xdr.LedgerKey{}, + ReadWrite: []xdr.LedgerKey{}, + }, + Instructions: 1000000, + DiskReadBytes: 1000, + WriteBytes: 1000, + }, + ResourceFee: 1000000, + } + + validTxDataBase64, err := xdr.MarshalBase64(validTxData) + require.NoError(t, err) + + input := graphql.BuildTransactionInput{ + Transaction: &graphql.TransactionInput{ + Operations: []string{operationXDR}, + Timeout: 30, + SimulationResult: &graphql.SimulationResultInput{ + TransactionData: &validTxDataBase64, + Events: []string{"test-event"}, + }, + }, + } + + mockTransactionService.On("BuildAndSignTransactionWithChannelAccount", + ctx, + mock.AnythingOfType("[]txnbuild.Operation"), + int64(30), + mock.MatchedBy(func(simResult entities.RPCSimulateTransactionResult) bool { + // Verify that TransactionData was successfully parsed and matches our original data + expectedTxDataBase64, marshalErr := xdr.MarshalBase64(simResult.TransactionData) + if marshalErr != nil { + return false + } + return expectedTxDataBase64 == validTxDataBase64 && + len(simResult.Events) == 1 && + simResult.Events[0] == "test-event" + })).Return(tx, nil) + + result, err := resolver.BuildTransaction(ctx, input) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.Success) + assert.NotEmpty(t, result.TransactionXdr) + + mockTransactionService.AssertExpectations(t) + }) + + t.Run("invalid transaction data", func(t *testing.T) { + mockAccountService := &mockAccountService{} + mockTransactionService := &mockTransactionService{} + + resolver := &mutationResolver{ + &Resolver{ + accountService: mockAccountService, + transactionService: mockTransactionService, + models: &data.Models{}, + }, + } + + // Create a valid operation XDR + srcAccount := keypair.MustRandom().Address() + p := txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + SourceAccount: srcAccount, + } + op, err := p.BuildXDR() + require.NoError(t, err) + + var buf strings.Builder + enc := xdr3.NewEncoder(&buf) + err = op.EncodeTo(enc) + require.NoError(t, err) + + opXDR := buf.String() + operationXDR := base64.StdEncoding.EncodeToString([]byte(opXDR)) + + invalidTransactionData := "invalid-transaction-data-xdr" + + input := graphql.BuildTransactionInput{ + Transaction: &graphql.TransactionInput{ + Operations: []string{operationXDR}, + Timeout: 30, + SimulationResult: &graphql.SimulationResultInput{ + TransactionData: &invalidTransactionData, + }, + }, + } + + result, err := resolver.BuildTransaction(ctx, input) + + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorContains(t, err, "Invalid TransactionData") + + var gqlErr *gqlerror.Error + if errors.As(err, &gqlErr) { + assert.Equal(t, "INVALID_TRANSACTION_DATA", gqlErr.Extensions["code"]) + } + }) +} diff --git a/internal/serve/graphql/resolvers/resolver.go b/internal/serve/graphql/resolvers/resolver.go index a4553117..7f063f17 100644 --- a/internal/serve/graphql/resolvers/resolver.go +++ b/internal/serve/graphql/resolvers/resolver.go @@ -10,6 +10,9 @@ package resolvers import ( "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/services" + + // TODO: Move TransactionService under /services + txservices "github.com/stellar/wallet-backend/internal/transactions/services" ) // Resolver is the main resolver struct for gqlgen @@ -21,14 +24,17 @@ type Resolver struct { models *data.Models // accountService provides account management operations accountService services.AccountService + // transactionService provides transaction building and signing operations + transactionService txservices.TransactionService } // NewResolver creates a new resolver instance with required dependencies // This constructor is called during server startup to initialize the resolver // Dependencies are injected here and available to all resolver functions. -func NewResolver(models *data.Models, accountService services.AccountService) *Resolver { +func NewResolver(models *data.Models, accountService services.AccountService, transactionService txservices.TransactionService) *Resolver { return &Resolver{ - models: models, - accountService: accountService, + models: models, + accountService: accountService, + transactionService: transactionService, } } diff --git a/internal/serve/graphql/schema/mutations.graphqls b/internal/serve/graphql/schema/mutations.graphqls index 16bc8f0c..23aefa37 100644 --- a/internal/serve/graphql/schema/mutations.graphqls +++ b/internal/serve/graphql/schema/mutations.graphqls @@ -4,6 +4,9 @@ type Mutation { # Account management mutations registerAccount(input: RegisterAccountInput!): RegisterAccountPayload! deregisterAccount(input: DeregisterAccountInput!): DeregisterAccountPayload! + + # Transaction mutations + buildTransaction(input: BuildTransactionInput!): BuildTransactionPayload! } # Input types for account mutations @@ -25,3 +28,31 @@ type DeregisterAccountPayload { success: Boolean! message: String } + +# Input types for transaction mutations +input BuildTransactionInput { + transaction: TransactionInput! +} + +# TODO: Update transaction input to include all attributes of the transaction. +input TransactionInput { + operations: [String!]! + timeout: Int! + simulationResult: SimulationResultInput +} + +# Optional simulation result input for Soroban transactions +input SimulationResultInput { + transactionData: String + events: [String!] + minResourceFee: String + results: [String!] + latestLedger: Int + error: String +} + +# Payload types for transaction mutations +type BuildTransactionPayload { + success: Boolean! + transactionXdr: String! +} diff --git a/internal/serve/httphandler/transactions_handler.go b/internal/serve/httphandler/transactions_handler.go deleted file mode 100644 index 2f0ea576..00000000 --- a/internal/serve/httphandler/transactions_handler.go +++ /dev/null @@ -1,70 +0,0 @@ -package httphandler - -import ( - "errors" - "fmt" - "net/http" - - "github.com/stellar/go/support/render/httpjson" - - "github.com/stellar/wallet-backend/internal/apptracker" - "github.com/stellar/wallet-backend/internal/metrics" - "github.com/stellar/wallet-backend/internal/serve/httperror" - "github.com/stellar/wallet-backend/internal/signing" - "github.com/stellar/wallet-backend/internal/signing/store" - transactionservices "github.com/stellar/wallet-backend/internal/transactions/services" - transactionsUtils "github.com/stellar/wallet-backend/internal/transactions/utils" - "github.com/stellar/wallet-backend/pkg/sorobanauth" - "github.com/stellar/wallet-backend/pkg/wbclient/types" -) - -type TransactionsHandler struct { - AppTracker apptracker.AppTracker - NetworkPassphrase string - TransactionService transactionservices.TransactionService - MetricsService metrics.MetricsService -} - -func (t *TransactionsHandler) BuildTransactions(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - var reqParams types.BuildTransactionsRequest - httpErr := DecodeJSONAndValidate(ctx, r, &reqParams, t.AppTracker) - if httpErr != nil { - httpErr.Render(w) - return - } - var transactionXDRs []string - for i, transaction := range reqParams.Transactions { - ops, err := transactionsUtils.BuildOperations(transaction.Operations) - if err != nil { - httperror.BadRequest("", map[string]any{ - fmt.Sprintf("transactions[%d].operations", i): err.Error(), - }).Render(w) - return - } - tx, err := t.TransactionService.BuildAndSignTransactionWithChannelAccount(ctx, ops, transaction.Timeout, transaction.SimulationResult) - if err != nil { - if errors.Is(err, transactionservices.ErrInvalidArguments) || - errors.Is(err, signing.ErrUnavailableChannelAccounts) || - errors.Is(err, sorobanauth.ErrForbiddenSigner) { - httperror.BadRequest(err.Error(), nil).Render(w) - return - } - if errors.Is(err, store.ErrNoIdleChannelAccountAvailable) { - httperror.InternalServerError(ctx, err.Error(), err, nil, t.AppTracker).Render(w) - return - } - httperror.InternalServerError(ctx, "unable to build transaction", err, nil, t.AppTracker).Render(w) - return - } - txXdrStr, err := tx.Base64() - if err != nil { - httperror.InternalServerError(ctx, "unable to base64 transaction", err, nil, t.AppTracker).Render(w) - return - } - transactionXDRs = append(transactionXDRs, txXdrStr) - } - httpjson.Render(w, types.BuildTransactionsResponse{ - TransactionXDRs: transactionXDRs, - }, httpjson.JSON) -} diff --git a/internal/serve/httphandler/transactions_handler_test.go b/internal/serve/httphandler/transactions_handler_test.go deleted file mode 100644 index 800ce4a0..00000000 --- a/internal/serve/httphandler/transactions_handler_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package httphandler - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - xdr3 "github.com/stellar/go-xdr/xdr3" - "github.com/stellar/go/keypair" - "github.com/stellar/go/txnbuild" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/stellar/wallet-backend/internal/apptracker" - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/db/dbtest" - "github.com/stellar/wallet-backend/internal/entities" - "github.com/stellar/wallet-backend/internal/transactions/services" - "github.com/stellar/wallet-backend/internal/transactions/utils" - "github.com/stellar/wallet-backend/pkg/wbclient/types" -) - -func TestBuildTransactions(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - mockAppTracker := apptracker.MockAppTracker{} - mockTxService := services.TransactionServiceMock{} - - handler := &TransactionsHandler{ - AppTracker: &mockAppTracker, - NetworkPassphrase: "testnet passphrase", - TransactionService: &mockTxService, - } - - srcAccount := keypair.MustRandom().Address() - p := txnbuild.Payment{ - Destination: keypair.MustRandom().Address(), - Amount: "10", - Asset: txnbuild.NativeAsset{}, - SourceAccount: srcAccount, - } - op, err := p.BuildXDR() - require.NoError(t, err) - - var buf strings.Builder - enc := xdr3.NewEncoder(&buf) - err = op.EncodeTo(enc) - require.NoError(t, err) - - opXDR := buf.String() - opXDRBase64 := base64.StdEncoding.EncodeToString([]byte(opXDR)) - - const endpoint = "/transactions/build" - - t.Run("tx_signing_fails", func(t *testing.T) { - reqBody := fmt.Sprintf(`{ - "transactions": [{"operations": [%q], "timeout": 100}] - }`, opXDRBase64) - rw := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - expectedOps, err := utils.BuildOperations([]string{opXDRBase64}) - require.NoError(t, err) - - err = errors.New("unable to find channel account") - mockTxService. - On("BuildAndSignTransactionWithChannelAccount", context.Background(), expectedOps, int64(100), entities.RPCSimulateTransactionResult{}). - Return(nil, err). - Once() - - mockAppTracker. - On("CaptureException", err). - Return(). - Once() - - http.HandlerFunc(handler.BuildTransactions).ServeHTTP(rw, req) - resp := rw.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - expectedRespBody := `{"error": "unable to build transaction"}` - assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - assert.JSONEq(t, expectedRespBody, string(respBody)) - }) - - t.Run("happy_path", func(t *testing.T) { - reqBody := fmt.Sprintf(`{ - "transactions": [{"operations": [%q], "timeout": 100}] - }`, opXDRBase64) - rw := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - expectedOps, err := utils.BuildOperations([]string{opXDRBase64}) - require.NoError(t, err) - tx := utils.BuildTestTransaction(t) - - mockTxService. - On("BuildAndSignTransactionWithChannelAccount", context.Background(), expectedOps, int64(100), entities.RPCSimulateTransactionResult{}). - Return(tx, nil). - Once() - - http.HandlerFunc(handler.BuildTransactions).ServeHTTP(rw, req) - resp := rw.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var buildTxResp types.BuildTransactionsResponse - err = json.Unmarshal(respBody, &buildTxResp) - require.NoError(t, err) - expectedTxXDR, err := tx.Base64() - require.NoError(t, err) - assert.Equal(t, expectedTxXDR, buildTxResp.TransactionXDRs[0]) - }) -} diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 3a093272..eeafd3a7 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -241,7 +241,7 @@ func handler(deps handlerDeps) http.Handler { r.Route("/graphql", func(r chi.Router) { r.Use(middleware.DataloaderMiddleware(deps.Models)) - resolver := resolvers.NewResolver(deps.Models, deps.AccountService) + resolver := resolvers.NewResolver(deps.Models, deps.AccountService, deps.TransactionService) srv := gqlhandler.New( generated.NewExecutableSchema( @@ -282,17 +282,6 @@ func handler(deps handlerDeps) http.Handler { r.Post("/create-sponsored-account", accountHandler.SponsorAccountCreation) r.Post("/create-fee-bump", accountHandler.CreateFeeBumpTransaction) }) - - r.Route("/transactions", func(r chi.Router) { - handler := &httphandler.TransactionsHandler{ - TransactionService: deps.TransactionService, - AppTracker: deps.AppTracker, - NetworkPassphrase: deps.NetworkPassphrase, - MetricsService: deps.MetricsService, - } - - r.Post("/build", handler.BuildTransactions) - }) }) return mux diff --git a/internal/transactions/services/mocks.go b/internal/transactions/services/mocks.go deleted file mode 100644 index 5c6554d0..00000000 --- a/internal/transactions/services/mocks.go +++ /dev/null @@ -1,30 +0,0 @@ -package services - -import ( - "context" - - "github.com/stellar/go/txnbuild" - - "github.com/stellar/wallet-backend/internal/entities" - - "github.com/stretchr/testify/mock" -) - -type TransactionServiceMock struct { - mock.Mock -} - -var _ TransactionService = (*TransactionServiceMock)(nil) - -func (t *TransactionServiceMock) NetworkPassphrase() string { - args := t.Called() - return args.String(0) -} - -func (t *TransactionServiceMock) BuildAndSignTransactionWithChannelAccount(ctx context.Context, operations []txnbuild.Operation, timeoutInSecs int64, simulationResult entities.RPCSimulateTransactionResult) (*txnbuild.Transaction, error) { - args := t.Called(ctx, operations, timeoutInSecs, simulationResult) - if result := args.Get(0); result != nil { - return result.(*txnbuild.Transaction), args.Error(1) - } - return nil, args.Error(1) -} diff --git a/internal/transactions/services/transaction_service.go b/internal/transactions/services/transaction_service.go index bd59f47d..b1bf9839 100644 --- a/internal/transactions/services/transaction_service.go +++ b/internal/transactions/services/transaction_service.go @@ -25,7 +25,15 @@ const ( DefaultTimeoutInSeconds = 30 ) -var ErrInvalidArguments = errors.New("invalid arguments") +var ( + ErrInvalidTimeout = errors.New("invalid timeout: timeout cannot be greater than maximum allowed seconds") + ErrInvalidOperationChannelAccount = errors.New("invalid operation: operation source account cannot be the channel account") + ErrInvalidOperationMissingSource = errors.New("invalid operation: operation source account cannot be empty for non-Soroban operations") + ErrInvalidSorobanOperationCount = errors.New("invalid Soroban transaction: must have exactly one operation") + ErrInvalidSorobanSimulationEmpty = errors.New("invalid Soroban transaction: simulation response cannot be empty") + ErrInvalidSorobanSimulationFailed = errors.New("invalid Soroban transaction: simulation failed") + ErrInvalidSorobanOperationType = errors.New("invalid Soroban transaction: operation type not supported") +) type TransactionService interface { NetworkPassphrase() string @@ -99,7 +107,7 @@ func (t *transactionService) NetworkPassphrase() string { func (t *transactionService) BuildAndSignTransactionWithChannelAccount(ctx context.Context, operations []txnbuild.Operation, timeoutInSecs int64, simulationResponse entities.RPCSimulateTransactionResult) (*txnbuild.Transaction, error) { if timeoutInSecs > MaxTimeoutInSeconds { - return nil, fmt.Errorf("%w: timeout cannot be greater than %d seconds", ErrInvalidArguments, MaxTimeoutInSeconds) + return nil, fmt.Errorf("%w (maximum: %d seconds)", ErrInvalidTimeout, MaxTimeoutInSeconds) } if timeoutInSecs <= 0 { timeoutInSecs = DefaultTimeoutInSeconds @@ -113,11 +121,11 @@ func (t *transactionService) BuildAndSignTransactionWithChannelAccount(ctx conte for _, op := range operations { // Prevent bad actors from using the channel account as a source account directly. if op.GetSourceAccount() == channelAccountPublicKey { - return nil, fmt.Errorf("%w: operation source account cannot be the channel account public key", ErrInvalidArguments) + return nil, fmt.Errorf("%w: %s", ErrInvalidOperationChannelAccount, channelAccountPublicKey) } // Prevent bad actors from using the channel account as a source account (inherited from the parent transaction). if !pkgUtils.IsSorobanTxnbuildOp(op) && op.GetSourceAccount() == "" { - return nil, fmt.Errorf("%w: operation source account cannot be empty", ErrInvalidArguments) + return nil, ErrInvalidOperationMissingSource } } @@ -182,13 +190,13 @@ func (t *transactionService) adjustParamsForSoroban(_ context.Context, channelAc // When soroban is used, only one operation is allowed. if len(operations) != 1 { - return txnbuild.TransactionParams{}, fmt.Errorf("%w: Soroban transactions require exactly one operation but %d were provided", ErrInvalidArguments, len(operations)) + return txnbuild.TransactionParams{}, fmt.Errorf("%w (%d provided)", ErrInvalidSorobanOperationCount, len(operations)) } if utils.IsEmpty(simulationResponse) { - return txnbuild.TransactionParams{}, fmt.Errorf("%w: simulation response cannot be empty", ErrInvalidArguments) + return txnbuild.TransactionParams{}, ErrInvalidSorobanSimulationEmpty } else if simulationResponse.Error != "" { - return txnbuild.TransactionParams{}, fmt.Errorf("%w: transaction simulation failed with error=%s", ErrInvalidArguments, simulationResponse.Error) + return txnbuild.TransactionParams{}, fmt.Errorf("%w: %s", ErrInvalidSorobanSimulationFailed, simulationResponse.Error) } // Check if the channel account public key is used as a source account for any SourceAccount auth entry. @@ -211,7 +219,7 @@ func (t *transactionService) adjustParamsForSoroban(_ context.Context, channelAc case *txnbuild.RestoreFootprint: sorobanOp.Ext = transactionExt default: - return txnbuild.TransactionParams{}, fmt.Errorf("%w: operation type %T is not a supported soroban operation", ErrInvalidArguments, operations[0]) + return txnbuild.TransactionParams{}, fmt.Errorf("%w: %T", ErrInvalidSorobanOperationType, operations[0]) } // Adjust the base fee to ensure the total fee computed by `txnbuild.NewTransaction` (baseFee+sorobanFee) will stay diff --git a/internal/transactions/services/transaction_service_test.go b/internal/transactions/services/transaction_service_test.go index 6c58c474..11a956df 100644 --- a/internal/transactions/services/transaction_service_test.go +++ b/internal/transactions/services/transaction_service_test.go @@ -246,7 +246,7 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { t.Run("🔴timeout_must_be_smaller_than_max_timeout", func(t *testing.T) { tx, err := txService.BuildAndSignTransactionWithChannelAccount(context.Background(), []txnbuild.Operation{}, MaxTimeoutInSeconds+1, entities.RPCSimulateTransactionResult{}) assert.Empty(t, tx) - assert.ErrorContains(t, err, fmt.Sprintf("cannot be greater than %d seconds", MaxTimeoutInSeconds)) + assert.ErrorContains(t, err, fmt.Sprintf("invalid timeout: timeout cannot be greater than maximum allowed seconds (maximum: %d seconds)", MaxTimeoutInSeconds)) }) t.Run("🔴handle_GetAccountPublicKey_err", func(t *testing.T) { @@ -276,7 +276,7 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { mChannelAccountSignatureClient.AssertExpectations(t) assert.Empty(t, tx) - assert.ErrorContains(t, err, "operation source account cannot be the channel account public key") + assert.ErrorContains(t, err, "invalid operation: operation source account cannot be the channel account") }) t.Run("🚨operation_source_account_cannot_be_empty", func(t *testing.T) { @@ -292,7 +292,7 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { mChannelAccountSignatureClient.AssertExpectations(t) assert.Empty(t, tx) - assert.ErrorContains(t, err, "operation source account cannot be empty") + assert.ErrorContains(t, err, "invalid operation: operation source account cannot be empty for non-Soroban operations") }) t.Run("🔴handle_GetAccountLedgerSequence_err", func(t *testing.T) { @@ -536,7 +536,7 @@ func Test_transactionService_adjustParamsForSoroban(t *testing.T) { buildPaymentOp(t), buildInvokeContractOp(t), }, - wantErrContains: "Soroban transactions require exactly one operation but 2 were provided", + wantErrContains: "invalid Soroban transaction: must have exactly one operation (2 provided)", }, { name: "🔴multiple_ops_where_all_are_soroban", @@ -545,7 +545,7 @@ func Test_transactionService_adjustParamsForSoroban(t *testing.T) { buildInvokeContractOp(t), buildInvokeContractOp(t), }, - wantErrContains: "Soroban transactions require exactly one operation but 2 were provided", + wantErrContains: "invalid Soroban transaction: must have exactly one operation (2 provided)", }, { name: "🔴handle_simulateTransaction_err", @@ -554,7 +554,7 @@ func Test_transactionService_adjustParamsForSoroban(t *testing.T) { buildInvokeContractOp(t), }, simulationResponse: entities.RPCSimulateTransactionResult{}, - wantErrContains: "invalid arguments: simulation response cannot be empty", + wantErrContains: "invalid Soroban transaction: simulation response cannot be empty", }, { name: "🔴handle_simulateTransaction_error_in_payload", @@ -565,7 +565,7 @@ func Test_transactionService_adjustParamsForSoroban(t *testing.T) { simulationResponse: entities.RPCSimulateTransactionResult{ Error: "simulate transaction failed because fooBar", }, - wantErrContains: "transaction simulation failed with error=simulate transaction failed because fooBar", + wantErrContains: "invalid Soroban transaction: simulation failed: simulate transaction failed because fooBar", }, { name: "🚨catch_txSource=channelAccount(AuthEntry)", diff --git a/pkg/wbclient/client.go b/pkg/wbclient/client.go index 8a7eeba4..02bfb0b9 100644 --- a/pkg/wbclient/client.go +++ b/pkg/wbclient/client.go @@ -10,16 +10,51 @@ import ( "net/url" "time" + "github.com/stellar/go/xdr" + + "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/utils" "github.com/stellar/wallet-backend/pkg/wbclient/auth" "github.com/stellar/wallet-backend/pkg/wbclient/types" ) const ( - buildTransactionsPath = "/transactions/build" + graphqlPath = "/graphql/query" createFeeBumpTransactionPath = "/tx/create-fee-bump" + buildTransactionQuery = ` + mutation BuildTransaction($input: BuildTransactionInput!) { + buildTransaction(input: $input) { + success + transactionXdr + } + } + ` ) +type GraphQLRequest struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +type GraphQLResponse struct { + Data json.RawMessage `json:"data,omitempty"` + Errors []GraphQLError `json:"errors,omitempty"` +} + +type GraphQLError struct { + Message string `json:"message"` + Extensions map[string]interface{} `json:"extensions,omitempty"` +} + +type BuildTransactionPayload struct { + Success bool `json:"success"` + TransactionXdr string `json:"transactionXdr"` +} + +type BuildTransactionData struct { + BuildTransaction BuildTransactionPayload `json:"buildTransaction"` +} + type Client struct { HTTPClient *http.Client BaseURL string @@ -34,10 +69,71 @@ func NewClient(baseURL string, requestSigner auth.HTTPRequestSigner) *Client { } } -func (c *Client) BuildTransactions(ctx context.Context, transactions ...types.Transaction) (*types.BuildTransactionsResponse, error) { - buildTxRequest := types.BuildTransactionsRequest{Transactions: transactions} +func buildSimulationResultMap(simResult entities.RPCSimulateTransactionResult) (map[string]interface{}, error) { + result := make(map[string]interface{}) + + if !utils.IsEmpty(simResult.TransactionData) { + if txDataStr, err := xdr.MarshalBase64(simResult.TransactionData); err != nil { + return nil, fmt.Errorf("marshaling transaction data: %w", err) + } else { + result["transactionData"] = txDataStr + } + } + + if len(simResult.Events) > 0 { + result["events"] = simResult.Events + } + + if simResult.MinResourceFee != "" { + result["minResourceFee"] = simResult.MinResourceFee + } + + if len(simResult.Results) > 0 { + // Convert RPCSimulateHostFunctionResult as GraphQL expects JSON + results := make([]string, len(simResult.Results)) + for i, result := range simResult.Results { + if resultJSON, err := json.Marshal(result); err != nil { + return nil, fmt.Errorf("marshaling simulation result %d: %w", i, err) + } else { + results[i] = string(resultJSON) + } + } + result["results"] = results + } + + if simResult.LatestLedger != 0 { + result["latestLedger"] = simResult.LatestLedger + } + + if simResult.Error != "" { + result["error"] = simResult.Error + } + + return result, nil +} + +func (c *Client) BuildTransaction(ctx context.Context, transaction types.Transaction) (*types.BuildTransactionResponse, error) { + simulationResult, err := buildSimulationResultMap(transaction.SimulationResult) + if err != nil { + return nil, err + } + + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "transaction": map[string]interface{}{ + "operations": transaction.Operations, + "timeout": transaction.Timeout, + "simulationResult": simulationResult, + }, + }, + } + + gqlRequest := GraphQLRequest{ + Query: buildTransactionQuery, + Variables: variables, + } - resp, err := c.request(ctx, http.MethodPost, buildTransactionsPath, buildTxRequest) + resp, err := c.request(ctx, http.MethodPost, graphqlPath, gqlRequest) if err != nil { return nil, fmt.Errorf("calling client request: %w", err) } @@ -46,12 +142,23 @@ func (c *Client) BuildTransactions(ctx context.Context, transactions ...types.Tr return nil, c.logHTTPError(ctx, resp) } - buildTxResponse, err := parseResponseBody[types.BuildTransactionsResponse](ctx, resp.Body) + gqlResponse, err := parseResponseBody[GraphQLResponse](ctx, resp.Body) if err != nil { - return nil, fmt.Errorf("parsing response body: %w", err) + return nil, fmt.Errorf("parsing GraphQL response body: %w", err) + } + + if len(gqlResponse.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", gqlResponse.Errors[0].Message) + } + + var data BuildTransactionData + if err := json.Unmarshal(gqlResponse.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshaling GraphQL data: %w", err) } - return buildTxResponse, nil + return &types.BuildTransactionResponse{ + TransactionXDR: data.BuildTransaction.TransactionXdr, + }, nil } func parseResponseBody[T any](ctx context.Context, respBody io.ReadCloser) (*T, error) { diff --git a/pkg/wbclient/types/types.go b/pkg/wbclient/types/types.go index 85373ad6..3ba04846 100644 --- a/pkg/wbclient/types/types.go +++ b/pkg/wbclient/types/types.go @@ -16,6 +16,10 @@ type BuildTransactionsResponse struct { TransactionXDRs []string `json:"transactionXdrs"` } +type BuildTransactionResponse struct { + TransactionXDR string `json:"transactionXdr"` +} + type CreateFeeBumpTransactionRequest struct { Transaction string `json:"transaction" validate:"required"` } From 0767bbc1cff77fda41098c685a781370fb89a080 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Tue, 12 Aug 2025 15:49:28 -0400 Subject: [PATCH 06/16] [Test Optimization] Use a common db setup for testing resolvers (#277) --- internal/data/operations.go | 2 +- internal/data/statechanges.go | 19 +- internal/data/statechanges_test.go | 7 +- internal/data/transactions.go | 3 +- internal/serve/graphql/dataloaders/loaders.go | 6 +- .../graphql/dataloaders/operation_loaders.go | 4 +- .../dataloaders/statechange_loaders.go | 4 +- .../dataloaders/transaction_loaders.go | 4 +- .../resolvers/account_resolvers_test.go | 239 +++++----- .../resolvers/operation_resolvers_test.go | 202 ++++---- .../graphql/resolvers/queries.resolvers.go | 19 + .../resolvers/queries_resolvers_test.go | 431 ++++++------------ .../serve/graphql/resolvers/setup_test.go | 39 ++ .../resolvers/statechange_resolvers_test.go | 121 ++--- .../serve/graphql/resolvers/test_utils.go | 154 +++++++ .../resolvers/transaction_resolvers_test.go | 224 ++++----- 16 files changed, 795 insertions(+), 683 deletions(-) create mode 100644 internal/serve/graphql/resolvers/setup_test.go create mode 100644 internal/serve/graphql/resolvers/test_utils.go diff --git a/internal/data/operations.go b/internal/data/operations.go index f9474e63..8f7e7874 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -69,7 +69,7 @@ func (m *OperationModel) BatchGetByAccountAddresses(ctx context.Context, account FROM operations INNER JOIN operations_accounts ON operations.id = operations_accounts.operation_id WHERE operations_accounts.account_id = ANY($1) - ORDER BY operations.ledger_created_at DESC + ORDER BY operations.id DESC `, columns) var operationsWithAccounts []*types.OperationWithAccountID diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index 30f7ef55..61d3255a 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -28,12 +28,17 @@ func (m *StateChangeModel) BatchGetByAccountAddresses( } query := fmt.Sprintf(` SELECT %s FROM state_changes WHERE account_id = ANY($1) + ORDER BY to_id DESC, state_change_order DESC `, columns) var stateChanges []*types.StateChange + start := time.Now() err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(accountAddresses)) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) if err != nil { return nil, fmt.Errorf("getting state changes by account addresses: %w", err) } + m.MetricsService.IncDBQuery("SELECT", "state_changes") return stateChanges, nil } @@ -254,8 +259,13 @@ func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []st if columns == "" { columns = "*" } + // We always return the to_id, state_change_order since those are the primary keys. + // This is used for subsequent queries for operation and transactions of a state change. query := fmt.Sprintf(` - SELECT %s, tx_hash FROM state_changes WHERE tx_hash = ANY($1) + SELECT to_id, state_change_order, %s, tx_hash + FROM state_changes + WHERE tx_hash = ANY($1) + ORDER BY to_id DESC, state_change_order DESC `, columns) var stateChanges []*types.StateChange start := time.Now() @@ -274,8 +284,13 @@ func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operation if columns == "" { columns = "*" } + // We always return the to_id, state_change_order since those are the primary keys. + // This is used for subsequent queries for operation and transactions of a state change. query := fmt.Sprintf(` - SELECT %s, operation_id FROM state_changes WHERE operation_id = ANY($1) + SELECT to_id, state_change_order, %s, operation_id + FROM state_changes + WHERE operation_id = ANY($1) + ORDER BY to_id DESC, state_change_order DESC `, columns) var stateChanges []*types.StateChange start := time.Now() diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 27c250ba..3d46f88e 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -237,9 +237,14 @@ func TestStateChangeModel_BatchGetByAccountAddresses(t *testing.T) { `, now, address1, address2) require.NoError(t, err) + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + defer mockMetricsService.AssertExpectations(t) + m := &StateChangeModel{ DB: dbConnectionPool, - MetricsService: metrics.NewMockMetricsService(), + MetricsService: mockMetricsService, } // Test BatchGetByAccount diff --git a/internal/data/transactions.go b/internal/data/transactions.go index ceee2d12..eb2f22a6 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -70,7 +70,8 @@ func (m *TransactionModel) BatchGetByAccountAddresses(ctx context.Context, accou FROM transactions_accounts INNER JOIN transactions ON transactions_accounts.tx_hash = transactions.hash - WHERE transactions_accounts.account_id = ANY($1)`, columns) + WHERE transactions_accounts.account_id = ANY($1) + ORDER BY transactions.to_id DESC`, columns) var transactions []*types.TransactionWithAccountID start := time.Now() err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(accountAddresses)) diff --git a/internal/serve/graphql/dataloaders/loaders.go b/internal/serve/graphql/dataloaders/loaders.go index 1363668c..4a045b7e 100644 --- a/internal/serve/graphql/dataloaders/loaders.go +++ b/internal/serve/graphql/dataloaders/loaders.go @@ -69,12 +69,12 @@ type Dataloaders struct { func NewDataloaders(models *data.Models) *Dataloaders { return &Dataloaders{ OperationsByTxHashLoader: operationsByTxHashLoader(models), - OperationsByAccountLoader: operationsByAccountLoader(models), + OperationsByAccountLoader: OperationsByAccountLoader(models), OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models), - TransactionsByAccountLoader: transactionsByAccountLoader(models), + TransactionsByAccountLoader: TransactionsByAccountLoader(models), TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models), TransactionsByOperationIDLoader: transactionByOperationIDLoader(models), - StateChangesByAccountLoader: stateChangesByAccountLoader(models), + StateChangesByAccountLoader: StateChangesByAccountLoader(models), StateChangesByTxHashLoader: stateChangesByTxHashLoader(models), StateChangesByOperationIDLoader: stateChangesByOperationIDLoader(models), AccountsByTxHashLoader: accountsByTxHashLoader(models), diff --git a/internal/serve/graphql/dataloaders/operation_loaders.go b/internal/serve/graphql/dataloaders/operation_loaders.go index c953762a..c5edc5ab 100644 --- a/internal/serve/graphql/dataloaders/operation_loaders.go +++ b/internal/serve/graphql/dataloaders/operation_loaders.go @@ -42,10 +42,10 @@ func operationsByTxHashLoader(models *data.Models) *dataloadgen.Loader[Operation ) } -// opByAccountLoader creates a dataloader for fetching operations by account address +// OperationsByAccountLoader creates a dataloader for fetching operations by account address // This prevents N+1 queries when multiple accounts request their operations // The loader batches multiple account addresses into a single database query -func operationsByAccountLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { +func OperationsByAccountLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { return newOneToManyLoader( func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithAccountID, error) { accountIDs := make([]string, len(keys)) diff --git a/internal/serve/graphql/dataloaders/statechange_loaders.go b/internal/serve/graphql/dataloaders/statechange_loaders.go index 60bbcb2d..056da54e 100644 --- a/internal/serve/graphql/dataloaders/statechange_loaders.go +++ b/internal/serve/graphql/dataloaders/statechange_loaders.go @@ -16,8 +16,8 @@ type StateChangeColumnsKey struct { Columns string } -// stateChangesByAccountLoader creates a dataloader for fetching state changes by account address -func stateChangesByAccountLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { +// StateChangesByAccountLoader creates a dataloader for fetching state changes by account address +func StateChangesByAccountLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { return newOneToManyLoader( func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChange, error) { accountIDs := make([]string, len(keys)) diff --git a/internal/serve/graphql/dataloaders/transaction_loaders.go b/internal/serve/graphql/dataloaders/transaction_loaders.go index 13c405be..dfa7cd3e 100644 --- a/internal/serve/graphql/dataloaders/transaction_loaders.go +++ b/internal/serve/graphql/dataloaders/transaction_loaders.go @@ -17,10 +17,10 @@ type TransactionColumnsKey struct { Columns string } -// txByAccountLoader creates a dataloader for fetching transactions by account address +// TransactionsByAccountLoader creates a dataloader for fetching transactions by account address // This prevents N+1 queries when multiple accounts request their transactions // The loader batches multiple account addresses into a single database query -func transactionsByAccountLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, []*types.Transaction] { +func TransactionsByAccountLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, []*types.Transaction] { return newOneToManyLoader( func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithAccountID, error) { accountIDs := make([]string, len(keys)) diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index 9f9531f1..6229a907 100644 --- a/internal/serve/graphql/resolvers/account_resolvers_test.go +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -2,190 +2,187 @@ package resolvers import ( "context" - "errors" + "fmt" "testing" - "github.com/99designs/gqlgen/graphql" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/vektah/gqlparser/v2/ast" - "github.com/vikstrous/dataloadgen" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" "github.com/stellar/wallet-backend/internal/serve/middleware" ) -func GetTestCtx(table string, columns []string) context.Context { - opCtx := &graphql.OperationContext{ - Operation: &ast.OperationDefinition{ - SelectionSet: ast.SelectionSet{ - &ast.Field{ - Name: table, - SelectionSet: ast.SelectionSet{}, +func TestAccountResolver_Transactions(t *testing.T) { + parentAccount := &types.Account{StellarAddress: "test-account"} + + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &accountResolver{ + &Resolver{ + models: &data.Models{ + Transactions: &data.TransactionModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, }, }, }, } - ctx := graphql.WithOperationContext(context.Background(), opCtx) - var selections ast.SelectionSet - for _, fieldName := range columns { - selections = append(selections, &ast.Field{Name: fieldName}) - } - fieldCtx := &graphql.FieldContext{ - Field: graphql.CollectedField{ - Selections: selections, - }, - } - ctx = graphql.WithFieldContext(ctx, fieldCtx) - return ctx -} - -func TestAccountResolver_Transactions(t *testing.T) { - resolver := &accountResolver{&Resolver{}} - parentAccount := &types.Account{StellarAddress: "test-account"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([][]*types.Transaction, []error) { - assert.Equal(t, []dataloaders.TransactionColumnsKey{ - {AccountID: "test-account", Columns: "transactions.hash"}, - }, keys) - results := [][]*types.Transaction{ - { - {Hash: "tx1"}, - {Hash: "tx2"}, - }, - } - return results, nil - } - - loader := dataloadgen.NewLoader(mockFetch) loaders := &dataloaders.Dataloaders{ - TransactionsByAccountLoader: loader, + TransactionsByAccountLoader: dataloaders.TransactionsByAccountLoader(resolver.models), } - ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) transactions, err := resolver.Transactions(ctx, parentAccount) require.NoError(t, err) - require.Len(t, transactions, 2) - assert.Equal(t, "tx1", transactions[0].Hash) - assert.Equal(t, "tx2", transactions[1].Hash) + require.Len(t, transactions, 4) + assert.Equal(t, "tx4", transactions[0].Hash) + assert.Equal(t, "tx3", transactions[1].Hash) + assert.Equal(t, "tx2", transactions[2].Hash) + assert.Equal(t, "tx1", transactions[3].Hash) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([][]*types.Transaction, []error) { - return nil, []error{errors.New("something went wrong")} + t.Run("nil account panics", func(t *testing.T) { + loaders := &dataloaders.Dataloaders{ + TransactionsByAccountLoader: dataloaders.TransactionsByAccountLoader(resolver.models), } + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - loader := dataloadgen.NewLoader(mockFetch) + assert.Panics(t, func() { + _, _ = resolver.Transactions(ctx, nil) //nolint:errcheck + }) + }) + + t.Run("account with no transactions", func(t *testing.T) { + nonExistentAccount := &types.Account{StellarAddress: "non-existent-account"} loaders := &dataloaders.Dataloaders{ - TransactionsByAccountLoader: loader, + TransactionsByAccountLoader: dataloaders.TransactionsByAccountLoader(resolver.models), } - ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - - _, err := resolver.Transactions(ctx, parentAccount) + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) + transactions, err := resolver.Transactions(ctx, nonExistentAccount) - require.Error(t, err) - assert.EqualError(t, err, "something went wrong") + require.NoError(t, err) + assert.Empty(t, transactions) }) } func TestAccountResolver_Operations(t *testing.T) { - resolver := &accountResolver{&Resolver{}} parentAccount := &types.Account{StellarAddress: "test-account"} - t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { - assert.Equal(t, []dataloaders.OperationColumnsKey{ - {AccountID: "test-account", Columns: "operations.id"}, - }, keys) - results := [][]*types.Operation{ - { - {ID: 1, TxHash: "tx1"}, - {ID: 2, TxHash: "tx2"}, - }, - } - return results, nil - } + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &accountResolver{&Resolver{ + models: &data.Models{ + Operations: &data.OperationModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} - loader := dataloadgen.NewLoader(mockFetch) + t.Run("success", func(t *testing.T) { loaders := &dataloaders.Dataloaders{ - OperationsByAccountLoader: loader, + OperationsByAccountLoader: dataloaders.OperationsByAccountLoader(resolver.models), } - ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) operations, err := resolver.Operations(ctx, parentAccount) require.NoError(t, err) - require.Len(t, operations, 2) - assert.Equal(t, "tx1", operations[0].TxHash) - assert.Equal(t, "tx2", operations[1].TxHash) + require.Len(t, operations, 8) + assert.Equal(t, int64(1008), operations[0].ID) + assert.Equal(t, int64(1007), operations[1].ID) + assert.Equal(t, int64(1006), operations[2].ID) + assert.Equal(t, int64(1005), operations[3].ID) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { - return nil, []error{errors.New("something went wrong")} - } - loader := dataloadgen.NewLoader(mockFetch) + t.Run("nil account panics", func(t *testing.T) { loaders := &dataloaders.Dataloaders{ - OperationsByAccountLoader: loader, + OperationsByAccountLoader: dataloaders.OperationsByAccountLoader(resolver.models), } - ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + + assert.Panics(t, func() { + _, _ = resolver.Operations(ctx, nil) //nolint:errcheck + }) + }) - _, err := resolver.Operations(ctx, parentAccount) + t.Run("account with no operations", func(t *testing.T) { + nonExistentAccount := &types.Account{StellarAddress: "non-existent-account"} + loaders := &dataloaders.Dataloaders{ + OperationsByAccountLoader: dataloaders.OperationsByAccountLoader(resolver.models), + } + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + operations, err := resolver.Operations(ctx, nonExistentAccount) - require.Error(t, err) - assert.EqualError(t, err, "something went wrong") + require.NoError(t, err) + assert.Empty(t, operations) }) } func TestAccountResolver_StateChanges(t *testing.T) { - resolver := &accountResolver{&Resolver{}} parentAccount := &types.Account{StellarAddress: "test-account"} + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &accountResolver{&Resolver{ + models: &data.Models{ + StateChanges: &data.StateChangeModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { - assert.Equal(t, []dataloaders.StateChangeColumnsKey{ - {AccountID: "test-account", Columns: "state_changes.account_id, state_changes.state_change_category"}, - }, keys) - results := [][]*types.StateChange{ - { - {ToID: 1, StateChangeOrder: 1, TxHash: "tx1"}, - {ToID: 1, StateChangeOrder: 2, TxHash: "tx1"}, - }, - } - return results, nil - } - loader := dataloadgen.NewLoader(mockFetch) loaders := &dataloaders.Dataloaders{ - StateChangesByAccountLoader: loader, + StateChangesByAccountLoader: dataloaders.StateChangesByAccountLoader(resolver.models), } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) - + ctx := context.WithValue(getTestCtx("state_changes", []string{"to_id", "state_change_order"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentAccount) require.NoError(t, err) - require.Len(t, stateChanges, 2) - assert.Equal(t, int64(1), stateChanges[0].ToID) - assert.Equal(t, int64(1), stateChanges[0].StateChangeOrder) - assert.Equal(t, int64(1), stateChanges[1].ToID) - assert.Equal(t, int64(2), stateChanges[1].StateChangeOrder) + require.Len(t, stateChanges, 20) + // With 16 state changes ordered by ToID descending, check first few + assert.Equal(t, "1008:2", fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) + assert.Equal(t, "1008:1", fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) + assert.Equal(t, "1007:2", fmt.Sprintf("%d:%d", stateChanges[2].ToID, stateChanges[2].StateChangeOrder)) + assert.Equal(t, "1007:1", fmt.Sprintf("%d:%d", stateChanges[3].ToID, stateChanges[3].StateChangeOrder)) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { - return nil, []error{errors.New("sc fetch error")} - } - loader := dataloadgen.NewLoader(mockFetch) + t.Run("nil account panics", func(t *testing.T) { loaders := &dataloaders.Dataloaders{ - StateChangesByAccountLoader: loader, + StateChangesByAccountLoader: dataloaders.StateChangesByAccountLoader(resolver.models), } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(getTestCtx("state_changes", []string{"to_id", "state_change_order"}), middleware.LoadersKey, loaders) - _, err := resolver.StateChanges(ctx, parentAccount) + assert.Panics(t, func() { + _, _ = resolver.StateChanges(ctx, nil) //nolint:errcheck + }) + }) - require.Error(t, err) - assert.EqualError(t, err, "sc fetch error") + t.Run("account with no state changes", func(t *testing.T) { + nonExistentAccount := &types.Account{StellarAddress: "non-existent-account"} + loaders := &dataloaders.Dataloaders{ + StateChangesByAccountLoader: dataloaders.StateChangesByAccountLoader(resolver.models), + } + ctx := context.WithValue(getTestCtx("state_changes", []string{"to_id", "state_change_order"}), middleware.LoadersKey, loaders) + stateChanges, err := resolver.StateChanges(ctx, nonExistentAccount) + + require.NoError(t, err) + assert.Empty(t, stateChanges) }) } diff --git a/internal/serve/graphql/resolvers/operation_resolvers_test.go b/internal/serve/graphql/resolvers/operation_resolvers_test.go index 277e7b52..72a8b85d 100644 --- a/internal/serve/graphql/resolvers/operation_resolvers_test.go +++ b/internal/serve/graphql/resolvers/operation_resolvers_test.go @@ -2,36 +2,38 @@ package resolvers import ( "context" - "errors" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/vikstrous/dataloadgen" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" "github.com/stellar/wallet-backend/internal/serve/middleware" ) func TestOperationResolver_Transaction(t *testing.T) { - resolver := &operationResolver{&Resolver{}} - parentOperation := &types.Operation{ID: 123} + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &operationResolver{&Resolver{ + models: &data.Models{ + Transactions: &data.TransactionModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentOperation := &types.Operation{ID: 1001} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { - assert.Equal(t, []dataloaders.TransactionColumnsKey{{OperationID: 123, Columns: "transactions.hash"}}, keys) - results := []*types.Transaction{ - {Hash: "tx1"}, - } - return results, nil - } - - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - TransactionsByOperationIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) transaction, err := resolver.Transaction(ctx, parentOperation) @@ -40,114 +42,126 @@ func TestOperationResolver_Transaction(t *testing.T) { assert.Equal(t, "tx1", transaction.Hash) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { - return nil, []error{errors.New("something went wrong")} - } + t.Run("nil operation panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - TransactionsByOperationIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) + assert.Panics(t, func() { + _, _ = resolver.Transaction(ctx, nil) //nolint:errcheck + }) + }) + + t.Run("operation with non-existent transaction", func(t *testing.T) { + nonExistentOperation := &types.Operation{ID: 9999, TxHash: "non-existent-tx"} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - _, err := resolver.Transaction(ctx, parentOperation) + transaction, err := resolver.Transaction(ctx, nonExistentOperation) - require.Error(t, err) - assert.EqualError(t, err, "something went wrong") + require.NoError(t, err) // Dataloader returns nil, not error for missing data + assert.Nil(t, transaction) }) } func TestOperationResolver_Accounts(t *testing.T) { - resolver := &operationResolver{&Resolver{}} - parentOperation := &types.Operation{ID: 123} + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &operationResolver{&Resolver{ + models: &data.Models{ + Account: &data.AccountModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentOperation := &types.Operation{ID: 1001} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { - assert.Equal(t, []dataloaders.AccountColumnsKey{{OperationID: 123, Columns: "accounts.stellar_address"}}, keys) - results := [][]*types.Account{ - { - {StellarAddress: "G-ACCOUNT1"}, - {StellarAddress: "G-ACCOUNT2"}, - }, - } - return results, nil - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - AccountsByOperationIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, parentOperation) require.NoError(t, err) - require.Len(t, accounts, 2) - assert.Equal(t, "G-ACCOUNT1", accounts[0].StellarAddress) - assert.Equal(t, "G-ACCOUNT2", accounts[1].StellarAddress) + require.Len(t, accounts, 1) + assert.Equal(t, "test-account", accounts[0].StellarAddress) + }) + + t.Run("nil operation panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) + + assert.Panics(t, func() { + _, _ = resolver.Accounts(ctx, nil) //nolint:errcheck + }) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { - return nil, []error{errors.New("account fetch error")} - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - AccountsByOperationIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) + t.Run("operation with no associated accounts", func(t *testing.T) { + nonExistentOperation := &types.Operation{ID: 9999} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) - _, err := resolver.Accounts(ctx, parentOperation) + accounts, err := resolver.Accounts(ctx, nonExistentOperation) - require.Error(t, err) - assert.EqualError(t, err, "account fetch error") + require.NoError(t, err) + assert.Empty(t, accounts) }) } func TestOperationResolver_StateChanges(t *testing.T) { - resolver := &operationResolver{&Resolver{}} - parentOperation := &types.Operation{ID: 123} + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &operationResolver{&Resolver{ + models: &data.Models{ + StateChanges: &data.StateChangeModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + Operations: &data.OperationModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentOperation := &types.Operation{ID: 1001} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { - assert.Equal(t, []dataloaders.StateChangeColumnsKey{{OperationID: 123, Columns: "account_id, state_change_category"}}, keys) - results := [][]*types.StateChange{ - { - {ToID: 1, StateChangeOrder: 1}, - {ToID: 1, StateChangeOrder: 2}, - }, - } - return results, nil - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - StateChangesByOperationIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentOperation) require.NoError(t, err) require.Len(t, stateChanges, 2) - assert.Equal(t, int64(1), stateChanges[0].ToID) - assert.Equal(t, int64(1), stateChanges[0].StateChangeOrder) - assert.Equal(t, int64(1), stateChanges[1].ToID) - assert.Equal(t, int64(2), stateChanges[1].StateChangeOrder) + assert.Equal(t, int64(1001), stateChanges[0].ToID) + assert.Equal(t, int64(2), stateChanges[0].StateChangeOrder) + assert.Equal(t, int64(1001), stateChanges[1].ToID) + assert.Equal(t, int64(1), stateChanges[1].StateChangeOrder) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { - return nil, []error{errors.New("sc fetch error")} - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - StateChangesByOperationIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) + t.Run("nil operation panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + + assert.Panics(t, func() { + _, _ = resolver.StateChanges(ctx, nil) //nolint:errcheck + }) + }) - _, err := resolver.StateChanges(ctx, parentOperation) + t.Run("operation with no state changes", func(t *testing.T) { + nonExistentOperation := &types.Operation{ID: 9999} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) - require.Error(t, err) - assert.EqualError(t, err, "sc fetch error") + stateChanges, err := resolver.StateChanges(ctx, nonExistentOperation) + + require.NoError(t, err) + assert.Empty(t, stateChanges) }) } diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go index b9c4f4b0..6142442e 100644 --- a/internal/serve/graphql/resolvers/queries.resolvers.go +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "fmt" "strings" "github.com/stellar/wallet-backend/internal/indexer/types" @@ -24,6 +25,12 @@ func (r *queryResolver) TransactionByHash(ctx context.Context, hash string) (*ty // This resolver handles the "transactions" query. // It demonstrates handling optional arguments (limit can be nil) func (r *queryResolver) Transactions(ctx context.Context, limit *int32) ([]*types.Transaction, error) { + if limit != nil && *limit < 0 { + return nil, fmt.Errorf("limit must be non-negative, got %d", *limit) + } + if limit != nil && *limit == 0 { + return []*types.Transaction{}, nil + } dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "") return r.models.Transactions.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) } @@ -38,12 +45,24 @@ func (r *queryResolver) Account(ctx context.Context, address string) (*types.Acc // Operations is the resolver for the operations field. // This resolver handles the "operations" query. func (r *queryResolver) Operations(ctx context.Context, limit *int32) ([]*types.Operation, error) { + if limit != nil && *limit < 0 { + return nil, fmt.Errorf("limit must be non-negative, got %d", *limit) + } + if limit != nil && *limit == 0 { + return []*types.Operation{}, nil + } dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "") return r.models.Operations.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) } // StateChanges is the resolver for the stateChanges field. func (r *queryResolver) StateChanges(ctx context.Context, limit *int32) ([]*types.StateChange, error) { + if limit != nil && *limit < 0 { + return nil, fmt.Errorf("limit must be non-negative, got %d", *limit) + } + if limit != nil && *limit == 0 { + return []*types.StateChange{}, nil + } dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "") return r.models.StateChanges.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) } diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index 4165eb53..6aa41ecf 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -1,100 +1,65 @@ package resolvers import ( - "context" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/db/dbtest" - "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/metrics" ) func TestQueryResolver_TransactionByHash(t *testing.T) { - ctx := context.Background() - - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + defer mockMetricsService.AssertExpectations(t) - cleanUpDB := func() { - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) - require.NoError(t, err) + resolver := &queryResolver{ + &Resolver{ + models: &data.Models{ + Transactions: &data.TransactionModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }, } t.Run("success", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() - defer mockMetricsService.AssertExpectations(t) - - resolver := &queryResolver{ - &Resolver{ - models: &data.Models{ - Transactions: &data.TransactionModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - }, - }, - }, - } - - expectedTx := &types.Transaction{ - Hash: "tx1", - ToID: 1, - EnvelopeXDR: "envelope1", - ResultXDR: "result1", - MetaXDR: "meta1", - LedgerNumber: 1, - LedgerCreatedAt: time.Now(), - } - - dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - expectedTx.Hash, expectedTx.ToID, expectedTx.EnvelopeXDR, expectedTx.ResultXDR, expectedTx.MetaXDR, expectedTx.LedgerNumber, expectedTx.LedgerCreatedAt) - require.NoError(t, err) - return nil - }) - require.NoError(t, dbErr) - - ctx := GetTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) + ctx := getTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) tx, err := resolver.TransactionByHash(ctx, "tx1") require.NoError(t, err) - assert.Equal(t, expectedTx.Hash, tx.Hash) - assert.Equal(t, expectedTx.ToID, tx.ToID) - assert.Equal(t, expectedTx.EnvelopeXDR, tx.EnvelopeXDR) - assert.Equal(t, expectedTx.ResultXDR, tx.ResultXDR) - assert.Equal(t, expectedTx.MetaXDR, tx.MetaXDR) - assert.Equal(t, expectedTx.LedgerNumber, tx.LedgerNumber) - cleanUpDB() + assert.Equal(t, "tx1", tx.Hash) + assert.Equal(t, int64(1), tx.ToID) + assert.Equal(t, "envelope1", tx.EnvelopeXDR) + assert.Equal(t, "result1", tx.ResultXDR) + assert.Equal(t, "meta1", tx.MetaXDR) + assert.Equal(t, uint32(1), tx.LedgerNumber) }) -} -func TestQueryResolver_Transactions(t *testing.T) { - ctx := context.Background() + t.Run("non-existent hash", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + tx, err := resolver.TransactionByHash(ctx, "non-existent-hash") + + require.Error(t, err) + assert.Nil(t, tx) + }) - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() + t.Run("empty hash", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + tx, err := resolver.TransactionByHash(ctx, "") - cleanUpDB := func() { - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) - require.NoError(t, err) - } + require.Error(t, err) + assert.Nil(t, tx) + }) +} - mockMetricsService := metrics.NewMockMetricsService() +func TestQueryResolver_Transactions(t *testing.T) { + mockMetricsService := &metrics.MockMetricsService{} mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() defer mockMetricsService.AssertExpectations(t) @@ -103,75 +68,56 @@ func TestQueryResolver_Transactions(t *testing.T) { &Resolver{ models: &data.Models{ Transactions: &data.TransactionModel{ - DB: dbConnectionPool, + DB: testDBConnectionPool, MetricsService: mockMetricsService, }, }, }, } - tx1 := &types.Transaction{ - Hash: "tx1", - ToID: 1, - EnvelopeXDR: "envelope1", - ResultXDR: "result1", - MetaXDR: "meta1", - LedgerNumber: 1, - LedgerCreatedAt: time.Now(), - } - tx2 := &types.Transaction{ - Hash: "tx2", - ToID: 2, - EnvelopeXDR: "envelope2", - ResultXDR: "result2", - MetaXDR: "meta2", - LedgerNumber: 2, - LedgerCreatedAt: time.Now(), - } - - dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7), ($8, $9, $10, $11, $12, $13, $14)`, - tx1.Hash, tx1.ToID, tx1.EnvelopeXDR, tx1.ResultXDR, tx1.MetaXDR, tx1.LedgerNumber, tx1.LedgerCreatedAt, - tx2.Hash, tx2.ToID, tx2.EnvelopeXDR, tx2.ResultXDR, tx2.MetaXDR, tx2.LedgerNumber, tx2.LedgerCreatedAt) - require.NoError(t, err) - return nil - }) - require.NoError(t, dbErr) - t.Run("get all", func(t *testing.T) { - ctx := GetTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) + ctx := getTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) txs, err := resolver.Transactions(ctx, nil) require.NoError(t, err) - assert.Len(t, txs, 2) + assert.Len(t, txs, 4) }) t.Run("get with limit", func(t *testing.T) { - ctx := GetTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) + ctx := getTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) limit := int32(1) txs, err := resolver.Transactions(ctx, &limit) require.NoError(t, err) assert.Len(t, txs, 1) }) - cleanUpDB() -} - -func TestQueryResolver_Account(t *testing.T) { - ctx := context.Background() + t.Run("negative limit error", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + limit := int32(-1) + txs, err := resolver.Transactions(ctx, &limit) + require.Error(t, err) + assert.Nil(t, txs) + assert.Contains(t, err.Error(), "limit must be non-negative") + }) - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() + t.Run("zero limit", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + limit := int32(0) + txs, err := resolver.Transactions(ctx, &limit) + require.NoError(t, err) + assert.Len(t, txs, 0) + }) - cleanUpDB := func() { - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM accounts`) + t.Run("limit larger than available data", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + limit := int32(100) + txs, err := resolver.Transactions(ctx, &limit) require.NoError(t, err) - } + assert.Len(t, txs, 4) + }) +} - mockMetricsService := metrics.NewMockMetricsService() +func TestQueryResolver_Account(t *testing.T) { + mockMetricsService := &metrics.MockMetricsService{} mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.Anything).Return() mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Return() defer mockMetricsService.AssertExpectations(t) @@ -180,52 +126,34 @@ func TestQueryResolver_Account(t *testing.T) { &Resolver{ models: &data.Models{ Account: &data.AccountModel{ - DB: dbConnectionPool, + DB: testDBConnectionPool, MetricsService: mockMetricsService, }, }, }, } - expectedAccount := &types.Account{ - StellarAddress: "GC...", - } - - dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO accounts (stellar_address) VALUES ($1)`, - expectedAccount.StellarAddress) + t.Run("success", func(t *testing.T) { + acc, err := resolver.Account(testCtx, "test-account") require.NoError(t, err) - return nil + assert.Equal(t, "test-account", acc.StellarAddress) }) - require.NoError(t, dbErr) - t.Run("success", func(t *testing.T) { - acc, err := resolver.Account(ctx, expectedAccount.StellarAddress) - require.NoError(t, err) - assert.Equal(t, expectedAccount.StellarAddress, acc.StellarAddress) + t.Run("non-existent account", func(t *testing.T) { + acc, err := resolver.Account(testCtx, "non-existent-account") + require.Error(t, err) + assert.Nil(t, acc) }) - cleanUpDB() + t.Run("empty address", func(t *testing.T) { + acc, err := resolver.Account(testCtx, "") + require.Error(t, err) + assert.Nil(t, acc) + }) } func TestQueryResolver_Operations(t *testing.T) { - ctx := context.Background() - - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - cleanUpDB := func() { - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM operations`) - require.NoError(t, err) - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) - require.NoError(t, err) - } - - mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService := &metrics.MockMetricsService{} mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() defer mockMetricsService.AssertExpectations(t) @@ -234,193 +162,108 @@ func TestQueryResolver_Operations(t *testing.T) { &Resolver{ models: &data.Models{ Operations: &data.OperationModel{ - DB: dbConnectionPool, + DB: testDBConnectionPool, MetricsService: mockMetricsService, }, }, }, } - // Insert a transaction to satisfy the foreign key constraint - expectedTx := &types.Transaction{ - Hash: "tx1", - ToID: 1, - EnvelopeXDR: "envelope1", - ResultXDR: "result1", - MetaXDR: "meta1", - LedgerNumber: 1, - LedgerCreatedAt: time.Now(), - } - dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - expectedTx.Hash, expectedTx.ToID, expectedTx.EnvelopeXDR, expectedTx.ResultXDR, expectedTx.MetaXDR, expectedTx.LedgerNumber, expectedTx.LedgerCreatedAt) - require.NoError(t, err) - return nil - }) - require.NoError(t, dbErr) - - op1 := &types.Operation{ - ID: 1, - OperationType: types.OperationTypePayment, - OperationXDR: "op1_xdr", - TxHash: "tx1", - LedgerNumber: 1, - LedgerCreatedAt: time.Now(), - } - op2 := &types.Operation{ - ID: 2, - OperationType: types.OperationTypeCreateAccount, - OperationXDR: "op2_xdr", - TxHash: "tx1", - LedgerNumber: 1, - LedgerCreatedAt: time.Now(), - } - - dbErr = db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO operations (id, operation_type, operation_xdr, tx_hash, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6), ($7, $8, $9, $10, $11, $12)`, - op1.ID, op1.OperationType, op1.OperationXDR, op1.TxHash, op1.LedgerNumber, op1.LedgerCreatedAt, - op2.ID, op2.OperationType, op2.OperationXDR, op2.TxHash, op2.LedgerNumber, op2.LedgerCreatedAt) - require.NoError(t, err) - return nil - }) - require.NoError(t, dbErr) - t.Run("get all", func(t *testing.T) { - ctx := GetTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) + ctx := getTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) ops, err := resolver.Operations(ctx, nil) require.NoError(t, err) - assert.Len(t, ops, 2) + assert.Len(t, ops, 8) }) t.Run("get with limit", func(t *testing.T) { - ctx := GetTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) + ctx := getTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) limit := int32(1) ops, err := resolver.Operations(ctx, &limit) require.NoError(t, err) assert.Len(t, ops, 1) }) - cleanUpDB() + t.Run("negative limit error", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + limit := int32(-5) + ops, err := resolver.Operations(ctx, &limit) + require.Error(t, err) + assert.Nil(t, ops) + assert.Contains(t, err.Error(), "limit must be non-negative") + }) + + t.Run("zero limit", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + limit := int32(0) + ops, err := resolver.Operations(ctx, &limit) + require.NoError(t, err) + assert.Len(t, ops, 0) + }) } func TestQueryResolver_StateChanges(t *testing.T) { - ctx := context.Background() - - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - cleanUpDB := func() { - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM state_changes`) - require.NoError(t, err) - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM operations`) - require.NoError(t, err) - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) - require.NoError(t, err) - } + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + defer mockMetricsService.AssertExpectations(t) resolver := &queryResolver{ &Resolver{ models: &data.Models{ StateChanges: &data.StateChangeModel{ - DB: dbConnectionPool, + DB: testDBConnectionPool, + MetricsService: mockMetricsService, }, }, }, } - // Insert a transaction to satisfy the foreign key constraint - expectedTx := &types.Transaction{ - Hash: "tx1", - ToID: 1, - EnvelopeXDR: "envelope1", - ResultXDR: "result1", - MetaXDR: "meta1", - LedgerNumber: 1, - LedgerCreatedAt: time.Now(), - } - dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - expectedTx.Hash, expectedTx.ToID, expectedTx.EnvelopeXDR, expectedTx.ResultXDR, expectedTx.MetaXDR, expectedTx.LedgerNumber, expectedTx.LedgerCreatedAt) + t.Run("get all", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{"stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) + scs, err := resolver.StateChanges(ctx, nil) require.NoError(t, err) - return nil + assert.Len(t, scs, 20) + // Verify the state changes have the expected account ID + assert.Equal(t, "test-account", scs[0].AccountID) }) - require.NoError(t, dbErr) - // Insert accounts to satisfy the foreign key constraint - dbErr = db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO accounts (stellar_address) VALUES ($1), ($2)`, - "account1", "account2") + t.Run("get with limit", func(t *testing.T) { + limit := int32(3) + ctx := getTestCtx("state_changes", []string{"stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) + scs, err := resolver.StateChanges(ctx, &limit) require.NoError(t, err) - return nil + assert.Len(t, scs, 3) + assert.Equal(t, int64(1008), scs[0].ToID) + assert.Equal(t, int64(2), scs[0].StateChangeOrder) + assert.Equal(t, int64(1008), scs[1].ToID) + assert.Equal(t, int64(1), scs[1].StateChangeOrder) + assert.Equal(t, int64(1007), scs[2].ToID) + assert.Equal(t, int64(2), scs[2].StateChangeOrder) }) - require.NoError(t, dbErr) - - sc1 := &types.StateChange{ - ToID: 1, - StateChangeOrder: 1, - StateChangeCategory: types.StateChangeCategoryCredit, - TxHash: "tx1", - OperationID: 1, - AccountID: "account1", - LedgerCreatedAt: time.Now(), - LedgerNumber: 1, - } - sc2 := &types.StateChange{ - ToID: 1, - StateChangeOrder: 2, - StateChangeCategory: types.StateChangeCategoryDebit, - TxHash: "tx1", - OperationID: 1, - AccountID: "account2", - LedgerCreatedAt: time.Now(), - LedgerNumber: 1, - } - dbErr = db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO state_changes (to_id, state_change_order, state_change_category, tx_hash, operation_id, account_id, ledger_created_at, ledger_number) VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16)`, - sc1.ToID, sc1.StateChangeOrder, sc1.StateChangeCategory, sc1.TxHash, sc1.OperationID, sc1.AccountID, sc1.LedgerCreatedAt, sc1.LedgerNumber, - sc2.ToID, sc2.StateChangeOrder, sc2.StateChangeCategory, sc2.TxHash, sc2.OperationID, sc2.AccountID, sc2.LedgerCreatedAt, sc2.LedgerNumber) - require.NoError(t, err) - return nil + t.Run("negative limit error", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{"accountId"}) + limit := int32(-10) + scs, err := resolver.StateChanges(ctx, &limit) + require.Error(t, err) + assert.Nil(t, scs) + assert.Contains(t, err.Error(), "limit must be non-negative") }) - require.NoError(t, dbErr) - t.Run("get all", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - resolver.models.StateChanges.MetricsService = mockMetricsService - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() - ctx := GetTestCtx("state_changes", []string{"stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) - scs, err := resolver.StateChanges(ctx, nil) + t.Run("zero limit", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{"accountId"}) + limit := int32(0) + scs, err := resolver.StateChanges(ctx, &limit) require.NoError(t, err) - assert.Len(t, scs, 2) - // Verify the state changes have the expected account IDs - accountIDs := []string{scs[0].AccountID, scs[1].AccountID} - assert.Contains(t, accountIDs, "account1") - assert.Contains(t, accountIDs, "account2") - mockMetricsService.AssertExpectations(t) + assert.Len(t, scs, 0) }) - t.Run("get with limit", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - resolver.models.StateChanges.MetricsService = mockMetricsService - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() - limit := int32(1) - ctx := GetTestCtx("state_changes", []string{"id", "stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) + t.Run("limit larger than available data", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{"accountId"}) + limit := int32(50) scs, err := resolver.StateChanges(ctx, &limit) require.NoError(t, err) - assert.Len(t, scs, 1) - mockMetricsService.AssertExpectations(t) + assert.Len(t, scs, 20) }) - - cleanUpDB() } diff --git a/internal/serve/graphql/resolvers/setup_test.go b/internal/serve/graphql/resolvers/setup_test.go new file mode 100644 index 00000000..3001c270 --- /dev/null +++ b/internal/serve/graphql/resolvers/setup_test.go @@ -0,0 +1,39 @@ +package resolvers + +import ( + "context" + "os" + "testing" + + godbtest "github.com/stellar/go/support/db/dbtest" + + "github.com/stellar/wallet-backend/internal/db" + "github.com/stellar/wallet-backend/internal/db/dbtest" +) + +var ( + testCtx context.Context + testDBConnectionPool db.ConnectionPool + testDBT *godbtest.DB +) + +func TestMain(m *testing.M) { + testCtx = context.Background() + + testDBT = dbtest.Open(&testing.T{}) + var err error + testDBConnectionPool, err = db.OpenDBConnectionPool(testDBT.DSN) + if err != nil { + panic(err) + } + + setupDB(testCtx, &testing.T{}, testDBConnectionPool) + + code := m.Run() + + cleanUpDB(testCtx, &testing.T{}, testDBConnectionPool) + testDBConnectionPool.Close() + testDBT.Close() + + os.Exit(code) +} diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go index 2abf6b95..1f96cc92 100644 --- a/internal/serve/graphql/resolvers/statechange_resolvers_test.go +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -4,14 +4,15 @@ import ( "context" "database/sql" "encoding/json" - "errors" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/vikstrous/dataloadgen" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" "github.com/stellar/wallet-backend/internal/serve/middleware" ) @@ -148,79 +149,91 @@ func TestStateChangeResolver_JSONFields(t *testing.T) { } func TestStateChangeResolver_Operation(t *testing.T) { - resolver := &stateChangeResolver{&Resolver{}} - parentSC := &types.StateChange{ToID: 1, StateChangeOrder: 1} - expectedStateChangeID := "1-1" + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &stateChangeResolver{&Resolver{ + models: &data.Models{ + Operations: &data.OperationModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentSC := &types.StateChange{ToID: 1001, StateChangeOrder: 1} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([]*types.Operation, []error) { - assert.Equal(t, []dataloaders.OperationColumnsKey{{StateChangeID: expectedStateChangeID, Columns: "operations.id"}}, keys) - return []*types.Operation{{ID: 99}}, nil - } - - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - OperationByStateChangeIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) op, err := resolver.Operation(ctx, parentSC) require.NoError(t, err) - assert.Equal(t, int64(99), op.ID) + assert.Equal(t, int64(1001), op.ID) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([]*types.Operation, []error) { - return nil, []error{errors.New("op fetch error")} - } + t.Run("nil state change panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - OperationByStateChangeIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + assert.Panics(t, func() { + _, _ = resolver.Operation(ctx, nil) //nolint:errcheck + }) + }) + + t.Run("state change with non-existent operation", func(t *testing.T) { + nonExistentSC := &types.StateChange{ToID: 9999, StateChangeOrder: 1} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - _, err := resolver.Operation(ctx, parentSC) - require.Error(t, err) - assert.EqualError(t, err, "op fetch error") + op, err := resolver.Operation(ctx, nonExistentSC) + require.NoError(t, err) // Dataloader returns nil, not error for missing data + assert.Nil(t, op) }) } func TestStateChangeResolver_Transaction(t *testing.T) { - resolver := &stateChangeResolver{&Resolver{}} - parentSC := &types.StateChange{ToID: 2, StateChangeOrder: 3} - expectedStateChangeID := "2-3" + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &stateChangeResolver{&Resolver{ + models: &data.Models{ + Transactions: &data.TransactionModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentSC := &types.StateChange{ToID: 1, StateChangeOrder: 1} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { - assert.Equal(t, []dataloaders.TransactionColumnsKey{{StateChangeID: expectedStateChangeID, Columns: "transactions.hash"}}, keys) - return []*types.Transaction{{Hash: "tx-abc"}}, nil - } - - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - TransactionByStateChangeIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) tx, err := resolver.Transaction(ctx, parentSC) require.NoError(t, err) - assert.Equal(t, "tx-abc", tx.Hash) + assert.Equal(t, "tx1", tx.Hash) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.TransactionColumnsKey) ([]*types.Transaction, []error) { - return nil, []error{errors.New("tx fetch error")} - } + t.Run("nil state change panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - TransactionByStateChangeIDLoader: loader, - } - ctx := context.WithValue(GetTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) + assert.Panics(t, func() { + _, _ = resolver.Transaction(ctx, nil) //nolint:errcheck + }) + }) + + t.Run("state change with non-existent transaction", func(t *testing.T) { + nonExistentSC := &types.StateChange{ToID: 9999, StateChangeOrder: 1, TxHash: "non-existent-tx"} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - _, err := resolver.Transaction(ctx, parentSC) - require.Error(t, err) - assert.EqualError(t, err, "tx fetch error") + tx, err := resolver.Transaction(ctx, nonExistentSC) + require.NoError(t, err) // Dataloader returns nil, not error for missing data + assert.Nil(t, tx) }) } diff --git a/internal/serve/graphql/resolvers/test_utils.go b/internal/serve/graphql/resolvers/test_utils.go new file mode 100644 index 00000000..77daf454 --- /dev/null +++ b/internal/serve/graphql/resolvers/test_utils.go @@ -0,0 +1,154 @@ +package resolvers + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/stretchr/testify/require" + + "github.com/vektah/gqlparser/v2/ast" + + "github.com/stellar/wallet-backend/internal/db" + "github.com/stellar/wallet-backend/internal/indexer/types" +) + +func getTestCtx(table string, columns []string) context.Context { + opCtx := &graphql.OperationContext{ + Operation: &ast.OperationDefinition{ + SelectionSet: ast.SelectionSet{ + &ast.Field{ + Name: table, + SelectionSet: ast.SelectionSet{}, + }, + }, + }, + } + ctx := graphql.WithOperationContext(context.Background(), opCtx) + var selections ast.SelectionSet + for _, fieldName := range columns { + selections = append(selections, &ast.Field{Name: fieldName}) + } + fieldCtx := &graphql.FieldContext{ + Field: graphql.CollectedField{ + Selections: selections, + }, + } + ctx = graphql.WithFieldContext(ctx, fieldCtx) + return ctx +} + +func setupDB(ctx context.Context, t *testing.T, dbConnectionPool db.ConnectionPool) { + parentAccount := &types.Account{StellarAddress: "test-account"} + txns := make([]*types.Transaction, 0, 4) + ops := make([]*types.Operation, 0, 8) + for i := range 4 { + txns = append(txns, &types.Transaction{ + Hash: fmt.Sprintf("tx%d", i+1), + ToID: int64(i + 1), + EnvelopeXDR: fmt.Sprintf("envelope%d", i+1), + ResultXDR: fmt.Sprintf("result%d", i+1), + MetaXDR: fmt.Sprintf("meta%d", i+1), + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + }) + } + + // Add 2 operations for each transaction + opIdx := 1 + for _, txn := range txns { + for range 2 { + ops = append(ops, &types.Operation{ + ID: int64(opIdx + 1000), + TxHash: txn.Hash, + OperationType: "payment", + OperationXDR: fmt.Sprintf("opxdr%d", opIdx), + LedgerNumber: 1, + LedgerCreatedAt: time.Now(), + }) + opIdx++ + } + } + + // Create 2 state changes per operation (20 total: 2 per operation × 8 operations + 4 fee state changes) + stateChanges := make([]*types.StateChange, 0, 20) + for _, op := range ops { + for scOrder := range 2 { + stateChanges = append(stateChanges, &types.StateChange{ + ToID: op.ID, + StateChangeOrder: int64(scOrder + 1), + StateChangeCategory: types.StateChangeCategoryCredit, + TxHash: op.TxHash, + OperationID: op.ID, + AccountID: parentAccount.StellarAddress, + LedgerCreatedAt: time.Now(), + LedgerNumber: 1, + }) + } + } + // Create fee state changes per transaction + for _, txn := range txns { + stateChanges = append(stateChanges, &types.StateChange{ + ToID: txn.ToID, + StateChangeOrder: int64(1), + StateChangeCategory: types.StateChangeCategoryCredit, + TxHash: txn.Hash, + AccountID: parentAccount.StellarAddress, + LedgerCreatedAt: time.Now(), + LedgerNumber: 1, + }) + } + + dbErr := db.RunInTransaction(context.Background(), dbConnectionPool, nil, func(tx db.Transaction) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO accounts (stellar_address) VALUES ($1)`, + parentAccount.StellarAddress) + require.NoError(t, err) + + for _, txn := range txns { + _, err = tx.ExecContext(ctx, + `INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + txn.Hash, txn.ToID, txn.EnvelopeXDR, txn.ResultXDR, txn.MetaXDR, txn.LedgerNumber, txn.LedgerCreatedAt) + require.NoError(t, err) + + _, err = tx.ExecContext(ctx, + `INSERT INTO transactions_accounts (tx_hash, account_id) VALUES ($1, $2)`, + txn.Hash, parentAccount.StellarAddress) + require.NoError(t, err) + } + + for _, op := range ops { + _, err = tx.ExecContext(ctx, + `INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, $3, $4, $5, $6)`, + op.ID, op.TxHash, op.OperationType, op.OperationXDR, op.LedgerNumber, op.LedgerCreatedAt) + require.NoError(t, err) + + _, err = tx.ExecContext(ctx, + `INSERT INTO operations_accounts (operation_id, account_id) VALUES ($1, $2)`, + op.ID, parentAccount.StellarAddress) + require.NoError(t, err) + } + + for _, sc := range stateChanges { + _, err = tx.ExecContext(ctx, + `INSERT INTO state_changes (to_id, state_change_order, state_change_category, tx_hash, operation_id, account_id, ledger_created_at, ledger_number) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + sc.ToID, sc.StateChangeOrder, sc.StateChangeCategory, sc.TxHash, sc.OperationID, sc.AccountID, sc.LedgerCreatedAt, sc.LedgerNumber) + require.NoError(t, err) + } + return nil + }) + require.NoError(t, dbErr) +} + +func cleanUpDB(ctx context.Context, t *testing.T, dbConnectionPool db.ConnectionPool) { + _, err := dbConnectionPool.ExecContext(ctx, `DELETE FROM state_changes`) + require.NoError(t, err) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM operations`) + require.NoError(t, err) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions`) + require.NoError(t, err) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM accounts`) + require.NoError(t, err) +} diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index 117a96bc..ac06103d 100644 --- a/internal/serve/graphql/resolvers/transaction_resolvers_test.go +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -2,162 +2,174 @@ package resolvers import ( "context" - "errors" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/vikstrous/dataloadgen" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/metrics" "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" "github.com/stellar/wallet-backend/internal/serve/middleware" ) func TestTransactionResolver_Operations(t *testing.T) { - resolver := &transactionResolver{&Resolver{}} - parentTx := &types.Transaction{Hash: "test-tx-hash"} + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &transactionResolver{&Resolver{ + models: &data.Models{ + Operations: &data.OperationModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentTx := &types.Transaction{Hash: "tx1"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { - assert.Equal(t, []dataloaders.OperationColumnsKey{ - {TxHash: "test-tx-hash", Columns: "id"}, - }, keys) - results := [][]*types.Operation{ - { - {ID: 1}, - {ID: 2}, - }, - } - return results, nil - } - - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - OperationsByTxHashLoader: loader, - } - ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) operations, err := resolver.Operations(ctx, parentTx) require.NoError(t, err) require.Len(t, operations, 2) - assert.Equal(t, int64(1), operations[0].ID) - assert.Equal(t, int64(2), operations[1].ID) + assert.Equal(t, int64(1001), operations[0].ID) + assert.Equal(t, int64(1002), operations[1].ID) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.OperationColumnsKey) ([][]*types.Operation, []error) { - return nil, []error{errors.New("op fetch error")} - } + t.Run("nil transaction panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - OperationsByTxHashLoader: loader, - } - ctx := context.WithValue(GetTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + assert.Panics(t, func() { + _, _ = resolver.Operations(ctx, nil) //nolint:errcheck + }) + }) + + t.Run("transaction with no operations", func(t *testing.T) { + nonExistentTx := &types.Transaction{Hash: "non-existent-tx"} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - _, err := resolver.Operations(ctx, parentTx) + operations, err := resolver.Operations(ctx, nonExistentTx) - require.Error(t, err) - assert.EqualError(t, err, "op fetch error") + require.NoError(t, err) + assert.Empty(t, operations) }) } func TestTransactionResolver_Accounts(t *testing.T) { - resolver := &transactionResolver{&Resolver{}} - parentTx := &types.Transaction{Hash: "test-tx-hash"} + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &transactionResolver{&Resolver{ + models: &data.Models{ + Account: &data.AccountModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentTx := &types.Transaction{Hash: "tx1"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { - assert.Equal(t, []dataloaders.AccountColumnsKey{ - {TxHash: "test-tx-hash", Columns: "accounts.stellar_address"}, - }, keys) - results := [][]*types.Account{ - { - {StellarAddress: "G-ACCOUNT1"}, - {StellarAddress: "G-ACCOUNT2"}, - }, - } - return results, nil - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - AccountsByTxHashLoader: loader, - } - ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) accounts, err := resolver.Accounts(ctx, parentTx) require.NoError(t, err) - require.Len(t, accounts, 2) - assert.Equal(t, "G-ACCOUNT1", accounts[0].StellarAddress) - assert.Equal(t, "G-ACCOUNT2", accounts[1].StellarAddress) + require.Len(t, accounts, 1) + assert.Equal(t, "test-account", accounts[0].StellarAddress) + }) + + t.Run("nil transaction panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) + + assert.Panics(t, func() { + _, _ = resolver.Accounts(ctx, nil) //nolint:errcheck + }) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.AccountColumnsKey) ([][]*types.Account, []error) { - return nil, []error{errors.New("account fetch error")} - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - AccountsByTxHashLoader: loader, - } - ctx := context.WithValue(GetTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) + t.Run("transaction with no associated accounts", func(t *testing.T) { + nonExistentTx := &types.Transaction{Hash: "non-existent-tx"} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) - _, err := resolver.Accounts(ctx, parentTx) + accounts, err := resolver.Accounts(ctx, nonExistentTx) - require.Error(t, err) - assert.EqualError(t, err, "account fetch error") + require.NoError(t, err) + assert.Empty(t, accounts) }) } func TestTransactionResolver_StateChanges(t *testing.T) { - resolver := &transactionResolver{&Resolver{}} - parentTx := &types.Transaction{Hash: "test-tx-hash"} + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &transactionResolver{&Resolver{ + models: &data.Models{ + StateChanges: &data.StateChangeModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + Transactions: &data.TransactionModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }} + parentTx := &types.Transaction{Hash: "tx1"} t.Run("success", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { - assert.Equal(t, []dataloaders.StateChangeColumnsKey{ - {TxHash: "test-tx-hash", Columns: "account_id, state_change_category"}, - }, keys) - results := [][]*types.StateChange{ - { - {ToID: 1, StateChangeOrder: 1}, - {ToID: 1, StateChangeOrder: 2}, - }, - } - return results, nil - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - StateChangesByTxHashLoader: loader, - } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) stateChanges, err := resolver.StateChanges(ctx, parentTx) require.NoError(t, err) - require.Len(t, stateChanges, 2) - assert.Equal(t, int64(1), stateChanges[0].ToID) - assert.Equal(t, int64(1), stateChanges[0].StateChangeOrder) - assert.Equal(t, int64(1), stateChanges[1].ToID) - assert.Equal(t, int64(2), stateChanges[1].StateChangeOrder) + require.Len(t, stateChanges, 5) + // For tx1: operations 1 and 2, each with 2 state changes and 1 fee change + assert.Equal(t, int64(1002), stateChanges[0].ToID) + assert.Equal(t, int64(2), stateChanges[0].StateChangeOrder) + assert.Equal(t, int64(1002), stateChanges[1].ToID) + assert.Equal(t, int64(1), stateChanges[1].StateChangeOrder) + assert.Equal(t, int64(1001), stateChanges[2].ToID) + assert.Equal(t, int64(2), stateChanges[2].StateChangeOrder) + assert.Equal(t, int64(1001), stateChanges[3].ToID) + assert.Equal(t, int64(1), stateChanges[3].StateChangeOrder) + assert.Equal(t, int64(1), stateChanges[4].ToID) + assert.Equal(t, int64(1), stateChanges[4].StateChangeOrder) }) - t.Run("dataloader error", func(t *testing.T) { - mockFetch := func(ctx context.Context, keys []dataloaders.StateChangeColumnsKey) ([][]*types.StateChange, []error) { - return nil, []error{errors.New("sc fetch error")} - } - loader := dataloadgen.NewLoader(mockFetch) - loaders := &dataloaders.Dataloaders{ - StateChangesByTxHashLoader: loader, - } - ctx := context.WithValue(GetTestCtx("state_changes", []string{"id"}), middleware.LoadersKey, loaders) + t.Run("nil transaction panics", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + + assert.Panics(t, func() { + _, _ = resolver.StateChanges(ctx, nil) //nolint:errcheck + }) + }) - _, err := resolver.StateChanges(ctx, parentTx) + t.Run("transaction with no state changes", func(t *testing.T) { + nonExistentTx := &types.Transaction{Hash: "non-existent-tx"} + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) - require.Error(t, err) - assert.EqualError(t, err, "sc fetch error") + stateChanges, err := resolver.StateChanges(ctx, nonExistentTx) + + require.NoError(t, err) + assert.Empty(t, stateChanges) }) } From 8674e4a77f47e30832da8f658ee60a7efa8307a1 Mon Sep 17 00:00:00 2001 From: akcays Date: Wed, 13 Aug 2025 11:50:53 -0400 Subject: [PATCH 07/16] Remove `tx/create-sponsored-account` endpoint (#267) * Remove /tx/create-sponsored-account endpoint * Remove accountSponsorhipService and move wrapTransaction to feeBumpService --- internal/serve/httphandler/account_handler.go | 53 +- .../serve/httphandler/account_handler_test.go | 340 +---------- internal/serve/serve.go | 51 +- .../services/account_sponsorship_service.go | 280 --------- .../account_sponsorship_service_test.go | 564 ------------------ internal/services/fee_bump_service.go | 139 +++++ internal/services/fee_bump_service_test.go | 270 +++++++++ internal/services/mocks.go | 11 +- openapi/main.yaml | 101 ---- 9 files changed, 445 insertions(+), 1364 deletions(-) delete mode 100644 internal/services/account_sponsorship_service.go delete mode 100644 internal/services/account_sponsorship_service_test.go create mode 100644 internal/services/fee_bump_service.go create mode 100644 internal/services/fee_bump_service_test.go diff --git a/internal/serve/httphandler/account_handler.go b/internal/serve/httphandler/account_handler.go index b8b65ac5..bd2f7817 100644 --- a/internal/serve/httphandler/account_handler.go +++ b/internal/serve/httphandler/account_handler.go @@ -8,61 +8,14 @@ import ( "github.com/stellar/go/txnbuild" "github.com/stellar/wallet-backend/internal/apptracker" - "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/serve/httperror" "github.com/stellar/wallet-backend/internal/services" "github.com/stellar/wallet-backend/pkg/wbclient/types" ) type AccountHandler struct { - AccountService services.AccountService - AccountSponsorshipService services.AccountSponsorshipService - SupportedAssets []entities.Asset - AppTracker apptracker.AppTracker -} - -type SponsorAccountCreationRequest struct { - Address string `json:"address" validate:"required,public_key"` - Signers []entities.Signer `json:"signers" validate:"required,gt=0,dive"` -} - -func (h AccountHandler) SponsorAccountCreation(rw http.ResponseWriter, req *http.Request) { - ctx := req.Context() - - var reqBody SponsorAccountCreationRequest - httpErr := DecodeJSONAndValidate(ctx, req, &reqBody, h.AppTracker) - if httpErr != nil { - httpErr.Render(rw) - return - } - - _, err := entities.ValidateSignersWeights(reqBody.Signers) - if err != nil { - httperror.BadRequest("Validation error.", map[string]interface{}{"signers": err.Error()}).Render(rw) - return - } - - txe, networkPassphrase, err := h.AccountSponsorshipService.SponsorAccountCreationTransaction(ctx, reqBody.Address, reqBody.Signers, h.SupportedAssets) - if err != nil { - if errors.Is(err, services.ErrSponsorshipLimitExceeded) { - httperror.BadRequest("Sponsorship limit exceeded.", nil).Render(rw) - return - } - - if errors.Is(err, services.ErrAccountAlreadyExists) { - httperror.BadRequest("Account already exists.", nil).Render(rw) - return - } - - httperror.InternalServerError(ctx, "", err, nil, h.AppTracker).Render(rw) - return - } - - respBody := types.TransactionEnvelopeResponse{ - Transaction: txe, - NetworkPassphrase: networkPassphrase, - } - httpjson.Render(rw, respBody, httpjson.JSON) + FeeBumpService services.FeeBumpService + AppTracker apptracker.AppTracker } func (h AccountHandler) CreateFeeBumpTransaction(rw http.ResponseWriter, req *http.Request) { @@ -87,7 +40,7 @@ func (h AccountHandler) CreateFeeBumpTransaction(rw http.ResponseWriter, req *ht return } - feeBumpTxe, networkPassphrase, err := h.AccountSponsorshipService.WrapTransaction(ctx, tx) + feeBumpTxe, networkPassphrase, err := h.FeeBumpService.WrapTransaction(ctx, tx) if err != nil { var opNotAllowedErr *services.OperationNotAllowedError switch { diff --git a/internal/serve/httphandler/account_handler_test.go b/internal/serve/httphandler/account_handler_test.go index a8c96e48..9a645069 100644 --- a/internal/serve/httphandler/account_handler_test.go +++ b/internal/serve/httphandler/account_handler_test.go @@ -15,339 +15,15 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/services" ) -func TestAccountHandlerSponsorAccountCreation(t *testing.T) { - asService := services.AccountSponsorshipServiceMock{} - defer asService.AssertExpectations(t) - - assets := []entities.Asset{ - { - Code: "USDC", - Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - }, - { - Code: "ARST", - Issuer: "GB7TAYRUZGE6TVT7NHP5SMIZRNQA6PLM423EYISAOAP3MKYIQMVYP2JO", - }, - } - handler := &AccountHandler{ - AccountSponsorshipService: &asService, - SupportedAssets: assets, - } - - const endpoint = "/tx/create-sponsored-account" - - t.Run("invalid_request_body", func(t *testing.T) { - // Empty body - reqBody := `{}` - rw := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - http.HandlerFunc(handler.SponsorAccountCreation).ServeHTTP(rw, req) - - resp := rw.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - expectedRespBody := ` - { - "error": "Validation error.", - "extras": { - "address": "This field is required", - "signers": "This field is required" - } - } - ` - assert.JSONEq(t, expectedRespBody, string(respBody)) - - // Invalid values - reqBody = ` - { - "address": "invalid", - "signers": [] - } - ` - rw = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - http.HandlerFunc(handler.SponsorAccountCreation).ServeHTTP(rw, req) - - resp = rw.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - expectedRespBody = ` - { - "error": "Validation error.", - "extras": { - "address": "Invalid public key provided", - "signers": "Should have at least 1 element(s)" - } - } - ` - assert.JSONEq(t, expectedRespBody, string(respBody)) - - reqBody = fmt.Sprintf(` - { - "address": %q, - "signers": [ - { - "address": "invalid", - "weight": 0, - "type": "test" - }, - { - "address": "invalid", - "weight": 0, - "type": "test" - } - ] - } - `, keypair.MustRandom().Address()) - rw = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - http.HandlerFunc(handler.SponsorAccountCreation).ServeHTTP(rw, req) - - resp = rw.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - expectedRespBody = ` - { - "error": "Validation error.", - "extras": { - "signers[0].address": "Invalid public key provided", - "signers[0].type": "Unexpected value \"test\". Expected one of the following values: full, partial", - "signers[0].weight": "Should be greater than or equal to 1", - "signers[1].address": "Invalid public key provided", - "signers[1].type": "Unexpected value \"test\". Expected one of the following values: full, partial", - "signers[1].weight": "Should be greater than or equal to 1" - } - } - ` - assert.JSONEq(t, expectedRespBody, string(respBody)) - }) - - t.Run("validate_signers_weight", func(t *testing.T) { - reqBody := fmt.Sprintf(` - { - "address": %q, - "signers": [ - { - "address": %q, - "weight": 10, - "type": %q - }, - { - "address": %q, - "weight": 10, - "type": %q - } - ] - } - `, keypair.MustRandom().Address(), keypair.MustRandom().Address(), entities.FullSignerType, keypair.MustRandom().Address(), entities.PartialSignerType) - rw := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - http.HandlerFunc(handler.SponsorAccountCreation).ServeHTTP(rw, req) - - resp := rw.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - expectedRespBody := ` - { - "error": "Validation error.", - "extras": { - "signers": "all partial signers' weights must be less than the weight of full signers" - } - } - ` - assert.JSONEq(t, expectedRespBody, string(respBody)) - }) - - t.Run("account_already_exists", func(t *testing.T) { - accountToSponsor := keypair.MustRandom().Address() - fullSigner := entities.Signer{ - Address: keypair.MustRandom().Address(), - Weight: 15, - Type: entities.FullSignerType, - } - partialSigner := entities.Signer{ - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - } - - reqBody := fmt.Sprintf(` - { - "address": %q, - "signers": [ - { - "address": %q, - "weight": %d, - "type": %q - }, - { - "address": %q, - "weight": %d, - "type": %q - } - ] - } - `, accountToSponsor, fullSigner.Address, fullSigner.Weight, fullSigner.Type, partialSigner.Address, partialSigner.Weight, partialSigner.Type) - rw := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - asService. - On("SponsorAccountCreationTransaction", req.Context(), accountToSponsor, []entities.Signer{fullSigner, partialSigner}, assets). - Return("", "", services.ErrAccountAlreadyExists). - Once() - - http.HandlerFunc(handler.SponsorAccountCreation).ServeHTTP(rw, req) - - resp := rw.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - expectedRespBody := ` - { - "error": "Account already exists." - } - ` - assert.JSONEq(t, expectedRespBody, string(respBody)) - }) - - t.Run("account_sponsorship_limit_exceeded", func(t *testing.T) { - accountToSponsor := keypair.MustRandom().Address() - fullSigner := entities.Signer{ - Address: keypair.MustRandom().Address(), - Weight: 15, - Type: entities.FullSignerType, - } - partialSigner := entities.Signer{ - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - } - - reqBody := fmt.Sprintf(` - { - "address": %q, - "signers": [ - { - "address": %q, - "weight": %d, - "type": %q - }, - { - "address": %q, - "weight": %d, - "type": %q - } - ] - } - `, accountToSponsor, fullSigner.Address, fullSigner.Weight, fullSigner.Type, partialSigner.Address, partialSigner.Weight, partialSigner.Type) - rw := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - asService. - On("SponsorAccountCreationTransaction", req.Context(), accountToSponsor, []entities.Signer{fullSigner, partialSigner}, assets). - Return("", "", services.ErrSponsorshipLimitExceeded). - Once() - - http.HandlerFunc(handler.SponsorAccountCreation).ServeHTTP(rw, req) - - resp := rw.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - expectedRespBody := ` - { - "error": "Sponsorship limit exceeded." - } - ` - assert.JSONEq(t, expectedRespBody, string(respBody)) - }) - - t.Run("successfully_returns_the_account_sponsorship_transaction_envelope", func(t *testing.T) { - accountToSponsor := keypair.MustRandom().Address() - fullSigner := entities.Signer{ - Address: keypair.MustRandom().Address(), - Weight: 15, - Type: entities.FullSignerType, - } - partialSigner := entities.Signer{ - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - } - - reqBody := fmt.Sprintf(` - { - "address": %q, - "signers": [ - { - "address": %q, - "weight": %d, - "type": %q - }, - { - "address": %q, - "weight": %d, - "type": %q - } - ] - } - `, accountToSponsor, fullSigner.Address, fullSigner.Weight, fullSigner.Type, partialSigner.Address, partialSigner.Weight, partialSigner.Type) - rw := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - - asService. - On("SponsorAccountCreationTransaction", req.Context(), accountToSponsor, []entities.Signer{fullSigner, partialSigner}, assets). - Return("tx-envelope", network.TestNetworkPassphrase, nil). - Once() - - http.HandlerFunc(handler.SponsorAccountCreation).ServeHTTP(rw, req) - - resp := rw.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - expectedRespBody := ` - { - "transaction": "tx-envelope", - "networkPassphrase": "Test SDF Network ; September 2015" - } - ` - assert.JSONEq(t, expectedRespBody, string(respBody)) - }) -} - func TestAccountHandlerCreateFeeBumpTransaction(t *testing.T) { - asService := services.AccountSponsorshipServiceMock{} - defer asService.AssertExpectations(t) + fbService := services.FeeBumpServiceMock{} + defer fbService.AssertExpectations(t) handler := &AccountHandler{ - AccountSponsorshipService: &asService, + FeeBumpService: &fbService, } const endpoint = "/tx/create-fee-bump" @@ -479,7 +155,7 @@ func TestAccountHandlerCreateFeeBumpTransaction(t *testing.T) { rw := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - asService. + fbService. On("WrapTransaction", req.Context(), tx). Return("", "", services.ErrAccountNotEligibleForBeingSponsored). Once() @@ -528,7 +204,7 @@ func TestAccountHandlerCreateFeeBumpTransaction(t *testing.T) { rw := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - asService. + fbService. On("WrapTransaction", req.Context(), tx). Return("", "", services.ErrFeeExceedsMaximumBaseFee). Once() @@ -577,7 +253,7 @@ func TestAccountHandlerCreateFeeBumpTransaction(t *testing.T) { rw := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - asService. + fbService. On("WrapTransaction", req.Context(), tx). Return("", "", services.ErrNoSignaturesProvided). Once() @@ -626,7 +302,7 @@ func TestAccountHandlerCreateFeeBumpTransaction(t *testing.T) { rw := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - asService. + fbService. On("WrapTransaction", req.Context(), tx). Return("", "", &services.OperationNotAllowedError{OperationType: xdr.OperationTypeLiquidityPoolDeposit}). Once() @@ -675,7 +351,7 @@ func TestAccountHandlerCreateFeeBumpTransaction(t *testing.T) { rw := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) - asService. + fbService. On("WrapTransaction", req.Context(), tx). Return("fee-bump-envelope", network.TestNetworkPassphrase, nil). Once() diff --git a/internal/serve/serve.go b/internal/serve/serve.go index eeafd3a7..6738b20f 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -74,12 +74,12 @@ type handlerDeps struct { NetworkPassphrase string // Services - AccountService services.AccountService - AccountSponsorshipService services.AccountSponsorshipService - PaymentService services.PaymentService - MetricsService metrics.MetricsService - TransactionService txservices.TransactionService - RPCService services.RPCService + AccountService services.AccountService + FeeBumpService services.FeeBumpService + PaymentService services.PaymentService + MetricsService metrics.MetricsService + TransactionService txservices.TransactionService + RPCService services.RPCService // Error Tracker AppTracker apptracker.AppTracker @@ -142,17 +142,14 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { return handlerDeps{}, fmt.Errorf("instantiating account service: %w", err) } - accountSponsorshipService, err := services.NewAccountSponsorshipService(services.AccountSponsorshipServiceOptions{ + feeBumpService, err := services.NewFeeBumpService(services.FeeBumpServiceOptions{ DistributionAccountSignatureClient: cfg.DistributionAccountSignatureClient, - ChannelAccountSignatureClient: cfg.ChannelAccountSignatureClient, - RPCService: rpcService, - MaxSponsoredBaseReserves: cfg.MaxSponsoredBaseReserves, BaseFee: int64(cfg.BaseFee), Models: models, BlockedOperationsTypes: blockedOperationTypes, }) if err != nil { - return handlerDeps{}, fmt.Errorf("instantiating account sponsorship service: %w", err) + return handlerDeps{}, fmt.Errorf("instantiating fee bump service: %w", err) } paymentService, err := services.NewPaymentService(models, cfg.ServerBaseURL) @@ -193,18 +190,18 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { } return handlerDeps{ - Models: models, - ServerHostname: serverHostname.Hostname(), - RequestAuthVerifier: requestAuthVerifier, - SupportedAssets: cfg.SupportedAssets, - AccountService: accountService, - AccountSponsorshipService: accountSponsorshipService, - PaymentService: paymentService, - MetricsService: metricsService, - RPCService: rpcService, - AppTracker: cfg.AppTracker, - NetworkPassphrase: cfg.NetworkPassphrase, - TransactionService: txService, + Models: models, + ServerHostname: serverHostname.Hostname(), + RequestAuthVerifier: requestAuthVerifier, + SupportedAssets: cfg.SupportedAssets, + AccountService: accountService, + FeeBumpService: feeBumpService, + PaymentService: paymentService, + MetricsService: metricsService, + RPCService: rpcService, + AppTracker: cfg.AppTracker, + NetworkPassphrase: cfg.NetworkPassphrase, + TransactionService: txService, }, nil } @@ -270,16 +267,12 @@ func handler(deps handlerDeps) http.Handler { r.Get("/", handler.GetPayments) }) - // TODO: Bring create-fee-bump and build under /transactions. Move create-sponsored-account to /accounts. r.Route("/tx", func(r chi.Router) { accountHandler := &httphandler.AccountHandler{ - AccountService: deps.AccountService, - AccountSponsorshipService: deps.AccountSponsorshipService, - SupportedAssets: deps.SupportedAssets, - AppTracker: deps.AppTracker, + FeeBumpService: deps.FeeBumpService, + AppTracker: deps.AppTracker, } - r.Post("/create-sponsored-account", accountHandler.SponsorAccountCreation) r.Post("/create-fee-bump", accountHandler.CreateFeeBumpTransaction) }) }) diff --git a/internal/services/account_sponsorship_service.go b/internal/services/account_sponsorship_service.go deleted file mode 100644 index 9d97182a..00000000 --- a/internal/services/account_sponsorship_service.go +++ /dev/null @@ -1,280 +0,0 @@ -package services - -import ( - "context" - "errors" - "fmt" - "slices" - - "github.com/stellar/go/support/log" - "github.com/stellar/go/txnbuild" - "github.com/stellar/go/xdr" - - "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/entities" - "github.com/stellar/wallet-backend/internal/signing" -) - -var ( - ErrAccountAlreadyExists = errors.New("account already exists") - ErrSponsorshipLimitExceeded = errors.New("sponsorship limit exceeded") - ErrAccountNotEligibleForBeingSponsored = errors.New("account not eligible for being sponsored") - ErrFeeExceedsMaximumBaseFee = errors.New("fee exceeds maximum base fee to sponsor") - ErrNoSignaturesProvided = errors.New("should have at least one signature") - ErrAccountNotFound = errors.New("account not found") -) - -type OperationNotAllowedError struct { - OperationType xdr.OperationType -} - -func (e OperationNotAllowedError) Error() string { - return fmt.Sprintf("operation %s not allowed", e.OperationType.String()) -} - -const ( - // Sufficient to cover three average ledger close time. - CreateAccountTxnTimeBounds = 18 - CreateAccountTxnTimeBoundsSafetyMargin = 12 -) - -type AccountSponsorshipService interface { - SponsorAccountCreationTransaction(ctx context.Context, address string, signers []entities.Signer, supportedAssets []entities.Asset) (string, string, error) - WrapTransaction(ctx context.Context, tx *txnbuild.Transaction) (string, string, error) -} - -type accountSponsorshipService struct { - DistributionAccountSignatureClient signing.SignatureClient - ChannelAccountSignatureClient signing.SignatureClient - RPCService RPCService - MaxSponsoredBaseReserves int - BaseFee int64 - Models *data.Models - BlockedOperationsTypes []xdr.OperationType -} - -var _ AccountSponsorshipService = (*accountSponsorshipService)(nil) - -func (s *accountSponsorshipService) SponsorAccountCreationTransaction(ctx context.Context, accountToSponsor string, signers []entities.Signer, supportedAssets []entities.Asset) (string, string, error) { - // Check the accountToSponsor does not exist on Stellar - _, err := s.RPCService.GetAccountLedgerSequence(accountToSponsor) - if err == nil { - return "", "", ErrAccountAlreadyExists - } - if !errors.Is(err, ErrAccountNotFound) { - return "", "", fmt.Errorf("getting details for account %s: %w", accountToSponsor, err) - } - - fullSignerWeight, err := entities.ValidateSignersWeights(signers) - if err != nil { - return "", "", fmt.Errorf("validating signers weights: %w", err) - } - - // Make sure the total number of entries does not exceed the numSponsoredThreshold - numEntries := 2 + len(supportedAssets) + len(signers) // 2 entries for account creation + 1 entry per supported asset + 1 entry per signer - if numEntries > s.MaxSponsoredBaseReserves { - return "", "", ErrSponsorshipLimitExceeded - } - - distributionAccountPublicKey, err := s.DistributionAccountSignatureClient.GetAccountPublicKey(ctx) - if err != nil { - return "", "", fmt.Errorf("getting distribution account public key: %w", err) - } - - fullSignerThreshold := txnbuild.NewThreshold(txnbuild.Threshold(fullSignerWeight)) - ops := []txnbuild.Operation{ - &txnbuild.BeginSponsoringFutureReserves{ - SponsoredID: accountToSponsor, - SourceAccount: distributionAccountPublicKey, - }, - &txnbuild.CreateAccount{ - Destination: accountToSponsor, - Amount: "0", - SourceAccount: distributionAccountPublicKey, - }, - } - for _, signer := range signers { - ops = append(ops, &txnbuild.SetOptions{ - Signer: &txnbuild.Signer{Address: signer.Address, Weight: txnbuild.Threshold(signer.Weight)}, - SourceAccount: accountToSponsor, - }) - } - for _, asset := range supportedAssets { - ops = append(ops, &txnbuild.ChangeTrust{ - Line: txnbuild.CreditAsset{ - Code: asset.Code, - Issuer: asset.Issuer, - }.MustToChangeTrustAsset(), - SourceAccount: accountToSponsor, - }) - } - ops = append(ops, - &txnbuild.EndSponsoringFutureReserves{ - SourceAccount: accountToSponsor, - }, - &txnbuild.SetOptions{ - MasterWeight: txnbuild.NewThreshold(0), - LowThreshold: fullSignerThreshold, - MediumThreshold: fullSignerThreshold, - HighThreshold: fullSignerThreshold, - SourceAccount: accountToSponsor, - }, - ) - - channelAccountPublicKey, err := s.ChannelAccountSignatureClient.GetAccountPublicKey(ctx) - if err != nil { - return "", "", fmt.Errorf("getting channel account public key: %w", err) - } - - channelAccountSeq, err := s.RPCService.GetAccountLedgerSequence(channelAccountPublicKey) - if err != nil { - return "", "", fmt.Errorf("getting sequence number for channel account public key: %s: %w", channelAccountPublicKey, err) - } - - tx, err := txnbuild.NewTransaction( - txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: channelAccountPublicKey, - Sequence: channelAccountSeq, - }, - IncrementSequenceNum: true, - Operations: ops, - BaseFee: s.BaseFee, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(CreateAccountTxnTimeBounds + CreateAccountTxnTimeBoundsSafetyMargin)}, - }, - ) - if err != nil { - return "", "", fmt.Errorf("building transaction: %w", err) - } - - tx, err = s.ChannelAccountSignatureClient.SignStellarTransaction(ctx, tx, channelAccountPublicKey) - if err != nil { - return "", "", fmt.Errorf("signing transaction: %w", err) - } - - tx, err = s.DistributionAccountSignatureClient.SignStellarTransaction(ctx, tx, distributionAccountPublicKey) - if err != nil { - return "", "", fmt.Errorf("signing transaction: %w", err) - } - - txe, err := tx.Base64() - if err != nil { - return "", "", fmt.Errorf("getting transaction envelope: %w", err) - } - - if err := s.Models.Account.Insert(ctx, accountToSponsor); err != nil { - return "", "", fmt.Errorf("inserting the sponsored account: %w", err) - } - - return txe, s.ChannelAccountSignatureClient.NetworkPassphrase(), nil -} - -// WrapTransaction wraps a stellar transaction with a fee bump transaction with the configured distribution account as the fee account. -func (s *accountSponsorshipService) WrapTransaction(ctx context.Context, tx *txnbuild.Transaction) (string, string, error) { - isFeeBumpEligible, err := s.Models.Account.IsAccountFeeBumpEligible(ctx, tx.SourceAccount().AccountID) - if err != nil { - return "", "", fmt.Errorf("checking if transaction source account is eligible for being fee-bumped: %w", err) - } - if !isFeeBumpEligible { - return "", "", ErrAccountNotEligibleForBeingSponsored - } - - for _, op := range tx.Operations() { - operationXDR, innerErr := op.BuildXDR() - if innerErr != nil { - return "", "", fmt.Errorf("retrieving xdr for operation: %w", innerErr) - } - - if slices.Contains(s.BlockedOperationsTypes, operationXDR.Body.Type) { - log.Ctx(ctx).Warnf("blocked operation type: %s", operationXDR.Body.Type.String()) - return "", "", &OperationNotAllowedError{OperationType: operationXDR.Body.Type} - } - } - - if tx.BaseFee() > s.BaseFee { - return "", "", ErrFeeExceedsMaximumBaseFee - } - - sigs := tx.Signatures() - if len(sigs) == 0 { - return "", "", ErrNoSignaturesProvided - } - - distributionAccountPublicKey, err := s.DistributionAccountSignatureClient.GetAccountPublicKey(ctx) - if err != nil { - return "", "", fmt.Errorf("getting distribution account public key: %w", err) - } - - feeBumpTx, err := txnbuild.NewFeeBumpTransaction( - txnbuild.FeeBumpTransactionParams{ - Inner: tx, - FeeAccount: distributionAccountPublicKey, - BaseFee: s.BaseFee, - }, - ) - if err != nil { - return "", "", fmt.Errorf("creating fee-bump transaction: %w", err) - } - - signedFeeBumpTx, err := s.DistributionAccountSignatureClient.SignStellarFeeBumpTransaction(ctx, feeBumpTx) - if err != nil { - return "", "", fmt.Errorf("signing fee-bump transaction: %w", err) - } - - feeBumpTxe, err := signedFeeBumpTx.Base64() - if err != nil { - return "", "", fmt.Errorf("getting transaction envelope: %w", err) - } - - return feeBumpTxe, s.DistributionAccountSignatureClient.NetworkPassphrase(), nil -} - -type AccountSponsorshipServiceOptions struct { - DistributionAccountSignatureClient signing.SignatureClient - ChannelAccountSignatureClient signing.SignatureClient - RPCService RPCService - MaxSponsoredBaseReserves int - BaseFee int64 - Models *data.Models - BlockedOperationsTypes []xdr.OperationType -} - -func (o *AccountSponsorshipServiceOptions) Validate() error { - if o.DistributionAccountSignatureClient == nil { - return fmt.Errorf("distribution account signature client cannot be nil") - } - - if o.RPCService == nil { - return fmt.Errorf("rpc client cannot be nil") - } - - if o.ChannelAccountSignatureClient == nil { - return fmt.Errorf("channel account signature client cannot be nil") - } - - if o.BaseFee < int64(txnbuild.MinBaseFee) { - return fmt.Errorf("base fee is lower than the minimum network fee") - } - - if o.Models == nil { - return fmt.Errorf("models cannot be nil") - } - - return nil -} - -func NewAccountSponsorshipService(opts AccountSponsorshipServiceOptions) (*accountSponsorshipService, error) { - if err := opts.Validate(); err != nil { - return nil, fmt.Errorf("validating account sponsorship service options: %w", err) - } - - return &accountSponsorshipService{ - DistributionAccountSignatureClient: opts.DistributionAccountSignatureClient, - ChannelAccountSignatureClient: opts.ChannelAccountSignatureClient, - RPCService: opts.RPCService, - MaxSponsoredBaseReserves: opts.MaxSponsoredBaseReserves, - BaseFee: opts.BaseFee, - Models: opts.Models, - BlockedOperationsTypes: opts.BlockedOperationsTypes, - }, nil -} diff --git a/internal/services/account_sponsorship_service_test.go b/internal/services/account_sponsorship_service_test.go deleted file mode 100644 index 80c5b840..00000000 --- a/internal/services/account_sponsorship_service_test.go +++ /dev/null @@ -1,564 +0,0 @@ -package services - -import ( - "context" - "testing" - - "github.com/stellar/go/keypair" - "github.com/stellar/go/network" - "github.com/stellar/go/txnbuild" - "github.com/stellar/go/xdr" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/db/dbtest" - "github.com/stellar/wallet-backend/internal/entities" - "github.com/stellar/wallet-backend/internal/metrics" - "github.com/stellar/wallet-backend/internal/signing" -) - -func TestAccountSponsorshipServiceSponsorAccountCreationTransaction(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - mockMetricsService := metrics.NewMockMetricsService() - - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - - signatureClient := signing.SignatureClientMock{} - defer signatureClient.AssertExpectations(t) - mockRPCService := RPCServiceMock{} - - ctx := context.Background() - s, err := NewAccountSponsorshipService(AccountSponsorshipServiceOptions{ - DistributionAccountSignatureClient: &signatureClient, - ChannelAccountSignatureClient: &signatureClient, - RPCService: &mockRPCService, - MaxSponsoredBaseReserves: 10, - BaseFee: txnbuild.MinBaseFee, - Models: models, - BlockedOperationsTypes: []xdr.OperationType{}, - }) - require.NoError(t, err) - - t.Run("account_already_exists", func(t *testing.T) { - defer mockMetricsService.AssertExpectations(t) - accountToSponsor := keypair.MustRandom().Address() - - mockRPCService. - On("GetAccountLedgerSequence", accountToSponsor). - Return(int64(1), nil). - Once() - defer mockRPCService.AssertExpectations(t) - - txe, networkPassphrase, err := s.SponsorAccountCreationTransaction(ctx, accountToSponsor, []entities.Signer{}, []entities.Asset{}) - assert.ErrorIs(t, ErrAccountAlreadyExists, err) - assert.Empty(t, txe) - assert.Empty(t, networkPassphrase) - }) - - t.Run("invalid_signers_weight", func(t *testing.T) { - defer mockMetricsService.AssertExpectations(t) - accountToSponsor := keypair.MustRandom().Address() - - mockRPCService. - On("GetAccountLedgerSequence", accountToSponsor). - Return(int64(0), ErrAccountNotFound). - Once() - defer mockRPCService.AssertExpectations(t) - - signers := []entities.Signer{ - { - Address: keypair.MustRandom().Address(), - Weight: 0, - Type: entities.PartialSignerType, - }, - } - - txe, networkPassphrase, err := s.SponsorAccountCreationTransaction(ctx, accountToSponsor, signers, []entities.Asset{}) - assert.EqualError(t, err, "validating signers weights: no full signers provided") - assert.Empty(t, txe) - assert.Empty(t, networkPassphrase) - }) - - t.Run("sponsorship_limit_reached", func(t *testing.T) { - defer mockMetricsService.AssertExpectations(t) - accountToSponsor := keypair.MustRandom().Address() - - mockRPCService. - On("GetAccountLedgerSequence", accountToSponsor). - Return(int64(0), ErrAccountNotFound). - Once() - defer mockRPCService.AssertExpectations(t) - - signers := []entities.Signer{ - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 20, - Type: entities.FullSignerType, - }, - } - - assets := []entities.Asset{ - { - Code: "USDC", - Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - }, - { - Code: "ARST", - Issuer: "GB7TAYRUZGE6TVT7NHP5SMIZRNQA6PLM423EYISAOAP3MKYIQMVYP2JO", - }, - } - - txe, networkPassphrase, err := s.SponsorAccountCreationTransaction(ctx, accountToSponsor, signers, assets) - assert.ErrorIs(t, ErrSponsorshipLimitExceeded, err) - assert.Empty(t, txe) - assert.Empty(t, networkPassphrase) - }) - - t.Run("successfully_returns_a_sponsored_transaction", func(t *testing.T) { - accountToSponsor := keypair.MustRandom().Address() - distributionAccount := keypair.MustRandom() - - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() - defer mockMetricsService.AssertExpectations(t) - - signers := []entities.Signer{ - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 10, - Type: entities.PartialSignerType, - }, - { - Address: keypair.MustRandom().Address(), - Weight: 20, - Type: entities.FullSignerType, - }, - } - - assets := []entities.Asset{ - { - Code: "USDC", - Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - }, - { - Code: "ARST", - Issuer: "GB7TAYRUZGE6TVT7NHP5SMIZRNQA6PLM423EYISAOAP3MKYIQMVYP2JO", - }, - } - - mockRPCService. - On("GetAccountLedgerSequence", accountToSponsor). - Return(int64(0), ErrAccountNotFound). - Once(). - On("GetAccountLedgerSequence", distributionAccount.Address()). - Return(int64(1), nil). - Once() - defer mockRPCService.AssertExpectations(t) - - signedTx := txnbuild.Transaction{} - signatureClient. - On("GetAccountPublicKey", ctx). - Return(distributionAccount.Address(), nil). - Times(2). - On("NetworkPassphrase"). - Return(network.TestNetworkPassphrase). - Once(). - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), []string{distributionAccount.Address()}). - Run(func(args mock.Arguments) { - tx, ok := args.Get(1).(*txnbuild.Transaction) - require.True(t, ok) - - // We make this workaround because the signers' SetOptions has some inner data that can't be asserted. - txe, err := tx.Base64() - require.NoError(t, err) - genericTx, err := txnbuild.TransactionFromXDR(txe) - require.NoError(t, err) - tx, ok = genericTx.Transaction() - require.True(t, ok) - - assert.Equal(t, distributionAccount.Address(), tx.SourceAccount().AccountID) - assert.Equal(t, txnbuild.NewTimeout(CreateAccountTxnTimeBounds+CreateAccountTxnTimeBoundsSafetyMargin), tx.Timebounds()) - assert.Equal(t, []txnbuild.Operation{ - &txnbuild.BeginSponsoringFutureReserves{ - SponsoredID: accountToSponsor, - SourceAccount: distributionAccount.Address(), - }, - &txnbuild.CreateAccount{ - Destination: accountToSponsor, - Amount: "0.0000000", - SourceAccount: distributionAccount.Address(), - }, - &txnbuild.SetOptions{ - Signer: &txnbuild.Signer{Address: signers[0].Address, Weight: txnbuild.Threshold(signers[0].Weight)}, - SourceAccount: accountToSponsor, - }, - &txnbuild.SetOptions{ - Signer: &txnbuild.Signer{Address: signers[1].Address, Weight: txnbuild.Threshold(signers[1].Weight)}, - SourceAccount: accountToSponsor, - }, - &txnbuild.SetOptions{ - Signer: &txnbuild.Signer{Address: signers[2].Address, Weight: txnbuild.Threshold(signers[2].Weight)}, - SourceAccount: accountToSponsor, - }, - &txnbuild.ChangeTrust{ - Line: txnbuild.CreditAsset{ - Code: assets[0].Code, - Issuer: assets[0].Issuer, - }.MustToChangeTrustAsset(), - Limit: "922337203685.4775807", - SourceAccount: accountToSponsor, - }, - &txnbuild.ChangeTrust{ - Line: txnbuild.CreditAsset{ - Code: assets[1].Code, - Issuer: assets[1].Issuer, - }.MustToChangeTrustAsset(), - Limit: "922337203685.4775807", - SourceAccount: accountToSponsor, - }, - &txnbuild.EndSponsoringFutureReserves{ - SourceAccount: accountToSponsor, - }, - &txnbuild.SetOptions{ - MasterWeight: txnbuild.NewThreshold(0), - LowThreshold: txnbuild.NewThreshold(20), - MediumThreshold: txnbuild.NewThreshold(20), - HighThreshold: txnbuild.NewThreshold(20), - SourceAccount: accountToSponsor, - }, - }, tx.Operations()) - - tx, err = tx.Sign(network.TestNetworkPassphrase, distributionAccount) - require.NoError(t, err) - - signedTx = *tx - }). - Return(&signedTx, nil). - Once(). - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), []string{distributionAccount.Address()}). - Return(&signedTx, nil). - Once() - - txe, networkPassphrase, err := s.SponsorAccountCreationTransaction(ctx, accountToSponsor, signers, assets) - require.NoError(t, err) - - assert.Equal(t, network.TestNetworkPassphrase, networkPassphrase) - assert.NotEmpty(t, txe) - genericTx, err := txnbuild.TransactionFromXDR(txe) - require.NoError(t, err) - tx, ok := genericTx.Transaction() - require.True(t, ok) - assert.Len(t, tx.Operations(), 9) - assert.Len(t, tx.Signatures(), 1) - - isFeeBumpEligible, err := models.Account.IsAccountFeeBumpEligible(ctx, accountToSponsor) - require.NoError(t, err) - assert.True(t, isFeeBumpEligible) - }) -} - -func TestAccountSponsorshipServiceWrapTransaction(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - mockMetricsService := metrics.NewMockMetricsService() - - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - - signatureClient := signing.SignatureClientMock{} - defer signatureClient.AssertExpectations(t) - - mockRPCService := RPCServiceMock{} - - ctx := context.Background() - s, err := NewAccountSponsorshipService(AccountSponsorshipServiceOptions{ - DistributionAccountSignatureClient: &signatureClient, - ChannelAccountSignatureClient: &signatureClient, - RPCService: &mockRPCService, - MaxSponsoredBaseReserves: 10, - BaseFee: txnbuild.MinBaseFee, - Models: models, - BlockedOperationsTypes: []xdr.OperationType{xdr.OperationTypeLiquidityPoolDeposit}, - }) - require.NoError(t, err) - - t.Run("account_not_eligible_for_transaction_fee_bump", func(t *testing.T) { - accountToSponsor := keypair.MustRandom() - - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() - defer mockMetricsService.AssertExpectations(t) - - tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: accountToSponsor.Address(), - Sequence: 123, - }, - IncrementSequenceNum: true, - Operations: []txnbuild.Operation{ - &txnbuild.Payment{ - Destination: keypair.MustRandom().Address(), - Amount: "10", - Asset: txnbuild.NativeAsset{}, - }, - }, - BaseFee: txnbuild.MinBaseFee, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(CreateAccountTxnTimeBounds + CreateAccountTxnTimeBoundsSafetyMargin)}, - }) - require.NoError(t, err) - - feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) - assert.ErrorIs(t, ErrAccountNotEligibleForBeingSponsored, err) - assert.Empty(t, feeBumpTxe) - assert.Empty(t, networkPassphrase) - }) - - t.Run("blocked_operations", func(t *testing.T) { - accountToSponsor := keypair.MustRandom() - - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() - defer mockMetricsService.AssertExpectations(t) - - err := models.Account.Insert(ctx, accountToSponsor.Address()) - require.NoError(t, err) - - tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: accountToSponsor.Address(), - Sequence: 123, - }, - IncrementSequenceNum: true, - Operations: []txnbuild.Operation{ - &txnbuild.Payment{ - Destination: keypair.MustRandom().Address(), - Amount: "10", - Asset: txnbuild.NativeAsset{}, - }, - &txnbuild.LiquidityPoolDeposit{ - LiquidityPoolID: txnbuild.LiquidityPoolId{123}, - MaxAmountA: "100", - MaxAmountB: "200", - MinPrice: xdr.Price{N: 1, D: 1}, - MaxPrice: xdr.Price{N: 1, D: 1}, - }, - }, - BaseFee: txnbuild.MinBaseFee, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(CreateAccountTxnTimeBounds + CreateAccountTxnTimeBoundsSafetyMargin)}, - }) - require.NoError(t, err) - - feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) - var opNotAllowedErr *OperationNotAllowedError - assert.ErrorAs(t, err, &opNotAllowedErr) - assert.Empty(t, feeBumpTxe) - assert.Empty(t, networkPassphrase) - }) - - t.Run("transaction_fee_exceeds_maximum_base_fee_for_sponsoring", func(t *testing.T) { - accountToSponsor := keypair.MustRandom() - - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() - defer mockMetricsService.AssertExpectations(t) - - err := models.Account.Insert(ctx, accountToSponsor.Address()) - require.NoError(t, err) - - tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: accountToSponsor.Address(), - Sequence: 123, - }, - IncrementSequenceNum: true, - Operations: []txnbuild.Operation{ - &txnbuild.Payment{ - Destination: keypair.MustRandom().Address(), - Amount: "10", - Asset: txnbuild.NativeAsset{}, - }, - }, - BaseFee: 100 * txnbuild.MinBaseFee, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(CreateAccountTxnTimeBounds + CreateAccountTxnTimeBoundsSafetyMargin)}, - }) - require.NoError(t, err) - - feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) - assert.ErrorIs(t, err, ErrFeeExceedsMaximumBaseFee) - assert.Empty(t, feeBumpTxe) - assert.Empty(t, networkPassphrase) - }) - - t.Run("transaction_should_have_at_least_one_signature", func(t *testing.T) { - accountToSponsor := keypair.MustRandom() - - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() - defer mockMetricsService.AssertExpectations(t) - - err := models.Account.Insert(ctx, accountToSponsor.Address()) - require.NoError(t, err) - - tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: accountToSponsor.Address(), - Sequence: 123, - }, - IncrementSequenceNum: true, - Operations: []txnbuild.Operation{ - &txnbuild.Payment{ - Destination: keypair.MustRandom().Address(), - Amount: "10", - Asset: txnbuild.NativeAsset{}, - }, - }, - BaseFee: txnbuild.MinBaseFee, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(CreateAccountTxnTimeBounds + CreateAccountTxnTimeBoundsSafetyMargin)}, - }) - require.NoError(t, err) - - feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) - assert.ErrorIs(t, err, ErrNoSignaturesProvided) - assert.Empty(t, feeBumpTxe) - assert.Empty(t, networkPassphrase) - }) - - t.Run("successfully_wraps_the_transaction_with_fee_bump", func(t *testing.T) { - distributionAccount := keypair.MustRandom() - accountToSponsor := keypair.MustRandom() - - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() - defer mockMetricsService.AssertExpectations(t) - - err := models.Account.Insert(ctx, accountToSponsor.Address()) - require.NoError(t, err) - - destinationAccount := keypair.MustRandom().Address() - tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: accountToSponsor.Address(), - Sequence: 123, - }, - IncrementSequenceNum: true, - Operations: []txnbuild.Operation{ - &txnbuild.Payment{ - Destination: destinationAccount, - Amount: "10", - Asset: txnbuild.NativeAsset{}, - }, - }, - BaseFee: txnbuild.MinBaseFee, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(CreateAccountTxnTimeBounds + CreateAccountTxnTimeBoundsSafetyMargin)}, - }) - require.NoError(t, err) - - tx, err = tx.Sign(network.TestNetworkPassphrase, accountToSponsor) - require.NoError(t, err) - - signedFeeBumpTx := txnbuild.FeeBumpTransaction{} - signatureClient. - On("GetAccountPublicKey", ctx). - Return(distributionAccount.Address(), nil). - Once(). - On("NetworkPassphrase"). - Return(network.TestNetworkPassphrase). - Once(). - On("SignStellarFeeBumpTransaction", ctx, mock.AnythingOfType("*txnbuild.FeeBumpTransaction")). - Run(func(args mock.Arguments) { - feeBumpTx, ok := args.Get(1).(*txnbuild.FeeBumpTransaction) - require.True(t, ok) - - assert.Equal(t, distributionAccount.Address(), feeBumpTx.FeeAccount()) - assert.Equal(t, []txnbuild.Operation{ - &txnbuild.Payment{ - Destination: destinationAccount, - Amount: "10", - Asset: txnbuild.NativeAsset{}, - }, - }, feeBumpTx.InnerTransaction().Operations()) - - feeBumpTx, err = feeBumpTx.Sign(network.TestNetworkPassphrase, distributionAccount) - require.NoError(t, err) - - signedFeeBumpTx = *feeBumpTx - }). - Return(&signedFeeBumpTx, nil) - - feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) - require.NoError(t, err) - - assert.Equal(t, network.TestNetworkPassphrase, networkPassphrase) - assert.NotEmpty(t, feeBumpTxe) - genericTx, err := txnbuild.TransactionFromXDR(feeBumpTxe) - require.NoError(t, err) - feeBumpTx, ok := genericTx.FeeBump() - require.True(t, ok) - assert.Equal(t, distributionAccount.Address(), feeBumpTx.FeeAccount()) - assert.Len(t, feeBumpTx.InnerTransaction().Operations(), 1) - assert.Len(t, feeBumpTx.Signatures(), 1) - }) -} diff --git a/internal/services/fee_bump_service.go b/internal/services/fee_bump_service.go new file mode 100644 index 00000000..9181f71b --- /dev/null +++ b/internal/services/fee_bump_service.go @@ -0,0 +1,139 @@ +package services + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/stellar/go/support/log" + "github.com/stellar/go/txnbuild" + "github.com/stellar/go/xdr" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/signing" +) + +var ( + ErrAccountNotFound = errors.New("account not found") + ErrAccountNotEligibleForBeingSponsored = errors.New("account not eligible for being sponsored") + ErrFeeExceedsMaximumBaseFee = errors.New("fee exceeds maximum base fee to sponsor") + ErrNoSignaturesProvided = errors.New("should have at least one signature") +) + +type OperationNotAllowedError struct { + OperationType xdr.OperationType +} + +func (e OperationNotAllowedError) Error() string { + return fmt.Sprintf("operation %s not allowed", e.OperationType.String()) +} + +type FeeBumpService interface { + WrapTransaction(ctx context.Context, tx *txnbuild.Transaction) (string, string, error) +} + +type feeBumpService struct { + DistributionAccountSignatureClient signing.SignatureClient + BaseFee int64 + Models *data.Models + BlockedOperationsTypes []xdr.OperationType +} + +var _ FeeBumpService = (*feeBumpService)(nil) + +// WrapTransaction wraps a stellar transaction with a fee bump transaction with the configured distribution account as the fee account. +func (s *feeBumpService) WrapTransaction(ctx context.Context, tx *txnbuild.Transaction) (string, string, error) { + isFeeBumpEligible, err := s.Models.Account.IsAccountFeeBumpEligible(ctx, tx.SourceAccount().AccountID) + if err != nil { + return "", "", fmt.Errorf("checking if transaction source account is eligible for being fee-bumped: %w", err) + } + if !isFeeBumpEligible { + return "", "", ErrAccountNotEligibleForBeingSponsored + } + + for _, op := range tx.Operations() { + operationXDR, innerErr := op.BuildXDR() + if innerErr != nil { + return "", "", fmt.Errorf("retrieving xdr for operation: %w", innerErr) + } + + if slices.Contains(s.BlockedOperationsTypes, operationXDR.Body.Type) { + log.Ctx(ctx).Warnf("blocked operation type: %s", operationXDR.Body.Type.String()) + return "", "", &OperationNotAllowedError{OperationType: operationXDR.Body.Type} + } + } + + if tx.BaseFee() > s.BaseFee { + return "", "", ErrFeeExceedsMaximumBaseFee + } + + sigs := tx.Signatures() + if len(sigs) == 0 { + return "", "", ErrNoSignaturesProvided + } + + distributionAccountPublicKey, err := s.DistributionAccountSignatureClient.GetAccountPublicKey(ctx) + if err != nil { + return "", "", fmt.Errorf("getting distribution account public key: %w", err) + } + + feeBumpTx, err := txnbuild.NewFeeBumpTransaction( + txnbuild.FeeBumpTransactionParams{ + Inner: tx, + FeeAccount: distributionAccountPublicKey, + BaseFee: s.BaseFee, + }, + ) + if err != nil { + return "", "", fmt.Errorf("creating fee-bump transaction: %w", err) + } + + signedFeeBumpTx, err := s.DistributionAccountSignatureClient.SignStellarFeeBumpTransaction(ctx, feeBumpTx) + if err != nil { + return "", "", fmt.Errorf("signing fee-bump transaction: %w", err) + } + + feeBumpTxe, err := signedFeeBumpTx.Base64() + if err != nil { + return "", "", fmt.Errorf("getting transaction envelope: %w", err) + } + + return feeBumpTxe, s.DistributionAccountSignatureClient.NetworkPassphrase(), nil +} + +type FeeBumpServiceOptions struct { + DistributionAccountSignatureClient signing.SignatureClient + BaseFee int64 + Models *data.Models + BlockedOperationsTypes []xdr.OperationType +} + +func (o *FeeBumpServiceOptions) Validate() error { + if o.DistributionAccountSignatureClient == nil { + return fmt.Errorf("distribution account signature client cannot be nil") + } + + if o.BaseFee < int64(txnbuild.MinBaseFee) { + return fmt.Errorf("base fee is lower than the minimum network fee") + } + + if o.Models == nil { + return fmt.Errorf("models cannot be nil") + } + + return nil +} + +func NewFeeBumpService(opts FeeBumpServiceOptions) (*feeBumpService, error) { + if err := opts.Validate(); err != nil { + return nil, fmt.Errorf("validating fee bump service options: %w", err) + } + + return &feeBumpService{ + DistributionAccountSignatureClient: opts.DistributionAccountSignatureClient, + BaseFee: opts.BaseFee, + Models: opts.Models, + BlockedOperationsTypes: opts.BlockedOperationsTypes, + }, nil +} diff --git a/internal/services/fee_bump_service_test.go b/internal/services/fee_bump_service_test.go new file mode 100644 index 00000000..bb3fd82a --- /dev/null +++ b/internal/services/fee_bump_service_test.go @@ -0,0 +1,270 @@ +package services + +import ( + "context" + "testing" + + "github.com/stellar/go/keypair" + "github.com/stellar/go/network" + "github.com/stellar/go/txnbuild" + "github.com/stellar/go/xdr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/db" + "github.com/stellar/wallet-backend/internal/db/dbtest" + "github.com/stellar/wallet-backend/internal/metrics" + "github.com/stellar/wallet-backend/internal/signing" +) + +func TestFeeBumpServiceWrapTransaction(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + + models, err := data.NewModels(dbConnectionPool, mockMetricsService) + require.NoError(t, err) + + signatureClient := signing.SignatureClientMock{} + defer signatureClient.AssertExpectations(t) + + ctx := context.Background() + s, err := NewFeeBumpService(FeeBumpServiceOptions{ + DistributionAccountSignatureClient: &signatureClient, + BaseFee: txnbuild.MinBaseFee, + Models: models, + BlockedOperationsTypes: []xdr.OperationType{xdr.OperationTypeLiquidityPoolDeposit}, + }) + require.NoError(t, err) + + t.Run("account_not_eligible_for_transaction_fee_bump", func(t *testing.T) { + accountToSponsor := keypair.MustRandom() + + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() + defer mockMetricsService.AssertExpectations(t) + + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: accountToSponsor.Address(), + Sequence: 123, + }, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) + assert.ErrorIs(t, ErrAccountNotEligibleForBeingSponsored, err) + assert.Empty(t, feeBumpTxe) + assert.Empty(t, networkPassphrase) + }) + + t.Run("blocked_operations", func(t *testing.T) { + accountToSponsor := keypair.MustRandom() + + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() + defer mockMetricsService.AssertExpectations(t) + + err := models.Account.Insert(ctx, accountToSponsor.Address()) + require.NoError(t, err) + + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: accountToSponsor.Address(), + Sequence: 123, + }, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + }, + &txnbuild.LiquidityPoolDeposit{ + LiquidityPoolID: txnbuild.LiquidityPoolId{123}, + MaxAmountA: "100", + MaxAmountB: "200", + MinPrice: xdr.Price{N: 1, D: 1}, + MaxPrice: xdr.Price{N: 1, D: 1}, + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) + var opNotAllowedErr *OperationNotAllowedError + assert.ErrorAs(t, err, &opNotAllowedErr) + assert.Empty(t, feeBumpTxe) + assert.Empty(t, networkPassphrase) + }) + + t.Run("transaction_fee_exceeds_maximum_base_fee_for_sponsoring", func(t *testing.T) { + accountToSponsor := keypair.MustRandom() + + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() + defer mockMetricsService.AssertExpectations(t) + + err := models.Account.Insert(ctx, accountToSponsor.Address()) + require.NoError(t, err) + + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: accountToSponsor.Address(), + Sequence: 123, + }, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + }, + }, + BaseFee: 100 * txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) + assert.ErrorIs(t, err, ErrFeeExceedsMaximumBaseFee) + assert.Empty(t, feeBumpTxe) + assert.Empty(t, networkPassphrase) + }) + + t.Run("transaction_should_have_at_least_one_signature", func(t *testing.T) { + accountToSponsor := keypair.MustRandom() + + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() + defer mockMetricsService.AssertExpectations(t) + + err := models.Account.Insert(ctx, accountToSponsor.Address()) + require.NoError(t, err) + + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: accountToSponsor.Address(), + Sequence: 123, + }, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) + assert.ErrorIs(t, err, ErrNoSignaturesProvided) + assert.Empty(t, feeBumpTxe) + assert.Empty(t, networkPassphrase) + }) + + t.Run("successfully_wraps_the_transaction_with_fee_bump", func(t *testing.T) { + distributionAccount := keypair.MustRandom() + accountToSponsor := keypair.MustRandom() + + mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "accounts", mock.AnythingOfType("float64")).Once() + mockMetricsService.On("IncDBQuery", "SELECT", "accounts").Once() + defer mockMetricsService.AssertExpectations(t) + + err := models.Account.Insert(ctx, accountToSponsor.Address()) + require.NoError(t, err) + + destinationAccount := keypair.MustRandom().Address() + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: accountToSponsor.Address(), + Sequence: 123, + }, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: destinationAccount, + Amount: "10", + Asset: txnbuild.NativeAsset{}, + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)}, + }) + require.NoError(t, err) + + tx, err = tx.Sign(network.TestNetworkPassphrase, accountToSponsor) + require.NoError(t, err) + + signedFeeBumpTx := txnbuild.FeeBumpTransaction{} + signatureClient. + On("GetAccountPublicKey", ctx). + Return(distributionAccount.Address(), nil). + Once(). + On("NetworkPassphrase"). + Return(network.TestNetworkPassphrase). + Once(). + On("SignStellarFeeBumpTransaction", ctx, mock.AnythingOfType("*txnbuild.FeeBumpTransaction")). + Run(func(args mock.Arguments) { + feeBumpTx, ok := args.Get(1).(*txnbuild.FeeBumpTransaction) + require.True(t, ok) + + assert.Equal(t, distributionAccount.Address(), feeBumpTx.FeeAccount()) + assert.Equal(t, []txnbuild.Operation{ + &txnbuild.Payment{ + Destination: destinationAccount, + Amount: "10", + Asset: txnbuild.NativeAsset{}, + }, + }, feeBumpTx.InnerTransaction().Operations()) + + feeBumpTx, err = feeBumpTx.Sign(network.TestNetworkPassphrase, distributionAccount) + require.NoError(t, err) + + signedFeeBumpTx = *feeBumpTx + }). + Return(&signedFeeBumpTx, nil) + + feeBumpTxe, networkPassphrase, err := s.WrapTransaction(ctx, tx) + require.NoError(t, err) + + assert.Equal(t, network.TestNetworkPassphrase, networkPassphrase) + assert.NotEmpty(t, feeBumpTxe) + genericTx, err := txnbuild.TransactionFromXDR(feeBumpTxe) + require.NoError(t, err) + feeBumpTx, ok := genericTx.FeeBump() + require.True(t, ok) + assert.Equal(t, distributionAccount.Address(), feeBumpTx.FeeAccount()) + assert.Len(t, feeBumpTx.InnerTransaction().Operations(), 1) + assert.Len(t, feeBumpTx.Signatures(), 1) + }) +} diff --git a/internal/services/mocks.go b/internal/services/mocks.go index b8c89a53..7abfc5cc 100644 --- a/internal/services/mocks.go +++ b/internal/services/mocks.go @@ -84,18 +84,13 @@ func NewRPCServiceMock(t interface { return mock } -type AccountSponsorshipServiceMock struct { +type FeeBumpServiceMock struct { mock.Mock } -var _ AccountSponsorshipService = (*AccountSponsorshipServiceMock)(nil) +var _ FeeBumpService = (*FeeBumpServiceMock)(nil) -func (s *AccountSponsorshipServiceMock) SponsorAccountCreationTransaction(ctx context.Context, accountToSponsor string, signers []entities.Signer, assets []entities.Asset) (string, string, error) { - args := s.Called(ctx, accountToSponsor, signers, assets) - return args.String(0), args.String(1), args.Error(2) -} - -func (s *AccountSponsorshipServiceMock) WrapTransaction(ctx context.Context, tx *txnbuild.Transaction) (string, string, error) { +func (s *FeeBumpServiceMock) WrapTransaction(ctx context.Context, tx *txnbuild.Transaction) (string, string, error) { args := s.Called(ctx, tx) return args.String(0), args.String(1), args.Error(2) } diff --git a/openapi/main.yaml b/openapi/main.yaml index 99b91fe1..e85f2c3c 100644 --- a/openapi/main.yaml +++ b/openapi/main.yaml @@ -277,107 +277,6 @@ paths: example: status: 500 error: An error occurred while processing this request. - /tx/create-sponsored-account: - post: - tags: - - Transactions - summary: Sponsor (pay the base reserve for) the creation of an account on the network - description: Create an account on the stellar network whose base reserve is paid for by the wallet backend's distribution account. Make sure that the total added weight of the partial signers is less than the total added weight of the full signers. - operationId: createSponsoredAccount - requestBody: - description: "Request body for configuring address signers with weights and types." - required: true - content: - application/json: - schema: - type: object - properties: - address: - type: string - description: "The address of the account to be created." - example: "GBQ4HFNATBR4NXIDHDNSLAXZAWUFQ7DLMS5DUFWOBXNXFBOZFE3NQ243" - signers: - type: array - description: "List of signers with their respective addresses, types, and weights." - items: - type: object - properties: - address: - type: string - description: "The address of the signer." - example: "GCXCF2T2FUY6UFYBLXNLJKGA4K6Y23KQEWTGV2X2YRBYOTP2RVYKY2DU" - type: - type: string - description: "Type of the signer (full, partial)." - example: "full" - weight: - type: integer - description: "Weight of the signer." - example: 20 - required: - - address - - signers - example: - address: "GBQ4HFNATBR4NXIDHDNSLAXZAWUFQ7DLMS5DUFWOBXNXFBOZFE3NQ243" - signers: - - address: "GCXCF2T2FUY6UFYBLXNLJKGA4K6Y23KQEWTGV2X2YRBYOTP2RVYKY2DU" - type: "full" - weight: 20 - - address: "GCAMJ67W77XEHUCPRTN3WGD6QG7XMJVWTU5KJMD4VDCI6FRUIO3X7B4T" - type: "partial" - weight: 10 - - address: "GC5E47IDYJAKVHXRLQM7Z2ABBARG6BAT6CYPEKTAZERS4H24KFUEKCA7" - type: "partial" - weight: 10 - responses: - '200': - description: "Successful response containing network passphrase and the base64 transaction xdr string." - content: - application/json: - schema: - type: object - properties: - networkPassphrase: - type: string - description: "The passphrase of the network used for the transaction." - transaction: - type: string - description: "The base64-encoded transaction envelope." - example: - networkPassphrase: "Test SDF Network ; September 2015" - transaction: "AAAAAgAAAADnY6MF7SMT2a2d6pt3i37Xx9IhaHqJ2lCcibNOISzhOwABX5AAFs2YAAAADAAAAAEAAAAAAAAAAAAAAABmYGswAAAAAAAAAAkAAAABAAAAAOdjowXtIxPZrZ3qm3eLftfH0iFoeonaUJyJs04hLOE7AAAAEAAAAACOC8v8STBDIULGM3FlZ6O7N3vHpNns7bcwRDFlIxTMiwAAAAEAAAAA52OjBe0jE9mtneqbd4t+18fSIWh6idpQnImzTiEs4TsAAAAAAAAAAI4Ly/xJMEMhQsYzcWVno7s3e8ek2ezttzBEMWUjFMyLAAAAAAAAAAAAAAABAAAAAI4Ly/xJMEMhQsYzcWVno7s3e8ek2ezttzBEMWUjFMyLAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAApIt66UEG1IDSjJPfTQNheHz06pj5cjZjsCB3mMCRHhQAAABQAAAABAAAAAI4Ly/xJMEMhQsYzcWVno7s3e8ek2ezttzBEMWUjFMyLAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAD7c8Mp4XnAkJ0wzCcXHw7lxQNMgnG3c8auqJWB75WJywAAAAoAAAABAAAAAI4Ly/xJMEMhQsYzcWVno7s3e8ek2ezttzBEMWUjFMyLAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAACIKVZwHT74BHJ6f1PVDd5HWMzEuPW1P5bw3dDipS9RzgAAAAoAAAABAAAAAI4Ly/xJMEMhQsYzcWVno7s3e8ek2ezttzBEMWUjFMyLAAAABgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layf/////////8AAAABAAAAAI4Ly/xJMEMhQsYzcWVno7s3e8ek2ezttzBEMWUjFMyLAAAABgAAAAFBUlNUAAAAAH8wYjTJienWf2nf2TEZi2APPWzmtkwiQHAftisIgyuHf/////////8AAAABAAAAAI4Ly/xJMEMhQsYzcWVno7s3e8ek2ezttzBEMWUjFMyLAAAAEQAAAAEAAAAAjgvL/EkwQyFCxjNxZWejuzd7x6TZ7O23MEQxZSMUzIsAAAAFAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAFAAAAAEAAAAUAAAAAQAAABQAAAAAAAAAAAAAAAAAAAABISzhOwAAAEA2UXoQIAlm8nq2K8fniAbMdsBbWRdx6ay8p5dAJUGfqFGJ7rWf/pdZVQ8D3gcKXlBwTVhHDVCSBfj0UsTaHREO" - '400': - description: Bad Request - content: - application/json: - schema: - type: object - properties: - status: - type: string - error: - type: string - description: Details about the error - example: - status: 400 - error: Validation Error. - '500': - description: Internal Server Error - content: - application/json: - schema: - type: object - properties: - status: - type: string - error: - type: string - example: - status: 500 - error: An error occurred while processing this request. - example: - status: 500 - error: An error occurred while processing this request. /tx/create-fee-bump: post: tags: From 39482f8fbafd7d553d2ab5605f04d4f12d72f20a Mon Sep 17 00:00:00 2001 From: Jake Urban <10968980+JakeUrban@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:55:59 -0700 Subject: [PATCH 08/16] Remove audience claim from JWT authentication (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update docker-compose.yaml * Remove audience claim from JWT authentication Removes the `audience` claim from JWTs. This claim did not meaningfully improve security for wallet backend instances, and while it did ensure JWTs could not be used for instances of the wallet backend running on different hosts, this is a defensive measure for the client's sake, not the server's. However, this does simplify client implementations and reduce the chances of misconfiguration. For example, when I run the wallet backend using docker-compose, the URL I make requests with have `localhost` hostnames, but the hostname of the server is actually `api` as its defined within the compose file. This possibility, for the domain clients must send requests to differ from the hostname configured for the instance, forces client SDKs to have one parameter for the URL to make requests to and a separate parameter for the `audience`. ## Changes Made: ### JWT Interface Updates: - Removed `audience` parameter from `JWTTokenParser.ParseJWT()` and `JWTTokenGenerator.GenerateJWT()` interfaces - Updated `HTTPRequestVerifier.VerifyHTTPRequest()` to no longer require audience parameter ### Core Logic Updates: - Removed audience validation logic from `claims.Validate()` function - Removed unused `slices` import from claims.go - Removed `Audience` field generation from JWT tokens - Removed hostname parsing logic that was only used for audience validation ### Middleware Updates: - Updated `AuthenticationMiddleware()` to no longer require `serverHostname` parameter - Removed `ServerHostname` field from `handlerDeps` struct since it was only used for audience validation - Removed unused `net/url` import from serve.go ### Test Cleanup: - Removed audience-only test cases: `🔴invalid_audience` and `🔴invalid_hostname` - Updated all remaining tests to remove audience parameters and references - Removed unused variables like `validAudience`, `invalidAudience`, `validHostname`, `invalidHostname` ### Documentation Update: - Removed `aud` claim documentation from README.md ## What's Still Preserved: - **`SERVER_BASE_URL` configuration** - Still used by PaymentService for building pagination URLs - **All other JWT validations** - Subject, method/path, body hash, expiration, etc. remain intact - **All other security mechanisms** - JWT signature validation, token expiration, etc. still work ## Verification: - ✅ All tests pass in auth and middleware packages - ✅ Entire codebase compiles successfully - ✅ No remaining audience/aud references found - ✅ Minimal impact achieved - only audience-specific functionality removed The changes are surgical and focused solely on removing audience claim validation while preserving all other authentication mechanisms and functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix formatting issues - remove extra blank lines Applied golangci-lint --fix to resolve CI formatting failures * move back to stable rpc image --------- Co-authored-by: Claude --- README.md | 1 - docker-compose.yaml | 4 +- internal/serve/middleware/middleware.go | 3 +- internal/serve/middleware/middleware_test.go | 13 +----- internal/serve/serve.go | 10 +---- pkg/wbclient/auth/claims.go | 7 +-- pkg/wbclient/auth/claims_test.go | 35 +-------------- pkg/wbclient/auth/jwt_http_signer_verifier.go | 19 ++------ .../auth/jwt_http_signer_verifier_test.go | 2 +- pkg/wbclient/auth/jwt_manager.go | 15 +++---- pkg/wbclient/auth/jwt_manager_test.go | 45 +++++-------------- 11 files changed, 29 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 0d022c3a..702936ad 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,6 @@ The JWT payload field should contain the following fields: - (default) `exp` – The expiration time on and after which the JWT must not be accepted for processing, in seconds since Epoch. (Must be less than `iat`+15sec.) - (default) `iat` - The time at which the JWT was issued, in seconds since Epoch. -- (default) `aud` – The audience for the JWT. This is the server's hostname. - (default) `sub` – The subject of the JWT, which is the public key of the Stellar account that is being authenticated. - (custom) `methodAndPath` – The HTTP method and path of the request (e.g., `GET /transactions/b9d0b2292c4e09e8eb22d036171491e87b8d2086bf8b265874c8d182cb9c9020`). - (custom) `bodyHash`, a hex-encoded SHA-256 hash of the raw HTTP request body, present even when the body is empty: diff --git a/docker-compose.yaml b/docker-compose.yaml index 4236d21f..983cab97 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -49,7 +49,7 @@ services: db: condition: service_healthy stellar-rpc: - condition: service_started + condition: service_healthy ingest: condition: service_healthy ports: @@ -108,7 +108,7 @@ services: db: condition: service_healthy stellar-rpc: - condition: service_started + condition: service_healthy entrypoint: "" command: - sh diff --git a/internal/serve/middleware/middleware.go b/internal/serve/middleware/middleware.go index 27630ace..277e80f6 100644 --- a/internal/serve/middleware/middleware.go +++ b/internal/serve/middleware/middleware.go @@ -16,7 +16,6 @@ import ( const MaxBodySize int64 = 10_240 // 10kb func AuthenticationMiddleware( - serverHostname string, requestAuthVerifier auth.HTTPRequestVerifier, appTracker apptracker.AppTracker, metricsService metrics.MetricsService, @@ -25,7 +24,7 @@ func AuthenticationMiddleware( return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() - err := requestAuthVerifier.VerifyHTTPRequest(req, serverHostname) + err := requestAuthVerifier.VerifyHTTPRequest(req) if err == nil { next.ServeHTTP(rw, req) return diff --git a/internal/serve/middleware/middleware_test.go b/internal/serve/middleware/middleware_test.go index 4a9530bb..e6ed0bc0 100644 --- a/internal/serve/middleware/middleware_test.go +++ b/internal/serve/middleware/middleware_test.go @@ -103,17 +103,6 @@ func TestAuthenticationMiddleware(t *testing.T) { expectedStatus: http.StatusUnauthorized, expectedMessage: `{"error":"Not authorized."}`, }, - { - name: "🔴invalid_hostname", - setupRequest: func() *http.Request { - req := httptest.NewRequest("GET", "https://invalid.test.com/authenticated", nil) - err := validSigner.SignHTTPRequest(req, time.Second*5) - require.NoError(t, err) - return req - }, - expectedStatus: http.StatusUnauthorized, - expectedMessage: `{"error":"Not authorized."}`, - }, { name: "🔴body_too_big", setupRequest: func() *http.Request { @@ -159,7 +148,7 @@ func TestAuthenticationMiddleware(t *testing.T) { if tc.setupMocks != nil { tc.setupMocks(t, mAppTracker, mMetricsService) } - authMiddleware := AuthenticationMiddleware("test.com", reqJWTVerifier, mAppTracker, mMetricsService) + authMiddleware := AuthenticationMiddleware(reqJWTVerifier, mAppTracker, mMetricsService) r := chi.NewRouter() r.Use(authMiddleware) diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 6738b20f..30661aea 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "net/url" "time" "github.com/go-chi/chi" @@ -68,7 +67,6 @@ type handlerDeps struct { Models *data.Models Port int DatabaseURL string - ServerHostname string RequestAuthVerifier auth.HTTPRequestVerifier SupportedAssets []entities.Asset NetworkPassphrase string @@ -184,14 +182,8 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { } go ensureChannelAccounts(ctx, channelAccountService, int64(cfg.NumberOfChannelAccounts)) - serverHostname, err := url.ParseRequestURI(cfg.ServerBaseURL) - if err != nil { - return handlerDeps{}, fmt.Errorf("parsing hostname: %w", err) - } - return handlerDeps{ Models: models, - ServerHostname: serverHostname.Hostname(), RequestAuthVerifier: requestAuthVerifier, SupportedAssets: cfg.SupportedAssets, AccountService: accountService, @@ -233,7 +225,7 @@ func handler(deps handlerDeps) http.Handler { // Authenticated routes mux.Group(func(r chi.Router) { - r.Use(middleware.AuthenticationMiddleware(deps.ServerHostname, deps.RequestAuthVerifier, deps.AppTracker, deps.MetricsService)) + r.Use(middleware.AuthenticationMiddleware(deps.RequestAuthVerifier, deps.AppTracker, deps.MetricsService)) r.Route("/graphql", func(r chi.Router) { r.Use(middleware.DataloaderMiddleware(deps.Models)) diff --git a/pkg/wbclient/auth/claims.go b/pkg/wbclient/auth/claims.go index 0160bf20..2769ec34 100644 --- a/pkg/wbclient/auth/claims.go +++ b/pkg/wbclient/auth/claims.go @@ -3,7 +3,6 @@ package auth import ( "errors" "fmt" - "slices" "strings" "time" @@ -17,7 +16,7 @@ type customClaims struct { jwtgo.RegisteredClaims } -func (c *customClaims) Validate(audience, methodAndPath string, body []byte, maxTimeout time.Duration) error { +func (c *customClaims) Validate(methodAndPath string, body []byte, maxTimeout time.Duration) error { if maxTimeout == 0 { maxTimeout = DefaultMaxTimeout } @@ -40,10 +39,6 @@ func (c *customClaims) Validate(audience, methodAndPath string, body []byte, max return errors.New("the JWT subject is not a valid Stellar public key") } - if audience != "" && !slices.Contains(c.Audience, audience) { - return fmt.Errorf("the JWT audience %s does not match the expected audience [%s]", c.Audience, audience) - } - if c.MethodAndPath != strings.TrimSpace(methodAndPath) { return fmt.Errorf("the JWT method-and-path %q does not match the expected method-and-path %q", c.MethodAndPath, methodAndPath) } diff --git a/pkg/wbclient/auth/claims_test.go b/pkg/wbclient/auth/claims_test.go index 09d86f8a..c95b7f6f 100644 --- a/pkg/wbclient/auth/claims_test.go +++ b/pkg/wbclient/auth/claims_test.go @@ -13,8 +13,6 @@ func Test_CustomClaims_Validate(t *testing.T) { invalidBody := []byte(`{"x": "y"}`) validMethodAndPath := "GET /valid/uri" invalidMethodAndPath := "POST /invalid/uri" - validAudience := "test.com" - invalidAudience := "invalid.test.com" validSubject := testKP1.Address() invalidSubject := "invalid-public-key" validIssuedAt := time.Now() @@ -24,7 +22,6 @@ func Test_CustomClaims_Validate(t *testing.T) { testCases := []struct { name string claims *customClaims - audience string methodAndPath string body []byte maxTimeout time.Duration @@ -39,10 +36,8 @@ func Test_CustomClaims_Validate(t *testing.T) { Subject: validSubject, IssuedAt: jwtgo.NewNumericDate(validIssuedAt), ExpiresAt: jwtgo.NewNumericDate(tooLongExpiresAt), - Audience: jwtgo.ClaimStrings{validAudience}, }, }, - audience: validAudience, methodAndPath: validMethodAndPath, body: validBody, maxTimeout: 15 * time.Second, @@ -57,10 +52,8 @@ func Test_CustomClaims_Validate(t *testing.T) { Subject: validSubject, IssuedAt: jwtgo.NewNumericDate(validExpiresAt.Add(-DefaultMaxTimeout - time.Second)), ExpiresAt: jwtgo.NewNumericDate(validExpiresAt), - Audience: jwtgo.ClaimStrings{validAudience}, }, }, - audience: validAudience, methodAndPath: validMethodAndPath, body: validBody, maxTimeout: 15 * time.Second, @@ -75,33 +68,13 @@ func Test_CustomClaims_Validate(t *testing.T) { Subject: invalidSubject, IssuedAt: jwtgo.NewNumericDate(validIssuedAt), ExpiresAt: jwtgo.NewNumericDate(validExpiresAt), - Audience: jwtgo.ClaimStrings{validAudience}, }, }, - audience: validAudience, methodAndPath: validMethodAndPath, body: validBody, maxTimeout: 15 * time.Second, wantErrContains: "the JWT subject is not a valid Stellar public key", }, - { - name: "🔴invalid_audience", - claims: &customClaims{ - BodyHash: HashBody(validBody), - MethodAndPath: validMethodAndPath, - RegisteredClaims: jwtgo.RegisteredClaims{ - Subject: validSubject, - IssuedAt: jwtgo.NewNumericDate(validIssuedAt), - ExpiresAt: jwtgo.NewNumericDate(validExpiresAt), - Audience: jwtgo.ClaimStrings{invalidAudience}, - }, - }, - audience: validAudience, - methodAndPath: validMethodAndPath, - body: validBody, - maxTimeout: 15 * time.Second, - wantErrContains: "the JWT audience [invalid.test.com] does not match the expected audience [test.com]", - }, { name: "🔴invalid_method_and_path", claims: &customClaims{ @@ -111,10 +84,8 @@ func Test_CustomClaims_Validate(t *testing.T) { Subject: validSubject, IssuedAt: jwtgo.NewNumericDate(validIssuedAt), ExpiresAt: jwtgo.NewNumericDate(validExpiresAt), - Audience: jwtgo.ClaimStrings{validAudience}, }, }, - audience: validAudience, methodAndPath: validMethodAndPath, body: validBody, maxTimeout: 15 * time.Second, @@ -129,10 +100,8 @@ func Test_CustomClaims_Validate(t *testing.T) { Subject: validSubject, IssuedAt: jwtgo.NewNumericDate(validIssuedAt), ExpiresAt: jwtgo.NewNumericDate(validExpiresAt), - Audience: jwtgo.ClaimStrings{validAudience}, }, }, - audience: validAudience, methodAndPath: validMethodAndPath, body: validBody, maxTimeout: 15 * time.Second, @@ -147,10 +116,8 @@ func Test_CustomClaims_Validate(t *testing.T) { Subject: validSubject, IssuedAt: jwtgo.NewNumericDate(validIssuedAt), ExpiresAt: jwtgo.NewNumericDate(validExpiresAt), - Audience: jwtgo.ClaimStrings{validAudience}, }, }, - audience: validAudience, methodAndPath: validMethodAndPath, body: validBody, maxTimeout: 15 * time.Second, @@ -159,7 +126,7 @@ func Test_CustomClaims_Validate(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := tc.claims.Validate(tc.audience, tc.methodAndPath, tc.body, tc.maxTimeout) + err := tc.claims.Validate(tc.methodAndPath, tc.body, tc.maxTimeout) if tc.wantErrContains != "" { assert.ErrorContains(t, err, tc.wantErrContains) } else { diff --git a/pkg/wbclient/auth/jwt_http_signer_verifier.go b/pkg/wbclient/auth/jwt_http_signer_verifier.go index 69d60472..2c03efcb 100644 --- a/pkg/wbclient/auth/jwt_http_signer_verifier.go +++ b/pkg/wbclient/auth/jwt_http_signer_verifier.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "time" ) @@ -22,7 +21,7 @@ type HTTPRequestSigner interface { // HTTPRequestVerifier is responsible for verifying HTTP requests using JWTs. type HTTPRequestVerifier interface { - VerifyHTTPRequest(req *http.Request, audience string) error + VerifyHTTPRequest(req *http.Request) error } // JWTHTTPSignerVerifier implements both signing and verifying of HTTP requests. @@ -50,21 +49,11 @@ func (s *JWTHTTPSignerVerifier) SignHTTPRequest(req *http.Request, timeout time. req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) }() - // Parse the hostname - scheme := req.URL.Scheme - if scheme == "" { - scheme = "http" - } - u, err := url.ParseRequestURI(scheme + "://" + req.Host) - if err != nil { - return fmt.Errorf("parsing hostname: %w", err) - } - // Generate the method and path methodAndPath := fmt.Sprintf("%s %s", req.Method, req.URL.Path) // Generate the token and sign the request - jwtToken, err := s.generator.GenerateJWT(u.Hostname(), methodAndPath, bodyBytes, time.Now().Add(timeout)) + jwtToken, err := s.generator.GenerateJWT(methodAndPath, bodyBytes, time.Now().Add(timeout)) if err != nil { return fmt.Errorf("generating JWT token: %w", err) } @@ -74,7 +63,7 @@ func (s *JWTHTTPSignerVerifier) SignHTTPRequest(req *http.Request, timeout time. } // VerifyHTTPRequest verifies the JWT in an HTTP request. -func (s *JWTHTTPSignerVerifier) VerifyHTTPRequest(req *http.Request, audience string) error { +func (s *JWTHTTPSignerVerifier) VerifyHTTPRequest(req *http.Request) error { authHeader := req.Header.Get("Authorization") if authHeader == "" { return fmt.Errorf("missing Authorization header: %w", ErrUnauthorized) @@ -103,7 +92,7 @@ func (s *JWTHTTPSignerVerifier) VerifyHTTPRequest(req *http.Request, audience st // Parse the JWT tokenString := authHeader[len("Bearer "):] // Remove "Bearer " prefix - _, _, err := s.parser.ParseJWT(tokenString, audience, methodAndPath, bodyBytes) + _, _, err := s.parser.ParseJWT(tokenString, methodAndPath, bodyBytes) if err != nil { return fmt.Errorf("verifying JWT: %w: %w", err, ErrUnauthorized) } diff --git a/pkg/wbclient/auth/jwt_http_signer_verifier_test.go b/pkg/wbclient/auth/jwt_http_signer_verifier_test.go index ad5abc9d..6de88246 100644 --- a/pkg/wbclient/auth/jwt_http_signer_verifier_test.go +++ b/pkg/wbclient/auth/jwt_http_signer_verifier_test.go @@ -123,7 +123,7 @@ func Test_JWTHTTPSignerVerifier_Integration(t *testing.T) { w := httptest.NewRecorder() ts := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := reqJWTVerifier.VerifyHTTPRequest(r, "example.com") + err := reqJWTVerifier.VerifyHTTPRequest(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return diff --git a/pkg/wbclient/auth/jwt_manager.go b/pkg/wbclient/auth/jwt_manager.go index b176060e..c6900c64 100644 --- a/pkg/wbclient/auth/jwt_manager.go +++ b/pkg/wbclient/auth/jwt_manager.go @@ -22,24 +22,24 @@ type JWTManager struct { type JWTTokenParser interface { // ParseJWT parses a JWT token and returns it with the claims. - ParseJWT(tokenString, audience, methodAndPath string, body []byte) (*jwtgo.Token, *customClaims, error) + ParseJWT(tokenString, methodAndPath string, body []byte) (*jwtgo.Token, *customClaims, error) } type JWTTokenGenerator interface { // GenerateJWT generates a JWT token with the given body and expiration time. - GenerateJWT(audience, methodAndPath string, body []byte, expiresAt time.Time) (string, error) + GenerateJWT(methodAndPath string, body []byte, expiresAt time.Time) (string, error) } // ParseJWT parses a JWT token and returns it with the claims. It also checks if the token expiration is within [now, // now+MaxTimeout], and if the claims' hashed_body matches the requestBody's hash. -func (m *JWTManager) ParseJWT(tokenString, audience, methodAndPath string, body []byte) (*jwtgo.Token, *customClaims, error) { +func (m *JWTManager) ParseJWT(tokenString, methodAndPath string, body []byte) (*jwtgo.Token, *customClaims, error) { claims := &customClaims{} err := claims.DecodeTokenString(tokenString) if err != nil { return nil, nil, fmt.Errorf("decoding JWT token: %w", err) } - err = claims.Validate(audience, methodAndPath, body, m.MaxTimeout) + err = claims.Validate(methodAndPath, body, m.MaxTimeout) if err != nil { return nil, nil, fmt.Errorf("pre-validating JWT token claims: %w", err) } @@ -67,14 +67,13 @@ func (m *JWTManager) ParseJWT(tokenString, audience, methodAndPath string, body } // GenerateJWT generates a JWT token with the given body and expiration time. -func (m *JWTManager) GenerateJWT(audience, methodAndPath string, body []byte, expiresAt time.Time) (string, error) { +func (m *JWTManager) GenerateJWT(methodAndPath string, body []byte, expiresAt time.Time) (string, error) { claims := &customClaims{ BodyHash: HashBody(body), MethodAndPath: strings.TrimSpace(methodAndPath), RegisteredClaims: jwtgo.RegisteredClaims{ IssuedAt: jwtgo.NewNumericDate(time.Now()), ExpiresAt: jwtgo.NewNumericDate(expiresAt), - Audience: jwtgo.ClaimStrings{audience}, Subject: m.PublicKey, }, } @@ -150,7 +149,7 @@ type MultiJWTTokenParser struct { jwtParsers map[string]JWTTokenParser } -func (m MultiJWTTokenParser) ParseJWT(tokenString, audience, methodAndPath string, body []byte) (*jwtgo.Token, *customClaims, error) { +func (m MultiJWTTokenParser) ParseJWT(tokenString, methodAndPath string, body []byte) (*jwtgo.Token, *customClaims, error) { claims := &customClaims{} err := claims.DecodeTokenString(tokenString) if err != nil { @@ -163,7 +162,7 @@ func (m MultiJWTTokenParser) ParseJWT(tokenString, audience, methodAndPath strin } //nolint:wrapcheck // we're ok not wrapping the error here because we're not adding any additional context - return jwtParser.ParseJWT(tokenString, audience, methodAndPath, body) + return jwtParser.ParseJWT(tokenString, methodAndPath, body) } func NewJWTTokenGenerator(stellarPrivateKey string) (JWTTokenGenerator, error) { diff --git a/pkg/wbclient/auth/jwt_manager_test.go b/pkg/wbclient/auth/jwt_manager_test.go index be612748..acaf6dc0 100644 --- a/pkg/wbclient/auth/jwt_manager_test.go +++ b/pkg/wbclient/auth/jwt_manager_test.go @@ -21,9 +21,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { validTimestamp := time.Now().Add(time.Second * 2) tooLongTimestamp := time.Now().Add(DefaultMaxTimeout * 2) - validHostname := "test.com" - invalidHostname := "invalid.test.com" - validMethodAndPath := "GET /valid-route" invalidMethodAndPath := "GET /invalid-route" @@ -32,7 +29,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { JWTManager func(t *testing.T) *JWTManager jwtBody []byte requestBody []byte - hostname string uri string expiresAt time.Time wantErrContains string @@ -46,7 +42,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { }, jwtBody: nil, requestBody: nil, - hostname: validHostname, uri: validMethodAndPath, expiresAt: validTimestamp, wantErrContains: "", @@ -60,7 +55,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { }, jwtBody: []byte(`{"foo": "bar"}`), requestBody: []byte(`{"foo": "bar"}`), - hostname: validHostname, uri: validMethodAndPath, expiresAt: validTimestamp, wantErrContains: "", @@ -74,7 +68,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { }, jwtBody: nil, requestBody: nil, - hostname: validHostname, uri: validMethodAndPath, expiresAt: validTimestamp, wantErrContains: "", @@ -88,7 +81,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { }, jwtBody: []byte(`{"foo": "bar"}`), requestBody: []byte(`{"foo": "bar"}`), - hostname: validHostname, uri: validMethodAndPath, expiresAt: validTimestamp, wantErrContains: "", @@ -100,7 +92,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { require.NoError(t, err) return jwtManager }, - hostname: validHostname, uri: validMethodAndPath, expiresAt: expiredTimestamp, wantErrContains: "the JWT token has expired by", @@ -112,7 +103,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { require.NoError(t, err) return jwtManager }, - hostname: validHostname, uri: validMethodAndPath, expiresAt: tooLongTimestamp, wantErrContains: "pre-validating JWT token claims: the JWT expiration is too long, max timeout is 15s", @@ -124,7 +114,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { require.NoError(t, err) return jwtManager }, - hostname: validHostname, uri: validMethodAndPath, expiresAt: validTimestamp, wantErrContains: "parsing JWT token with claims: token signature is invalid: ed25519: verification error", @@ -138,23 +127,10 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { }, jwtBody: []byte(`{"foo": "bar"}`), requestBody: []byte(`{"x": "y"}`), - hostname: validHostname, uri: validMethodAndPath, expiresAt: validTimestamp, wantErrContains: "pre-validating JWT token claims: the JWT hashed body does not match the expected value", }, - { - name: "🔴invalid_hostname", - JWTManager: func(t *testing.T) *JWTManager { - jwtManager, err := NewJWTManager(testKP1.Seed(), testKP1.Address(), 0) - require.NoError(t, err) - return jwtManager - }, - hostname: invalidHostname, - uri: validMethodAndPath, - expiresAt: validTimestamp, - wantErrContains: "pre-validating JWT token claims: the JWT audience [invalid.test.com] does not match the expected audience [test.com]", - }, { name: "🔴invalid_method_and_path", JWTManager: func(t *testing.T) *JWTManager { @@ -162,7 +138,6 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { require.NoError(t, err) return jwtManager }, - hostname: validHostname, uri: invalidMethodAndPath, expiresAt: validTimestamp, wantErrContains: `pre-validating JWT token claims: the JWT method-and-path "GET /invalid-route" does not match the expected method-and-path "GET /valid-route"`, @@ -172,10 +147,10 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { jwtManager := tc.JWTManager(t) - token, err := jwtManager.GenerateJWT(tc.hostname, tc.uri, tc.jwtBody, tc.expiresAt) + token, err := jwtManager.GenerateJWT(tc.uri, tc.jwtBody, tc.expiresAt) require.NoError(t, err) - parsedToken, parsedClaims, err := jwtManager.ParseJWT(token, validHostname, validMethodAndPath, tc.requestBody) + parsedToken, parsedClaims, err := jwtManager.ParseJWT(token, validMethodAndPath, tc.requestBody) if tc.wantErrContains != "" { assert.ErrorContains(t, err, tc.wantErrContains) assert.Nil(t, parsedToken, "parsed token should be nil") @@ -192,12 +167,12 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { jwtManager, err := NewJWTManager(testKP1.Seed(), testKP1.Address(), 0) require.NoError(t, err) - validToken, err := jwtManager.GenerateJWT(validHostname, validMethodAndPath, nil, validTimestamp) + validToken, err := jwtManager.GenerateJWT(validMethodAndPath, nil, validTimestamp) require.NoError(t, err) t.Run("🔴invalid_Public_Key", func(t *testing.T) { jwtManager := &JWTManager{PublicKey: "invalid-public-key"} - parsedToken, parsedClaims, err := jwtManager.ParseJWT(validToken, validHostname, validMethodAndPath, nil) + parsedToken, parsedClaims, err := jwtManager.ParseJWT(validToken, validMethodAndPath, nil) assert.ErrorContains(t, err, "the JWT token is not signed by the expected Stellar public key") assert.Nil(t, parsedToken, "parsed token should be nil") assert.Nil(t, parsedClaims, "parsed claims should be nil") @@ -205,7 +180,7 @@ func Test_JWTManager_GenerateAndParseToken(t *testing.T) { t.Run("🔴invalid_Private_Key", func(t *testing.T) { jwtManager := &JWTManager{PrivateKey: "invalid-private-key"} - token, err := jwtManager.GenerateJWT(validHostname, validMethodAndPath, nil, validTimestamp) + token, err := jwtManager.GenerateJWT(validMethodAndPath, nil, validTimestamp) assert.ErrorContains(t, err, "decoding Stellar private key") assert.Empty(t, token, "token should be empty") }) @@ -320,11 +295,11 @@ func Test_NewJWTTokenParser(t *testing.T) { // Generate a JWT token jwtManager, err := NewJWTManager(testKP1.Seed(), testKP1.Address(), 0) require.NoError(t, err) - token, err := jwtManager.GenerateJWT("", "", tc.reqBody, time.Now().Add(time.Second*2)) + token, err := jwtManager.GenerateJWT("", tc.reqBody, time.Now().Add(time.Second*2)) require.NoError(t, err) // Parse the JWT token - parsedToken, parsedClaims, err := jwtTokenParser.ParseJWT(token, "", "", tc.reqBody) + parsedToken, parsedClaims, err := jwtTokenParser.ParseJWT(token, "", tc.reqBody) require.NoError(t, err) assert.True(t, parsedToken.Valid, "parsed token should be valid") require.Equal(t, HashBody(tc.reqBody), parsedClaims.BodyHash) @@ -379,11 +354,11 @@ func Test_NewMultiJWTTokenParser(t *testing.T) { // Generate a JWT token jwtManager, err := NewJWTManager(testKP1.Seed(), testKP1.Address(), 0) require.NoError(t, err) - token, err := jwtManager.GenerateJWT("", "", tc.reqBody, time.Now().Add(time.Second*2)) + token, err := jwtManager.GenerateJWT("", tc.reqBody, time.Now().Add(time.Second*2)) require.NoError(t, err) // Parse the JWT token - parsedToken, parsedClaims, err := jwtTokenParser.ParseJWT(token, "", "", tc.reqBody) + parsedToken, parsedClaims, err := jwtTokenParser.ParseJWT(token, "", tc.reqBody) require.NoError(t, err) assert.True(t, parsedToken.Valid, "parsed token should be valid") require.Equal(t, HashBody(tc.reqBody), parsedClaims.BodyHash) @@ -427,7 +402,7 @@ func Test_NewJWTTokenGenerator(t *testing.T) { assert.NotNil(t, jwtTokenGenerator, "jwt token generator should not be nil") // Generate a JWT token - token, err := jwtTokenGenerator.GenerateJWT("", "", tc.reqBody, time.Now().Add(time.Second*2)) + token, err := jwtTokenGenerator.GenerateJWT("", tc.reqBody, time.Now().Add(time.Second*2)) require.NoError(t, err) assert.NotEmpty(t, token, "token should not be empty") } From 1a97fda8b0b0e126b8dd9d6dced5034d346354e7 Mon Sep 17 00:00:00 2001 From: akcays Date: Mon, 18 Aug 2025 12:11:47 -0400 Subject: [PATCH 09/16] Remove /payments/ endpoint, models, and ingestion logic (#284) * Remove /payments/ endpoint, models, and ingestion logic * Code review changes --- internal/data/fixtures.go | 18 - internal/data/models.go | 2 - internal/data/payments.go | 194 ---------- internal/data/payments_test.go | 339 ------------------ internal/data/query_utils.go | 37 -- .../2024-05-22.0-ingest_payments.sql | 23 -- .../2024-06-27.0-ingest_payments_2.sql | 13 - internal/metrics/metrics.go | 17 +- internal/metrics/metrics_test.go | 21 +- internal/metrics/mocks.go | 4 - internal/serve/httphandler/payment_handler.go | 53 --- .../serve/httphandler/payment_handler_test.go | 318 ---------------- .../httphandler/request_params_validator.go | 9 - internal/serve/serve.go | 18 +- internal/services/ingest.go | 134 +------ internal/services/ingest_test.go | 244 ------------- internal/services/payment_service.go | 99 ----- internal/services/payment_service_test.go | 149 -------- .../transactions/utils/operation_builder.go | 17 - internal/utils/ingestion_utils.go | 30 -- openapi/main.yaml | 166 +-------- 21 files changed, 10 insertions(+), 1895 deletions(-) delete mode 100644 internal/data/fixtures.go delete mode 100644 internal/data/payments.go delete mode 100644 internal/data/payments_test.go delete mode 100644 internal/data/query_utils.go delete mode 100644 internal/db/migrations/2024-06-27.0-ingest_payments_2.sql delete mode 100644 internal/serve/httphandler/payment_handler.go delete mode 100644 internal/serve/httphandler/payment_handler_test.go delete mode 100644 internal/services/payment_service.go delete mode 100644 internal/services/payment_service_test.go diff --git a/internal/data/fixtures.go b/internal/data/fixtures.go deleted file mode 100644 index ad4b1343..00000000 --- a/internal/data/fixtures.go +++ /dev/null @@ -1,18 +0,0 @@ -package data - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/stellar/wallet-backend/internal/db" -) - -func InsertTestPayments(t *testing.T, ctx context.Context, payments []Payment, connectionPool db.ConnectionPool) { - t.Helper() - - const query = `INSERT INTO ingest_payments (operation_id, operation_type, transaction_id, transaction_hash, from_address, to_address, src_asset_code, src_asset_issuer, src_asset_type, src_amount, dest_asset_code, dest_asset_issuer, dest_asset_type, dest_amount, created_at, memo, memo_type) VALUES (:operation_id, :operation_type, :transaction_id, :transaction_hash, :from_address, :to_address, :src_asset_code, :src_asset_issuer, :src_asset_type, :src_amount, :dest_asset_code, :dest_asset_issuer, :dest_asset_type, :dest_amount, :created_at, :memo, :memo_type);` - _, err := connectionPool.NamedExecContext(ctx, query, payments) - require.NoError(t, err) -} diff --git a/internal/data/models.go b/internal/data/models.go index 3e0a775a..4f2cdafc 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -13,7 +13,6 @@ type Models struct { Contract *ContractModel IngestStore *IngestStoreModel Operations *OperationModel - Payments *PaymentModel Transactions *TransactionModel StateChanges *StateChangeModel } @@ -29,7 +28,6 @@ func NewModels(db db.ConnectionPool, metricsService metrics.MetricsService) (*Mo Contract: &ContractModel{DB: db, MetricsService: metricsService}, IngestStore: &IngestStoreModel{DB: db, MetricsService: metricsService}, Operations: &OperationModel{DB: db, MetricsService: metricsService}, - Payments: &PaymentModel{DB: db, MetricsService: metricsService}, Transactions: &TransactionModel{DB: db, MetricsService: metricsService}, StateChanges: &StateChangeModel{DB: db, MetricsService: metricsService}, }, nil diff --git a/internal/data/payments.go b/internal/data/payments.go deleted file mode 100644 index 51ead947..00000000 --- a/internal/data/payments.go +++ /dev/null @@ -1,194 +0,0 @@ -package data - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/metrics" -) - -type PaymentModel struct { - DB db.ConnectionPool - MetricsService metrics.MetricsService -} - -type Payment struct { - OperationID string `db:"operation_id" json:"operationId"` - OperationType string `db:"operation_type" json:"operationType"` - TransactionID string `db:"transaction_id" json:"transactionId"` - TransactionHash string `db:"transaction_hash" json:"transactionHash"` - FromAddress string `db:"from_address" json:"fromAddress"` - ToAddress string `db:"to_address" json:"toAddress"` - SrcAssetCode string `db:"src_asset_code" json:"srcAssetCode"` - SrcAssetIssuer string `db:"src_asset_issuer" json:"srcAssetIssuer"` - SrcAssetType string `db:"src_asset_type" json:"srcAssetType"` - SrcAmount int64 `db:"src_amount" json:"srcAmount"` - DestAssetCode string `db:"dest_asset_code" json:"destAssetCode"` - DestAssetIssuer string `db:"dest_asset_issuer" json:"destAssetIssuer"` - DestAssetType string `db:"dest_asset_type" json:"destAssetType"` - DestAmount int64 `db:"dest_amount" json:"destAmount"` - CreatedAt time.Time `db:"created_at" json:"createdAt"` - Memo *string `db:"memo" json:"memo"` - MemoType string `db:"memo_type" json:"memoType"` -} - -func (m *PaymentModel) AddPayment(ctx context.Context, tx db.Transaction, payment Payment) error { - const query = ` - INSERT INTO ingest_payments ( - operation_id, operation_type, transaction_id, transaction_hash, from_address, to_address, src_asset_code, src_asset_issuer, src_asset_type, src_amount, - dest_asset_code, dest_asset_issuer, dest_asset_type, dest_amount, created_at, memo, memo_type - ) - SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 - WHERE EXISTS ( - SELECT 1 FROM accounts WHERE stellar_address IN ($5, $6) - ) - ON CONFLICT (operation_id) DO UPDATE SET - operation_type = EXCLUDED.operation_type, - transaction_id = EXCLUDED.transaction_id, - transaction_hash = EXCLUDED.transaction_hash, - from_address = EXCLUDED.from_address, - to_address = EXCLUDED.to_address, - src_asset_code = EXCLUDED.src_asset_code, - src_asset_issuer = EXCLUDED.src_asset_issuer, - src_asset_type = EXCLUDED.src_asset_type, - src_amount = EXCLUDED.src_amount, - dest_asset_code = EXCLUDED.dest_asset_code, - dest_asset_issuer = EXCLUDED.dest_asset_issuer, - dest_asset_type = EXCLUDED.dest_asset_type, - dest_amount = EXCLUDED.dest_amount, - created_at = EXCLUDED.created_at, - memo = EXCLUDED.memo, - memo_type = EXCLUDED.memo_type - ; - ` - start := time.Now() - _, err := tx.ExecContext(ctx, query, payment.OperationID, payment.OperationType, payment.TransactionID, payment.TransactionHash, payment.FromAddress, payment.ToAddress, payment.SrcAssetCode, payment.SrcAssetIssuer, payment.SrcAssetType, payment.SrcAmount, - payment.DestAssetCode, payment.DestAssetIssuer, payment.DestAssetType, payment.DestAmount, payment.CreatedAt, payment.Memo, payment.MemoType) - duration := time.Since(start).Seconds() - m.MetricsService.ObserveDBQueryDuration("INSERT", "ingest_payments", duration) - if err != nil { - return fmt.Errorf("inserting payment: %w", err) - } - m.MetricsService.IncDBQuery("INSERT", "ingest_payments") - return nil -} - -func (m *PaymentModel) GetPaymentsPaginated(ctx context.Context, address string, beforeID, afterID string, sort SortOrder, limit int) ([]Payment, bool, bool, error) { - if !sort.IsValid() { - return nil, false, false, fmt.Errorf("invalid sort value: %s", sort) - } - - if beforeID != "" && afterID != "" { - return nil, false, false, errors.New("at most one cursor may be provided, got afterId and beforeId") - } - - const filteredSetCTE = ` - WITH filtered_set AS ( - SELECT * FROM ingest_payments WHERE :address = '' OR :address IN (from_address, to_address) - ) - ` - - var selectQ string - if beforeID != "" && sort == DESC { - selectQ = "SELECT * FROM (SELECT * FROM filtered_set WHERE operation_id > :before_id ORDER BY operation_id ASC LIMIT :limit) AS reverse_set ORDER BY operation_id DESC" - } else if beforeID != "" && sort == ASC { - selectQ = "SELECT * FROM (SELECT * FROM filtered_set WHERE operation_id < :before_id ORDER BY operation_id DESC LIMIT :limit) AS reverse_set ORDER BY operation_id ASC" - } else if afterID != "" && sort == DESC { - selectQ = "SELECT * FROM filtered_set WHERE operation_id < :after_id ORDER BY operation_id DESC LIMIT :limit" - } else if afterID != "" && sort == ASC { - selectQ = "SELECT * FROM filtered_set WHERE operation_id > :after_id ORDER BY operation_id ASC LIMIT :limit" - } else if sort == ASC { - selectQ = "SELECT * FROM filtered_set ORDER BY operation_id ASC LIMIT :limit" - } else { - selectQ = "SELECT * FROM filtered_set ORDER BY operation_id DESC LIMIT :limit" - } - - argumentsMap := map[string]interface{}{ - "address": address, - "limit": limit, - "before_id": beforeID, - "after_id": afterID, - } - - payments := make([]Payment, 0) - query := fmt.Sprintf("%s %s", filteredSetCTE, selectQ) - query, args, err := PrepareNamedQuery(ctx, m.DB, query, argumentsMap) - if err != nil { - return nil, false, false, fmt.Errorf("preparing named query: %w", err) - } - start := time.Now() - err = m.DB.SelectContext(ctx, &payments, query, args...) - duration := time.Since(start).Seconds() - m.MetricsService.ObserveDBQueryDuration("SELECT", "ingest_payments", duration) - if err != nil { - return nil, false, false, fmt.Errorf("fetching payments: %w", err) - } - m.MetricsService.IncDBQuery("SELECT", "ingest_payments") - - prevExists, nextExists, err := m.existsPrevNext(ctx, filteredSetCTE, address, sort, payments) - if err != nil { - return nil, false, false, fmt.Errorf("checking prev and next pages: %w", err) - } - return payments, prevExists, nextExists, nil -} - -func (m *PaymentModel) existsPrevNext(ctx context.Context, filteredSetCTE string, address string, sort SortOrder, payments []Payment) (bool, bool, error) { - if len(payments) == 0 { - return false, false, nil - } - - firstElementID := FirstPaymentOperationID(payments) - lastElementID := LastPaymentOperationID(payments) - - query := fmt.Sprintf(` - %s - SELECT - EXISTS( - SELECT 1 FROM filtered_set WHERE CASE WHEN :sort = 'ASC' THEN operation_id < :first_element_id WHEN :sort = 'DESC' THEN operation_id > :first_element_id END LIMIT 1 - ) AS prev_exists, - EXISTS( - SELECT 1 FROM filtered_set WHERE CASE WHEN :sort = 'ASC' THEN operation_id > :last_element_id WHEN :sort = 'DESC' THEN operation_id < :last_element_id END LIMIT 1 - ) AS next_exists - `, filteredSetCTE) - - argumentsMap := map[string]interface{}{ - "address": address, - "first_element_id": firstElementID, - "last_element_id": lastElementID, - "sort": sort, - } - - query, args, err := PrepareNamedQuery(ctx, m.DB, query, argumentsMap) - if err != nil { - return false, false, fmt.Errorf("preparing named query: %w", err) - } - - var prevExists, nextExists bool - start := time.Now() - err = m.DB.QueryRowxContext(ctx, query, args...).Scan(&prevExists, &nextExists) - duration := time.Since(start).Seconds() - m.MetricsService.ObserveDBQueryDuration("SELECT", "ingest_payments", duration) - if err != nil { - return false, false, fmt.Errorf("fetching prev and next exists: %w", err) - } - m.MetricsService.IncDBQuery("SELECT", "ingest_payments") - return prevExists, nextExists, nil -} - -func FirstPaymentOperationID(payments []Payment) string { - if len(payments) > 0 { - return payments[0].OperationID - } - return "" -} - -func LastPaymentOperationID(payments []Payment) string { - len := len(payments) - if len > 0 { - return payments[len-1].OperationID - } - return "" -} diff --git a/internal/data/payments_test.go b/internal/data/payments_test.go deleted file mode 100644 index 3db96858..00000000 --- a/internal/data/payments_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package data - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/stellar/go/xdr" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/db/dbtest" - "github.com/stellar/wallet-backend/internal/metrics" - "github.com/stellar/wallet-backend/internal/utils" -) - -func TestPaymentModelAddPayment(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "ingest_payments", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "INSERT", "ingest_payments").Return() - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - ctx := context.Background() - - const ( - fromAddress = "GCYQBVCREYSLKHHOWLT27VNZNGVIXXAPYVNNOWMQV67WVDD4PP2VZAX7" - toAddress = "GDDEAH46MNFO6JD7NTQ5FWJBC4ZSA47YEK3RKFHQWADYTS6NDVD5CORW" - ) - payment := Payment{ - OperationID: "2120562792996865", - OperationType: xdr.OperationTypePayment.String(), - TransactionID: "2120562792996864", - TransactionHash: "a3daffa64dc46db84888b1206dc8014a480042e7fe8b19fd5d05465709f4e887", - FromAddress: fromAddress, - ToAddress: toAddress, - SrcAssetCode: "USDC", - SrcAssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - SrcAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), - SrcAmount: 500000000, - DestAssetCode: "USDC", - DestAssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), - DestAmount: 500000000, - CreatedAt: time.Date(2023, 12, 15, 1, 0, 0, 0, time.UTC), - Memo: nil, - MemoType: xdr.MemoTypeMemoNone.String(), - } - - addPayment := func(p Payment) { - err := db.RunInTransaction(ctx, m.DB, nil, func(dbTx db.Transaction) error { - return m.AddPayment(ctx, dbTx, p) - }) - require.NoError(t, err) - } - - fetchPayment := func() (Payment, error) { - var dbPayment Payment - err := dbConnectionPool.GetContext(ctx, &dbPayment, "SELECT * FROM ingest_payments") - return dbPayment, err - } - - cleanUpDB := func() { - _, err := dbConnectionPool.ExecContext(ctx, `DELETE FROM accounts; DELETE FROM ingest_payments;`) - require.NoError(t, err) - } - - t.Run("unkown_address", func(t *testing.T) { - addPayment(payment) - - _, err := fetchPayment() - assert.ErrorIs(t, err, sql.ErrNoRows) - - cleanUpDB() - }) - - t.Run("from_known_address", func(t *testing.T) { - _, err := dbConnectionPool.ExecContext(ctx, `INSERT INTO accounts (stellar_address) VALUES ($1)`, fromAddress) - require.NoError(t, err) - - addPayment(payment) - - dbPayment, err := fetchPayment() - require.NoError(t, err) - assert.Equal(t, payment, dbPayment) - - cleanUpDB() - }) - - t.Run("to_known_address", func(t *testing.T) { - _, err := dbConnectionPool.ExecContext(ctx, `INSERT INTO accounts (stellar_address) VALUES ($1)`, toAddress) - require.NoError(t, err) - - addPayment(payment) - - dbPayment, err := fetchPayment() - require.NoError(t, err) - assert.Equal(t, payment, dbPayment) - - cleanUpDB() - }) - - t.Run("to_known_address_update_on_reingestion", func(t *testing.T) { - updatedPayment := Payment{ - OperationID: payment.OperationID, // Same OperationID - OperationType: xdr.OperationTypePathPaymentStrictSend.String(), - TransactionID: "2120562792996865", - TransactionHash: "a3daffa64dc46db84888b1206dc8014a480042e7fe8b19fd5d05465709f4e888", - FromAddress: fromAddress, - ToAddress: toAddress, - SrcAssetCode: "XLM", - SrcAssetIssuer: "", - SrcAssetType: xdr.AssetTypeAssetTypeCreditAlphanum12.String(), - SrcAmount: 300000000, - DestAssetCode: "ARST", - DestAssetIssuer: "GB7TAYRUZGE6TVT7NHP5SMIZRNQA6PLM423EYISAOAP3MKYIQMVYP2JO", - DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum12.String(), - DestAmount: 700000000, - CreatedAt: time.Date(2023, 12, 16, 1, 0, 0, 0, time.UTC), - Memo: utils.PointOf("diff"), - MemoType: xdr.MemoTypeMemoText.String(), - } - - _, err := dbConnectionPool.ExecContext(ctx, `INSERT INTO accounts (stellar_address) VALUES ($1)`, toAddress) - require.NoError(t, err) - - addPayment(payment) - addPayment(updatedPayment) - - dbPayment, err := fetchPayment() - require.NoError(t, err) - assert.Equal(t, updatedPayment, dbPayment) - - cleanUpDB() - }) -} - -func TestPaymentModelGetPaymentsPaginated(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - ctx := context.Background() - - dbPayments := []Payment{ - {OperationID: "1", OperationType: xdr.OperationTypePayment.String(), TransactionID: "11", TransactionHash: "c370ff20144e4c96b17432b8d14664c1", FromAddress: "GAZ37ZO4TU3H", ToAddress: "GDD2HQO6IOFT", SrcAssetCode: "XLM", SrcAssetIssuer: "", SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), SrcAmount: 10, DestAssetCode: "XLM", DestAssetIssuer: "", DestAssetType: xdr.AssetTypeAssetTypeNative.String(), DestAmount: 10, CreatedAt: time.Date(2024, 6, 21, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "2", OperationType: xdr.OperationTypePayment.String(), TransactionID: "22", TransactionHash: "30850d8fc7d1439782885103390cd975", FromAddress: "GBZ5Q56JKHJQ", ToAddress: "GASV72SENBSY", SrcAssetCode: "XLM", SrcAssetIssuer: "", SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), SrcAmount: 20, DestAssetCode: "XLM", DestAssetIssuer: "", DestAssetType: xdr.AssetTypeAssetTypeNative.String(), DestAmount: 20, CreatedAt: time.Date(2024, 6, 22, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "3", OperationType: xdr.OperationTypePayment.String(), TransactionID: "33", TransactionHash: "d9521ed7057d4d1e9b9dd22ab515cbf1", FromAddress: "GAYFAYPOECBT", ToAddress: "GDWDPNMALNIT", SrcAssetCode: "XLM", SrcAssetIssuer: "", SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), SrcAmount: 30, DestAssetCode: "XLM", DestAssetIssuer: "", DestAssetType: xdr.AssetTypeAssetTypeNative.String(), DestAmount: 30, CreatedAt: time.Date(2024, 6, 23, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "4", OperationType: xdr.OperationTypePayment.String(), TransactionID: "44", TransactionHash: "2af98496a86741c6a6814200e06027fd", FromAddress: "GACKTNR2QQXU", ToAddress: "GBZ5KUZHAAVI", SrcAssetCode: "USDC", SrcAssetIssuer: "GAHLU7PDIQMZ", SrcAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), SrcAmount: 40, DestAssetCode: "USDC", DestAssetIssuer: "GAHLU7PDIQMZ", DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), DestAmount: 40, CreatedAt: time.Date(2024, 6, 24, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "5", OperationType: xdr.OperationTypePayment.String(), TransactionID: "55", TransactionHash: "edfab36f9f104c4fb74b549de44cfbcc", FromAddress: "GA4CMYJEC5W5", ToAddress: "GAZ37ZO4TU3H", SrcAssetCode: "USDC", SrcAssetIssuer: "GAHLU7PDIQMZ", SrcAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), SrcAmount: 50, DestAssetCode: "USDC", DestAssetIssuer: "GAHLU7PDIQMZ", DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), DestAmount: 50, CreatedAt: time.Date(2024, 6, 25, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - } - InsertTestPayments(t, ctx, dbPayments, dbConnectionPool) - - t.Run("no_filter_desc", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - payments, prevExists, nextExists, err := m.GetPaymentsPaginated(ctx, "", "", "", DESC, 2) - require.NoError(t, err) - - assert.False(t, prevExists) - assert.True(t, nextExists) - - assert.Equal(t, []Payment{ - dbPayments[4], - dbPayments[3], - }, payments) - }) - - t.Run("no_filter_asc", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - payments, prevExists, nextExists, err := m.GetPaymentsPaginated(ctx, "", "", "", ASC, 2) - require.NoError(t, err) - - assert.False(t, prevExists) - assert.True(t, nextExists) - - assert.Equal(t, []Payment{ - dbPayments[0], - dbPayments[1], - }, payments) - }) - - t.Run("filter_address", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - payments, prevExists, nextExists, err := m.GetPaymentsPaginated(ctx, dbPayments[1].FromAddress, "", "", DESC, 2) - require.NoError(t, err) - - assert.False(t, prevExists) - assert.False(t, nextExists) - - assert.Equal(t, []Payment{ - dbPayments[1], - }, payments) - }) - - t.Run("filter_after_id_desc", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - payments, prevExists, nextExists, err := m.GetPaymentsPaginated(ctx, "", "", dbPayments[3].OperationID, DESC, 2) - require.NoError(t, err) - - assert.True(t, prevExists) - assert.True(t, nextExists) - - assert.Equal(t, []Payment{ - dbPayments[2], - dbPayments[1], - }, payments) - }) - - t.Run("filter_after_id_asc", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - payments, prevExists, nextExists, err := m.GetPaymentsPaginated(ctx, "", "", dbPayments[3].OperationID, ASC, 2) - require.NoError(t, err) - - assert.True(t, prevExists) - assert.False(t, nextExists) - - assert.Equal(t, []Payment{ - dbPayments[4], - }, payments) - }) - - t.Run("filter_before_id_desc", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - payments, prevExists, nextExists, err := m.GetPaymentsPaginated(ctx, "", dbPayments[2].OperationID, "", DESC, 2) - require.NoError(t, err) - - assert.False(t, prevExists) - assert.True(t, nextExists) - - assert.Equal(t, []Payment{ - dbPayments[4], - dbPayments[3], - }, payments) - }) - - t.Run("filter_before_id_asc", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - defer mockMetricsService.AssertExpectations(t) - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - payments, prevExists, nextExists, err := m.GetPaymentsPaginated(ctx, "", dbPayments[2].OperationID, "", ASC, 2) - require.NoError(t, err) - - assert.False(t, prevExists) - assert.True(t, nextExists) - - assert.Equal(t, []Payment{ - dbPayments[0], - dbPayments[1], - }, payments) - }) - - t.Run("filter_before_id_after_id_asc", func(t *testing.T) { - mockMetricsService := metrics.NewMockMetricsService() - - m := &PaymentModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - - _, _, _, err := m.GetPaymentsPaginated(ctx, "", dbPayments[4].OperationID, dbPayments[2].OperationID, ASC, 2) - assert.ErrorContains(t, err, "at most one cursor may be provided, got afterId and beforeId") - }) -} diff --git a/internal/data/query_utils.go b/internal/data/query_utils.go deleted file mode 100644 index f04e82ec..00000000 --- a/internal/data/query_utils.go +++ /dev/null @@ -1,37 +0,0 @@ -package data - -import ( - "context" - "fmt" - - "github.com/jmoiron/sqlx" - - "github.com/stellar/wallet-backend/internal/db" -) - -type SortOrder string - -const ( - ASC SortOrder = "ASC" - DESC SortOrder = "DESC" -) - -func (o SortOrder) IsValid() bool { - return o == ASC || o == DESC -} - -// PrepareNamedQuery prepares the given query replacing the named parameters with Postgres' bindvars. -// It returns an SQL Injection-safe query and the arguments array to be used alongside it. -func PrepareNamedQuery(ctx context.Context, connectionPool db.ConnectionPool, namedQuery string, argsMap map[string]interface{}) (string, []interface{}, error) { - query, args, err := sqlx.Named(namedQuery, argsMap) - if err != nil { - return "", nil, fmt.Errorf("replacing attributes with bindvars: %w", err) - } - query, args, err = sqlx.In(query, args...) - if err != nil { - return "", nil, fmt.Errorf("expanding slice arguments: %w", err) - } - query = connectionPool.Rebind(query) - - return query, args, nil -} diff --git a/internal/db/migrations/2024-05-22.0-ingest_payments.sql b/internal/db/migrations/2024-05-22.0-ingest_payments.sql index 6898ce63..735a5a04 100644 --- a/internal/db/migrations/2024-05-22.0-ingest_payments.sql +++ b/internal/db/migrations/2024-05-22.0-ingest_payments.sql @@ -6,29 +6,6 @@ CREATE TABLE ingest_store ( PRIMARY KEY (key) ); -CREATE TABLE ingest_payments ( - operation_id bigint NOT NULL, - operation_type text NOT NULL, - transaction_id bigint NOT NULL, - transaction_hash text NOT NULL, - from_address text NOT NULL, - to_address text NOT NULL, - src_asset_code text NOT NULL, - src_asset_issuer text NOT NULL, - src_amount bigint NOT NULL, - dest_asset_code text NOT NULL, - dest_asset_issuer text NOT NULL, - dest_amount bigint NOT NULL, - created_at timestamp with time zone NOT NULL, - memo text NULL, - PRIMARY KEY (operation_id) -); - -CREATE INDEX from_address_idx ON ingest_payments (from_address); -CREATE INDEX to_address_idx ON ingest_payments (to_address); - -- +migrate Down -DROP TABLE ingest_payments; - DROP TABLE ingest_store; diff --git a/internal/db/migrations/2024-06-27.0-ingest_payments_2.sql b/internal/db/migrations/2024-06-27.0-ingest_payments_2.sql deleted file mode 100644 index 5761b38f..00000000 --- a/internal/db/migrations/2024-06-27.0-ingest_payments_2.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +migrate Up - -ALTER TABLE ingest_payments - ADD COLUMN src_asset_type text NOT NULL, - ADD COLUMN dest_asset_type text NOT NULL, - ADD COLUMN memo_type text NULL; - --- +migrate Down - -ALTER TABLE ingest_payments - DROP COLUMN src_asset_type, - DROP COLUMN dest_asset_type, - DROP COLUMN memo_type; diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index fa2f8377..846f6619 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -13,7 +13,6 @@ import ( type MetricsService interface { RegisterPoolMetrics(channel string, pool *pond.WorkerPool) GetRegistry() *prometheus.Registry - SetNumPaymentOpsIngestedPerLedger(operationType string, value int) SetLatestLedgerIngested(value float64) ObserveIngestionDuration(ingestionType string, duration float64) IncActiveAccount() @@ -37,9 +36,8 @@ type metricsService struct { db *sqlx.DB // Ingest Service Metrics - numPaymentOpsIngestedPerLedger *prometheus.GaugeVec - latestLedgerIngested prometheus.Gauge - ingestionDuration *prometheus.SummaryVec + latestLedgerIngested prometheus.Gauge + ingestionDuration *prometheus.SummaryVec // Account Metrics activeAccounts prometheus.Gauge @@ -72,13 +70,6 @@ func NewMetricsService(db *sqlx.DB) MetricsService { } // Ingest Service Metrics - m.numPaymentOpsIngestedPerLedger = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "num_payment_ops_ingested_per_ledger", - Help: "Number of payment operations ingested per ledger", - }, - []string{"operation_type"}, - ) m.latestLedgerIngested = prometheus.NewGauge( prometheus.GaugeOpts{ Name: "latest_ledger_ingested", @@ -196,7 +187,6 @@ func (m *metricsService) registerMetrics() { collector := sqlstats.NewStatsCollector("wallet-backend-db", m.db) m.registry.MustRegister( collector, - m.numPaymentOpsIngestedPerLedger, m.latestLedgerIngested, m.ingestionDuration, m.activeAccounts, @@ -300,9 +290,6 @@ func (m *metricsService) GetRegistry() *prometheus.Registry { } // Ingest Service Metrics -func (m *metricsService) SetNumPaymentOpsIngestedPerLedger(operationType string, value int) { - m.numPaymentOpsIngestedPerLedger.WithLabelValues(operationType).Set(float64(value)) -} func (m *metricsService) SetLatestLedgerIngested(value float64) { m.latestLedgerIngested.Set(value) diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index c8698417..74c2a45b 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -33,24 +33,6 @@ func TestIngestMetrics(t *testing.T) { ms := NewMetricsService(db) - t.Run("payment ops metrics", func(t *testing.T) { - ms.SetNumPaymentOpsIngestedPerLedger("create_account", 5) - ms.SetNumPaymentOpsIngestedPerLedger("payment", 10) - - // We can't directly access the metric values, but we can verify they're collected - metricFamilies, err := ms.GetRegistry().Gather() - require.NoError(t, err) - - found := false - for _, mf := range metricFamilies { - if mf.GetName() == "num_payment_ops_ingested_per_ledger" { - found = true - assert.Equal(t, 2, len(mf.GetMetric())) - } - } - assert.True(t, found) - }) - t.Run("latest ledger metrics", func(t *testing.T) { ms.SetLatestLedgerIngested(1234) @@ -68,7 +50,6 @@ func TestIngestMetrics(t *testing.T) { }) t.Run("ingestion duration metrics", func(t *testing.T) { - ms.ObserveIngestionDuration("payment", 0.5) ms.ObserveIngestionDuration("transaction", 1.0) metricFamilies, err := ms.GetRegistry().Gather() @@ -78,7 +59,7 @@ func TestIngestMetrics(t *testing.T) { for _, mf := range metricFamilies { if mf.GetName() == "ingestion_duration_seconds" { found = true - assert.Equal(t, 2, len(mf.GetMetric())) + assert.Equal(t, 1, len(mf.GetMetric())) } } assert.True(t, found) diff --git a/internal/metrics/mocks.go b/internal/metrics/mocks.go index a2658876..1c72e8be 100644 --- a/internal/metrics/mocks.go +++ b/internal/metrics/mocks.go @@ -25,10 +25,6 @@ func (m *MockMetricsService) GetRegistry() *prometheus.Registry { return args.Get(0).(*prometheus.Registry) } -func (m *MockMetricsService) SetNumPaymentOpsIngestedPerLedger(operationType string, value int) { - m.Called(operationType, value) -} - func (m *MockMetricsService) SetLatestLedgerIngested(value float64) { m.Called(value) } diff --git a/internal/serve/httphandler/payment_handler.go b/internal/serve/httphandler/payment_handler.go deleted file mode 100644 index 49eff95c..00000000 --- a/internal/serve/httphandler/payment_handler.go +++ /dev/null @@ -1,53 +0,0 @@ -package httphandler - -import ( - "net/http" - - "github.com/stellar/go/support/render/httpjson" - - "github.com/stellar/wallet-backend/internal/apptracker" - "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/entities" - "github.com/stellar/wallet-backend/internal/serve/httperror" - "github.com/stellar/wallet-backend/internal/services" -) - -type PaymentHandler struct { - PaymentService services.PaymentService - AppTracker apptracker.AppTracker -} - -type PaymentsRequest struct { - Address string `query:"address" validate:"public_key"` - AfterID string `query:"afterId"` - BeforeID string `query:"beforeId"` - Sort data.SortOrder `query:"sort" validate:"oneof=ASC DESC"` - Limit int `query:"limit" validate:"gt=0,lte=200"` -} - -type PaymentsResponse struct { - Payments []data.Payment `json:"payments"` - entities.Pagination -} - -func (h PaymentHandler) GetPayments(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - reqQuery := PaymentsRequest{Sort: data.DESC, Limit: 50} - httpErr := DecodeQueryAndValidate(ctx, r, &reqQuery, h.AppTracker) - if httpErr != nil { - httpErr.Render(w) - return - } - - payments, pagination, err := h.PaymentService.GetPaymentsPaginated(ctx, reqQuery.Address, reqQuery.BeforeID, reqQuery.AfterID, reqQuery.Sort, reqQuery.Limit) - if err != nil { - httperror.InternalServerError(ctx, "", err, nil, h.AppTracker).Render(w) - return - } - - httpjson.Render(w, PaymentsResponse{ - Payments: payments, - Pagination: pagination, - }, httpjson.JSON) -} diff --git a/internal/serve/httphandler/payment_handler_test.go b/internal/serve/httphandler/payment_handler_test.go deleted file mode 100644 index 2a8c96c1..00000000 --- a/internal/serve/httphandler/payment_handler_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package httphandler - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/go-chi/chi" - "github.com/stellar/go/xdr" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/db/dbtest" - "github.com/stellar/wallet-backend/internal/metrics" - "github.com/stellar/wallet-backend/internal/services" - "github.com/stellar/wallet-backend/internal/utils" -) - -func TestPaymentHandlerGetPayments(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - setupTest := func() (*PaymentHandler, *metrics.MockMetricsService) { - mockMetricsService := metrics.NewMockMetricsService() - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - paymentService, err := services.NewPaymentService(models, "http://testing.com") - require.NoError(t, err) - handler := &PaymentHandler{ - PaymentService: paymentService, - } - return handler, mockMetricsService - } - - // Setup router and test data - setupRouter := func(handler *PaymentHandler) *chi.Mux { - r := chi.NewRouter() - r.Route("/payments", func(r chi.Router) { - r.Get("/", handler.GetPayments) - }) - return r - } - - ctx := context.Background() - - dbPayments := []data.Payment{ - { - OperationID: "1", - OperationType: xdr.OperationTypePayment.String(), - TransactionID: "11", - TransactionHash: "c370ff20144e4c96b17432b8d14664c1", - FromAddress: "GD73EG2IJJQQTCD33JKPKEGS76CJJ4TQ7NHDQYMS4D3Z5FBHPML6M66W", - ToAddress: "GCJ4LXZIQRSS5Z7YVIH5YLA7RXMYB64DQN3XMKWEBHUUAFXIXOL3GYVT", - SrcAssetCode: "XLM", - SrcAssetIssuer: "", - SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), - SrcAmount: 10, - DestAssetCode: "XLM", - DestAssetIssuer: "", - DestAssetType: xdr.AssetTypeAssetTypeNative.String(), - DestAmount: 10, - CreatedAt: time.Date(2024, 6, 21, 0, 0, 0, 0, time.UTC), - Memo: utils.PointOf("test"), - MemoType: xdr.MemoTypeMemoText.String(), - }, - { - OperationID: "2", - OperationType: xdr.OperationTypePayment.String(), - TransactionID: "22", - TransactionHash: "30850d8fc7d1439782885103390cd975", - FromAddress: "GASP7HTICNNA2U5RKMPRQELEUJFO7PBB3AKKRGTAG23QVG255ESPZW2L", - ToAddress: "GDB4RW6QFWMGHGI6JTIKMGVUUQO7NNOLSFDMCOMUCCWHMAMFL3FH4Q2J", - SrcAssetCode: "USDC", - SrcAssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - SrcAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), - SrcAmount: 20, - DestAssetCode: "USDC", - DestAssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), - DestAmount: 20, - CreatedAt: time.Date(2024, 6, 22, 0, 0, 0, 0, time.UTC), - Memo: utils.PointOf("123"), - MemoType: xdr.MemoTypeMemoId.String(), - }, - { - OperationID: "3", - OperationType: xdr.OperationTypePathPaymentStrictSend.String(), - TransactionID: "33", - TransactionHash: "d9521ed7057d4d1e9b9dd22ab515cbf1", - FromAddress: "GCXBGEYNIEIUJ56YX5UVBM27NTKCBMLDD2NEPTTXZGQMBA2EOKG5VA2W", - ToAddress: "GAX6VPTVC2YNJM52OYMJAZKTQMSLNQ6NKYYU77KSGRVHINZ2D3EUJWAN", - SrcAssetCode: "XLM", - SrcAssetIssuer: "", - SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), - SrcAmount: 300, - DestAssetCode: "USDC", - DestAssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), - DestAmount: 30, - CreatedAt: time.Date(2024, 6, 23, 0, 0, 0, 0, time.UTC), - Memo: nil, - MemoType: xdr.MemoTypeMemoNone.String(), - }, - } - data.InsertTestPayments(t, ctx, dbPayments, dbConnectionPool) - - t.Run("no_filters", func(t *testing.T) { - handler, mockMetricsService := setupTest() - r := setupRouter(handler) - - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - - // Prepare request - req, err := http.NewRequest(http.MethodGet, "/payments", nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 200 response - assert.Equal(t, http.StatusOK, rr.Code) - - resp := rr.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - expectedRespBody := `{ - "_links": { - "next": "", - "prev": "", - "self": "http://testing.com?limit=50&sort=DESC" - }, - "payments": [ - { - "createdAt": "2024-06-23T00:00:00Z", - "destAmount": 30, - "destAssetCode": "USDC", - "destAssetIssuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - "destAssetType": "AssetTypeAssetTypeCreditAlphanum4", - "fromAddress": "GCXBGEYNIEIUJ56YX5UVBM27NTKCBMLDD2NEPTTXZGQMBA2EOKG5VA2W", - "memo": null, - "memoType": "MemoTypeMemoNone", - "operationId": "3", - "operationType": "OperationTypePathPaymentStrictSend", - "srcAmount": 300, - "srcAssetCode": "XLM", - "srcAssetIssuer": "", - "srcAssetType": "AssetTypeAssetTypeNative", - "toAddress": "GAX6VPTVC2YNJM52OYMJAZKTQMSLNQ6NKYYU77KSGRVHINZ2D3EUJWAN", - "transactionHash": "d9521ed7057d4d1e9b9dd22ab515cbf1", - "transactionId": "33" - }, - { - "createdAt": "2024-06-22T00:00:00Z", - "destAmount": 20, - "destAssetCode": "USDC", - "destAssetIssuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - "destAssetType": "AssetTypeAssetTypeCreditAlphanum4", - "fromAddress": "GASP7HTICNNA2U5RKMPRQELEUJFO7PBB3AKKRGTAG23QVG255ESPZW2L", - "memo": "123", - "memoType": "MemoTypeMemoId", - "operationId": "2", - "operationType": "OperationTypePayment", - "srcAmount": 20, - "srcAssetCode": "USDC", - "srcAssetIssuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - "srcAssetType": "AssetTypeAssetTypeCreditAlphanum4", - "toAddress": "GDB4RW6QFWMGHGI6JTIKMGVUUQO7NNOLSFDMCOMUCCWHMAMFL3FH4Q2J", - "transactionHash": "30850d8fc7d1439782885103390cd975", - "transactionId": "22" - }, - { - "createdAt": "2024-06-21T00:00:00Z", - "destAmount": 10, - "destAssetCode": "XLM", - "destAssetIssuer": "", - "destAssetType": "AssetTypeAssetTypeNative", - "fromAddress": "GD73EG2IJJQQTCD33JKPKEGS76CJJ4TQ7NHDQYMS4D3Z5FBHPML6M66W", - "memo": "test", - "memoType": "MemoTypeMemoText", - "operationId": "1", - "operationType": "OperationTypePayment", - "srcAmount": 10, - "srcAssetCode": "XLM", - "srcAssetIssuer": "", - "srcAssetType": "AssetTypeAssetTypeNative", - "toAddress": "GCJ4LXZIQRSS5Z7YVIH5YLA7RXMYB64DQN3XMKWEBHUUAFXIXOL3GYVT", - "transactionHash": "c370ff20144e4c96b17432b8d14664c1", - "transactionId": "11" - } - ] - }` - assert.JSONEq(t, expectedRespBody, string(respBody)) - mockMetricsService.AssertExpectations(t) - }) - - t.Run("filter_address", func(t *testing.T) { - handler, mockMetricsService := setupTest() - r := setupRouter(handler) - - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Return().Times(2) - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.Anything).Return().Times(2) - - // Prepare request - req, err := http.NewRequest(http.MethodGet, "/payments?address=GASP7HTICNNA2U5RKMPRQELEUJFO7PBB3AKKRGTAG23QVG255ESPZW2L", nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 200 response - assert.Equal(t, http.StatusOK, rr.Code) - - resp := rr.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - expectedRespBody := `{ - "_links": { - "next": "", - "prev": "", - "self": "http://testing.com?address=GASP7HTICNNA2U5RKMPRQELEUJFO7PBB3AKKRGTAG23QVG255ESPZW2L&limit=50&sort=DESC" - }, - "payments": [ - { - "createdAt": "2024-06-22T00:00:00Z", - "destAmount": 20, - "destAssetCode": "USDC", - "destAssetIssuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - "destAssetType": "AssetTypeAssetTypeCreditAlphanum4", - "fromAddress": "GASP7HTICNNA2U5RKMPRQELEUJFO7PBB3AKKRGTAG23QVG255ESPZW2L", - "memo": "123", - "memoType": "MemoTypeMemoId", - "operationId": "2", - "operationType": "OperationTypePayment", - "srcAmount": 20, - "srcAssetCode": "USDC", - "srcAssetIssuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - "srcAssetType": "AssetTypeAssetTypeCreditAlphanum4", - "toAddress": "GDB4RW6QFWMGHGI6JTIKMGVUUQO7NNOLSFDMCOMUCCWHMAMFL3FH4Q2J", - "transactionHash": "30850d8fc7d1439782885103390cd975", - "transactionId": "22" - } - ] - }` - assert.JSONEq(t, expectedRespBody, string(respBody)) - mockMetricsService.AssertExpectations(t) - }) - - t.Run("invalid_params_1", func(t *testing.T) { - handler, mockMetricsService := setupTest() - r := setupRouter(handler) - - // Prepare request - req, err := http.NewRequest(http.MethodGet, "/payments?address=12345&limit=0&sort=BS", nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 400 response - assert.Equal(t, http.StatusBadRequest, rr.Code) - - resp := rr.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - expectedRespBody := `{ - "error": "Validation error.", - "extras": { - "limit": "Should be greater than 0", - "address": "Invalid public key provided", - "sort": "Unexpected value \"BS\". Expected one of the following values: ASC, DESC" - } - }` - assert.JSONEq(t, expectedRespBody, string(respBody)) - mockMetricsService.AssertExpectations(t) - }) - - t.Run("invalid_params_2", func(t *testing.T) { - handler, mockMetricsService := setupTest() - r := setupRouter(handler) - - // Prepare request - req, err := http.NewRequest(http.MethodGet, "/payments?limit=210", nil) - require.NoError(t, err) - - // Serve request - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // Assert 400 response - assert.Equal(t, http.StatusBadRequest, rr.Code) - - resp := rr.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - expectedRespBody := `{ - "error": "Validation error.", - "extras": { - "limit": "Should be less than or equal to 200" - } - }` - assert.JSONEq(t, expectedRespBody, string(respBody)) - mockMetricsService.AssertExpectations(t) - }) -} diff --git a/internal/serve/httphandler/request_params_validator.go b/internal/serve/httphandler/request_params_validator.go index 53f3ced6..33a27d3c 100644 --- a/internal/serve/httphandler/request_params_validator.go +++ b/internal/serve/httphandler/request_params_validator.go @@ -22,15 +22,6 @@ func DecodeJSONAndValidate(ctx context.Context, req *http.Request, reqBody inter return ValidateRequestParams(ctx, reqBody, appTracker) } -func DecodeQueryAndValidate(ctx context.Context, req *http.Request, reqQuery interface{}, appTracker apptracker.AppTracker) *httperror.ErrorResponse { - err := httpdecode.DecodeQuery(req, reqQuery) - if err != nil { - return httperror.BadRequest("Invalid request URL params.", nil) - } - - return ValidateRequestParams(ctx, reqQuery, appTracker) -} - func ValidateRequestParams(ctx context.Context, reqParams interface{}, appTracker apptracker.AppTracker) *httperror.ErrorResponse { val, err := validators.NewValidator() if err != nil { diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 30661aea..82ad9f91 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -72,13 +72,12 @@ type handlerDeps struct { NetworkPassphrase string // Services + AccountService services.AccountService FeeBumpService services.FeeBumpService - PaymentService services.PaymentService MetricsService metrics.MetricsService TransactionService txservices.TransactionService RPCService services.RPCService - // Error Tracker AppTracker apptracker.AppTracker } @@ -150,11 +149,6 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { return handlerDeps{}, fmt.Errorf("instantiating fee bump service: %w", err) } - paymentService, err := services.NewPaymentService(models, cfg.ServerBaseURL) - if err != nil { - return handlerDeps{}, fmt.Errorf("instantiating payment service: %w", err) - } - txService, err := txservices.NewTransactionService(txservices.TransactionServiceOptions{ DB: dbConnectionPool, DistributionAccountSignatureClient: cfg.DistributionAccountSignatureClient, @@ -188,7 +182,6 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { SupportedAssets: cfg.SupportedAssets, AccountService: accountService, FeeBumpService: feeBumpService, - PaymentService: paymentService, MetricsService: metricsService, RPCService: rpcService, AppTracker: cfg.AppTracker, @@ -250,15 +243,6 @@ func handler(deps handlerDeps) http.Handler { r.Handle("/query", srv) }) - r.Route("/payments", func(r chi.Router) { - handler := &httphandler.PaymentHandler{ - PaymentService: deps.PaymentService, - AppTracker: deps.AppTracker, - } - - r.Get("/", handler.GetPayments) - }) - r.Route("/tx", func(r chi.Router) { accountHandler := &httphandler.AccountHandler{ FeeBumpService: deps.FeeBumpService, diff --git a/internal/services/ingest.go b/internal/services/ingest.go index e791609d..a1ffdfba 100644 --- a/internal/services/ingest.go +++ b/internal/services/ingest.go @@ -29,21 +29,17 @@ import ( "github.com/stellar/wallet-backend/internal/metrics" "github.com/stellar/wallet-backend/internal/signing/store" cache "github.com/stellar/wallet-backend/internal/store" - txutils "github.com/stellar/wallet-backend/internal/transactions/utils" "github.com/stellar/wallet-backend/internal/utils" ) var ErrAlreadyInSync = errors.New("ingestion is already in sync") const ( - advisoryLockID = int(3747555612780983) - ingestHealthCheckMaxWaitTime = 90 * time.Second - getLedgersLimit = 50 // NOTE: cannot be larger than 200 - ledgerProcessorsCount = 16 - paymentPrometheusLabel = "payment" - pathPaymentStrictSendPrometheusLabel = "path_payment_strict_send" - pathPaymentStrictReceivePrometheusLabel = "path_payment_strict_receive" - totalIngestionPrometheusLabel = "total" + advisoryLockID = int(3747555612780983) + ingestHealthCheckMaxWaitTime = 90 * time.Second + getLedgersLimit = 50 // NOTE: cannot be larger than 200 + ledgerProcessorsCount = 16 + totalIngestionPrometheusLabel = "total" ) type IngestService interface { @@ -149,12 +145,6 @@ func (m *ingestService) DeprecatedRun(ctx context.Context, startLedger uint32, e continue } ingestHeartbeatChannel <- true - startTime := time.Now() - err = m.ingestPayments(ctx, ledgerTransactions) - if err != nil { - return fmt.Errorf("ingesting payments: %w", err) - } - m.metricsService.ObserveIngestionDuration(paymentPrometheusLabel, time.Since(startTime).Seconds()) // eagerly unlock channel accounts from txs err = m.unlockChannelAccountsDeprecated(ctx, ledgerTransactions) @@ -509,77 +499,6 @@ func (m *ingestService) GetLedgerTransactions(ledger int64) ([]entities.Transact return ledgerTransactions, nil } -func (m *ingestService) ingestPayments(ctx context.Context, ledgerTransactions []entities.Transaction) error { - err := db.RunInTransaction(ctx, m.models.Payments.DB, nil, func(dbTx db.Transaction) error { - paymentOpsIngested := 0 - pathPaymentStrictSendOpsIngested := 0 - pathPaymentStrictReceiveOpsIngested := 0 - for _, tx := range ledgerTransactions { - if tx.Status != entities.SuccessStatus { - continue - } - genericTx, err := txnbuild.TransactionFromXDR(tx.EnvelopeXDR) - if err != nil { - return fmt.Errorf("deserializing envelope xdr: %w", err) - } - txEnvelopeXDR, err := genericTx.ToXDR() - if err != nil { - return fmt.Errorf("generic transaction cannot be unpacked into a transaction") - } - txResultXDR, err := txutils.UnmarshallTransactionResultXDR(tx.ResultXDR) - if err != nil { - return fmt.Errorf("cannot unmarshal transacation result xdr: %s", err.Error()) - } - - txMemo, txMemoType := utils.Memo(txEnvelopeXDR.Memo(), tx.Hash) - if txMemo != nil { - *txMemo = utils.SanitizeUTF8(*txMemo) - } - for idx, op := range txEnvelopeXDR.Operations() { - opIdx := idx + 1 - payment := data.Payment{ - OperationID: utils.OperationID(int32(tx.Ledger), int32(tx.ApplicationOrder), int32(opIdx)), - OperationType: op.Body.Type.String(), - TransactionID: utils.TransactionID(int32(tx.Ledger), int32(tx.ApplicationOrder)), - TransactionHash: tx.Hash, - FromAddress: utils.SourceAccount(op, txEnvelopeXDR), - CreatedAt: time.Unix(int64(tx.CreatedAt), 0), - Memo: txMemo, - MemoType: txMemoType, - } - - switch op.Body.Type { - case xdr.OperationTypePayment: - paymentOpsIngested++ - fillPayment(&payment, op.Body) - case xdr.OperationTypePathPaymentStrictSend: - pathPaymentStrictSendOpsIngested++ - fillPathSend(&payment, op.Body, txResultXDR, opIdx) - case xdr.OperationTypePathPaymentStrictReceive: - pathPaymentStrictReceiveOpsIngested++ - fillPathReceive(&payment, op.Body, txResultXDR, opIdx) - default: - continue - } - - err = m.models.Payments.AddPayment(ctx, dbTx, payment) - if err != nil { - return fmt.Errorf("adding payment for ledger %d, tx %s (%d), operation %s (%d): %w", tx.Ledger, tx.Hash, tx.ApplicationOrder, payment.OperationID, opIdx, err) - } - } - } - m.metricsService.SetNumPaymentOpsIngestedPerLedger(paymentPrometheusLabel, paymentOpsIngested) - m.metricsService.SetNumPaymentOpsIngestedPerLedger(pathPaymentStrictSendPrometheusLabel, pathPaymentStrictSendOpsIngested) - m.metricsService.SetNumPaymentOpsIngestedPerLedger(pathPaymentStrictReceivePrometheusLabel, pathPaymentStrictReceiveOpsIngested) - return nil - }) - if err != nil { - return fmt.Errorf("ingesting payments: %w", err) - } - - return nil -} - // unlockChannelAccountsDeprecated unlocks the channel accounts associated with the given transaction XDRs. func (m *ingestService) unlockChannelAccountsDeprecated(ctx context.Context, ledgerTransactions []entities.Transaction) error { if len(ledgerTransactions) == 0 { @@ -658,46 +577,3 @@ func trackIngestServiceHealth(ctx context.Context, heartbeat chan any, tracker a } } } - -func fillPayment(payment *data.Payment, operation xdr.OperationBody) { - paymentOp := operation.MustPaymentOp() - payment.ToAddress = paymentOp.Destination.Address() - payment.SrcAssetCode = utils.AssetCode(paymentOp.Asset) - payment.SrcAssetIssuer = paymentOp.Asset.GetIssuer() - payment.SrcAssetType = paymentOp.Asset.Type.String() - payment.SrcAmount = int64(paymentOp.Amount) - payment.DestAssetCode = payment.SrcAssetCode - payment.DestAssetIssuer = payment.SrcAssetIssuer - payment.DestAssetType = payment.SrcAssetType - payment.DestAmount = payment.SrcAmount -} - -func fillPathSend(payment *data.Payment, operation xdr.OperationBody, txResult xdr.TransactionResult, operationIdx int) { - pathOp := operation.MustPathPaymentStrictSendOp() - result := utils.OperationResult(txResult, operationIdx).MustPathPaymentStrictSendResult() - payment.ToAddress = pathOp.Destination.Address() - payment.SrcAssetCode = utils.AssetCode(pathOp.SendAsset) - payment.SrcAssetIssuer = pathOp.SendAsset.GetIssuer() - payment.SrcAssetType = pathOp.SendAsset.Type.String() - payment.SrcAmount = int64(pathOp.SendAmount) - payment.DestAssetCode = utils.AssetCode(pathOp.DestAsset) - payment.DestAssetIssuer = pathOp.DestAsset.GetIssuer() - payment.DestAssetType = pathOp.DestAsset.Type.String() - if result != (xdr.PathPaymentStrictSendResult{}) { - payment.DestAmount = int64(result.DestAmount()) - } -} - -func fillPathReceive(payment *data.Payment, operation xdr.OperationBody, txResult xdr.TransactionResult, operationIdx int) { - pathOp := operation.MustPathPaymentStrictReceiveOp() - result := utils.OperationResult(txResult, operationIdx).MustPathPaymentStrictReceiveResult() - payment.ToAddress = pathOp.Destination.Address() - payment.SrcAssetCode = utils.AssetCode(pathOp.SendAsset) - payment.SrcAssetIssuer = pathOp.SendAsset.GetIssuer() - payment.SrcAssetType = pathOp.SendAsset.Type.String() - payment.SrcAmount = int64(result.SendAmount()) - payment.DestAssetCode = utils.AssetCode(pathOp.DestAsset) - payment.DestAssetIssuer = pathOp.DestAsset.GetIssuer() - payment.DestAssetType = pathOp.DestAsset.Type.String() - payment.DestAmount = int64(pathOp.DestAmount) -} diff --git a/internal/services/ingest_test.go b/internal/services/ingest_test.go index 9868c4c5..69dae3c1 100644 --- a/internal/services/ingest_test.go +++ b/internal/services/ingest_test.go @@ -202,240 +202,6 @@ func TestGetLedgerTransactions(t *testing.T) { }) } -func TestIngestPayments(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - mockMetricsService := metrics.NewMockMetricsService() - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - mockAppTracker := apptracker.MockAppTracker{} - mockRPCService := RPCServiceMock{} - mockRPCService.On("NetworkPassphrase").Return(network.TestNetworkPassphrase) - mockChAccStore := &store.ChannelAccountStoreMock{} - mockContractStore := &cache.MockTokenContractStore{} - ingestService, err := NewIngestService(models, "ingestionLedger", &mockAppTracker, &mockRPCService, mockChAccStore, mockContractStore, mockMetricsService) - require.NoError(t, err) - srcAccount := keypair.MustRandom().Address() - destAccount := keypair.MustRandom().Address() - usdIssuer := keypair.MustRandom().Address() - eurIssuer := keypair.MustRandom().Address() - - t.Run("test_op_payment", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Once() - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "ingest_payments", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "ingest_payments").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "payment", 1).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_send", 0).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_receive", 0).Once() - defer mockMetricsService.AssertExpectations(t) - - err = models.Account.Insert(context.Background(), srcAccount) - require.NoError(t, err) - paymentOp := txnbuild.Payment{ - SourceAccount: srcAccount, - Destination: destAccount, - Amount: "10", - Asset: txnbuild.NativeAsset{}, - } - var transaction *txnbuild.Transaction - transaction, err = txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: keypair.MustRandom().Address(), - }, - Operations: []txnbuild.Operation{&paymentOp}, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(10)}, - }) - require.NoError(t, err) - var txEnvXDR string - txEnvXDR, err = transaction.Base64() - require.NoError(t, err) - - ledgerTransaction := entities.Transaction{ - Status: entities.SuccessStatus, - Hash: "abcd", - ApplicationOrder: 1, - FeeBump: false, - EnvelopeXDR: txEnvXDR, - ResultXDR: "AAAAAAAAAMj////9AAAAAA==", - Ledger: 1, - } - - ledgerTransactions := []entities.Transaction{ledgerTransaction} - - err = ingestService.ingestPayments(context.Background(), ledgerTransactions) - require.NoError(t, err) - - var payments []data.Payment - payments, _, _, err = models.Payments.GetPaymentsPaginated(context.Background(), srcAccount, "", "", data.ASC, 1) - assert.NoError(t, err) - assert.Equal(t, payments[0].TransactionHash, "abcd") - }) - - t.Run("test_op_path_payment_send", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Times(2) - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "ingest_payments", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "ingest_payments").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "payment", 0).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_send", 1).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_receive", 0).Once() - defer mockMetricsService.AssertExpectations(t) - - // Use unique account for this test case - testSrcAccount := keypair.MustRandom().Address() - err = models.Account.Insert(context.Background(), testSrcAccount) - require.NoError(t, err) - - path := []txnbuild.Asset{ - txnbuild.CreditAsset{Code: "USD", Issuer: usdIssuer}, - txnbuild.CreditAsset{Code: "EUR", Issuer: eurIssuer}, - } - - pathPaymentOp := txnbuild.PathPaymentStrictSend{ - SourceAccount: testSrcAccount, - Destination: destAccount, - DestMin: "9", - SendAmount: "10", - SendAsset: txnbuild.NativeAsset{}, - DestAsset: txnbuild.NativeAsset{}, - Path: path, - } - var transaction *txnbuild.Transaction - transaction, err = txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: keypair.MustRandom().Address(), - }, - Operations: []txnbuild.Operation{&pathPaymentOp}, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(10)}, - }) - require.NoError(t, err) - - signer := keypair.MustRandom() - err = models.Account.Insert(context.Background(), signer.Address()) - require.NoError(t, err) - var signedTx *txnbuild.Transaction - signedTx, err = transaction.Sign(network.TestNetworkPassphrase, signer) - require.NoError(t, err) - - var txEnvXDR string - txEnvXDR, err = signedTx.Base64() - require.NoError(t, err) - - ledgerTransaction := entities.Transaction{ - Status: entities.SuccessStatus, - Hash: "efgh", - ApplicationOrder: 1, - FeeBump: false, - EnvelopeXDR: txEnvXDR, - ResultXDR: "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAANAAAAAAAAAAAAAAAAXF0V022EgeGIo6QNVbMVvHdxvvl2MfZcVZUJpfph+0QAAAAAAAAAAAX14QAAAAAA", - Ledger: 1, - } - - ledgerTransactions := []entities.Transaction{ledgerTransaction} - - err = ingestService.ingestPayments(context.Background(), ledgerTransactions) - require.NoError(t, err) - - var payments []data.Payment - payments, _, _, err = models.Payments.GetPaymentsPaginated(context.Background(), testSrcAccount, "", "", data.ASC, 1) - require.NoError(t, err) - require.NotEmpty(t, payments, "Expected at least one payment") - assert.Equal(t, payments[0].TransactionHash, ledgerTransaction.Hash) - assert.Equal(t, payments[0].SrcAmount, int64(100000000)) - assert.Equal(t, payments[0].SrcAssetType, xdr.AssetTypeAssetTypeNative.String()) - assert.Equal(t, payments[0].ToAddress, destAccount) - assert.Equal(t, payments[0].FromAddress, testSrcAccount) - assert.Equal(t, payments[0].SrcAssetCode, "XLM") - assert.Equal(t, payments[0].DestAssetCode, "XLM") - }) - - t.Run("test_op_path_payment_receive", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "accounts", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "INSERT", "accounts").Times(2) - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "ingest_payments", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "ingest_payments").Once() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "payment", 0).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_send", 0).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_receive", 1).Once() - defer mockMetricsService.AssertExpectations(t) - - // Use unique account for this test case - testSrcAccount := keypair.MustRandom().Address() - err = models.Account.Insert(context.Background(), testSrcAccount) - require.NoError(t, err) - - path := []txnbuild.Asset{ - txnbuild.CreditAsset{Code: "USD", Issuer: usdIssuer}, - txnbuild.CreditAsset{Code: "EUR", Issuer: eurIssuer}, - } - - pathPaymentOp := txnbuild.PathPaymentStrictReceive{ - SourceAccount: testSrcAccount, - Destination: destAccount, - SendMax: "11", - DestAmount: "10", - SendAsset: txnbuild.NativeAsset{}, - DestAsset: txnbuild.NativeAsset{}, - Path: path, - } - transaction, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ - SourceAccount: &txnbuild.SimpleAccount{ - AccountID: keypair.MustRandom().Address(), - }, - Operations: []txnbuild.Operation{&pathPaymentOp}, - Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(10)}, - }) - require.NoError(t, err) - - signer := keypair.MustRandom() - err = models.Account.Insert(context.Background(), signer.Address()) - require.NoError(t, err) - - signedTx, err := transaction.Sign(network.TestNetworkPassphrase, signer) - require.NoError(t, err) - - txEnvXDR, err := signedTx.Base64() - require.NoError(t, err) - - ledgerTransaction := entities.Transaction{ - Status: entities.SuccessStatus, - Hash: "efgh", - ApplicationOrder: 1, - FeeBump: false, - EnvelopeXDR: txEnvXDR, - ResultXDR: "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAjOiEfRh4kaFVQDu/CSTZLMtnyg0DbNowZ/G2nLES3KwAAAAAAAAAAAVdSoAAAAAA", - Ledger: 1, - } - - ledgerTransactions := []entities.Transaction{ledgerTransaction} - - err = ingestService.ingestPayments(context.Background(), ledgerTransactions) - require.NoError(t, err) - - payments, _, _, err := models.Payments.GetPaymentsPaginated(context.Background(), testSrcAccount, "", "", data.ASC, 1) - require.NoError(t, err) - require.NotEmpty(t, payments, "Expected at least one payment") - assert.Equal(t, payments[0].TransactionHash, ledgerTransaction.Hash) - assert.Equal(t, payments[0].SrcAssetType, xdr.AssetTypeAssetTypeNative.String()) - assert.Equal(t, payments[0].ToAddress, destAccount) - assert.Equal(t, payments[0].FromAddress, testSrcAccount) - assert.Equal(t, payments[0].SrcAssetCode, "XLM") - assert.Equal(t, payments[0].DestAssetCode, "XLM") - }) -} - func TestIngest_LatestSyncedLedgerBehindRPC(t *testing.T) { dbt := dbtest.Open(t) dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -449,18 +215,12 @@ func TestIngest_LatestSyncedLedgerBehindRPC(t *testing.T) { }() mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "ingest_payments", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("IncDBQuery", "INSERT", "ingest_payments").Once() mockMetricsService.On("ObserveDBQueryDuration", "INSERT", "ingest_store", mock.AnythingOfType("float64")).Once() mockMetricsService.On("IncDBQuery", "INSERT", "ingest_store").Once() mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_store", mock.AnythingOfType("float64")).Once() mockMetricsService.On("IncDBQuery", "SELECT", "ingest_store").Once() mockMetricsService.On("SetLatestLedgerIngested", float64(50)).Once() - mockMetricsService.On("ObserveIngestionDuration", "payment", mock.AnythingOfType("float64")).Once() mockMetricsService.On("ObserveIngestionDuration", "total", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "payment", 1).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_send", 0).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_receive", 0).Once() defer mockMetricsService.AssertExpectations(t) models, err := data.NewModels(dbConnectionPool, mockMetricsService) @@ -572,11 +332,7 @@ func TestIngest_LatestSyncedLedgerAheadOfRPC(t *testing.T) { mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_store", mock.AnythingOfType("float64")).Once() mockMetricsService.On("IncDBQuery", "SELECT", "ingest_store").Once() mockMetricsService.On("SetLatestLedgerIngested", float64(100)).Once() - mockMetricsService.On("ObserveIngestionDuration", "payment", mock.AnythingOfType("float64")).Once() mockMetricsService.On("ObserveIngestionDuration", "total", mock.AnythingOfType("float64")).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "payment", 0).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_send", 0).Once() - mockMetricsService.On("SetNumPaymentOpsIngestedPerLedger", "path_payment_strict_receive", 0).Once() defer mockMetricsService.AssertExpectations(t) heartbeatChan := make(chan entities.RPCGetHealthResult, 1) diff --git a/internal/services/payment_service.go b/internal/services/payment_service.go deleted file mode 100644 index 0243ce52..00000000 --- a/internal/services/payment_service.go +++ /dev/null @@ -1,99 +0,0 @@ -package services - -import ( - "context" - "errors" - "fmt" - "net/url" - "strconv" - - "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/entities" -) - -type PaymentService interface { - GetPaymentsPaginated(ctx context.Context, address string, beforeID, afterID string, sort data.SortOrder, limit int) ([]data.Payment, entities.Pagination, error) -} - -var _ PaymentService = (*paymentService)(nil) - -type paymentService struct { - models *data.Models - serverBaseURL string -} - -func NewPaymentService(models *data.Models, serverBaseURL string) (*paymentService, error) { - if models == nil { - return nil, errors.New("models cannot be nil") - } - - if _, err := url.ParseRequestURI(serverBaseURL); err != nil { - return nil, fmt.Errorf("invalid URL %s: %w", serverBaseURL, err) - } - - return &paymentService{ - models: models, - serverBaseURL: serverBaseURL, - }, nil -} - -func (s *paymentService) GetPaymentsPaginated(ctx context.Context, address string, beforeID, afterID string, sort data.SortOrder, limit int) ([]data.Payment, entities.Pagination, error) { - payments, prevExists, nextExists, err := s.models.Payments.GetPaymentsPaginated(ctx, address, beforeID, afterID, sort, limit) - if err != nil { - return nil, entities.Pagination{}, fmt.Errorf("getting payments: %w", err) - } - - self, prev, next := "", "", "" - self, err = buildURL(s.serverBaseURL, address, beforeID, afterID, sort, limit) - if err != nil { - return nil, entities.Pagination{}, fmt.Errorf("building self link: %w", err) - } - - if prevExists { - firstElementID := data.FirstPaymentOperationID(payments) - prev, err = buildURL(s.serverBaseURL, address, firstElementID, "", sort, limit) - if err != nil { - return nil, entities.Pagination{}, fmt.Errorf("building prev link: %w", err) - } - } - - if nextExists { - lastElementID := data.LastPaymentOperationID(payments) - next, err = buildURL(s.serverBaseURL, address, "", lastElementID, sort, limit) - if err != nil { - return nil, entities.Pagination{}, fmt.Errorf("building next link: %w", err) - } - } - - pagination := entities.Pagination{ - Links: entities.PaginationLinks{ - Self: self, - Prev: prev, - Next: next, - }, - } - return payments, pagination, nil -} - -func buildURL(baseURL string, address string, beforeID, afterID string, sort data.SortOrder, limit int) (string, error) { - url, err := url.ParseRequestURI(baseURL) - if err != nil { - return "", fmt.Errorf("parsing base URL: %s: %w", baseURL, err) - } - - values := url.Query() - values.Add("sort", string(sort)) - values.Add("limit", strconv.Itoa(limit)) - if address != "" { - values.Add("address", address) - } - if beforeID != "" { - values.Add("beforeId", beforeID) - } - if afterID != "" { - values.Add("afterId", afterID) - } - url.RawQuery = values.Encode() - - return url.String(), nil -} diff --git a/internal/services/payment_service_test.go b/internal/services/payment_service_test.go deleted file mode 100644 index 4d44d773..00000000 --- a/internal/services/payment_service_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package services - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stellar/go/xdr" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/stellar/wallet-backend/internal/data" - "github.com/stellar/wallet-backend/internal/db" - "github.com/stellar/wallet-backend/internal/db/dbtest" - "github.com/stellar/wallet-backend/internal/entities" - "github.com/stellar/wallet-backend/internal/metrics" -) - -func TestPaymentServiceGetPaymentsPaginated(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - mockMetricsService := metrics.NewMockMetricsService() - - models, err := data.NewModels(dbConnectionPool, mockMetricsService) - require.NoError(t, err) - service, err := NewPaymentService(models, "http://testing.com") - require.NoError(t, err) - ctx := context.Background() - - dbPayments := []data.Payment{ - {OperationID: "1", OperationType: xdr.OperationTypePayment.String(), TransactionID: "11", TransactionHash: "c370ff20144e4c96b17432b8d14664c1", FromAddress: "GAZ37ZO4TU3H", ToAddress: "GDD2HQO6IOFT", SrcAssetCode: "XLM", SrcAssetIssuer: "", SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), SrcAmount: 10, DestAssetCode: "XLM", DestAssetIssuer: "", DestAssetType: xdr.AssetTypeAssetTypeNative.String(), DestAmount: 10, CreatedAt: time.Date(2024, 6, 21, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "2", OperationType: xdr.OperationTypePayment.String(), TransactionID: "22", TransactionHash: "30850d8fc7d1439782885103390cd975", FromAddress: "GBZ5Q56JKHJQ", ToAddress: "GASV72SENBSY", SrcAssetCode: "XLM", SrcAssetIssuer: "", SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), SrcAmount: 20, DestAssetCode: "XLM", DestAssetIssuer: "", DestAssetType: xdr.AssetTypeAssetTypeNative.String(), DestAmount: 20, CreatedAt: time.Date(2024, 6, 22, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "3", OperationType: xdr.OperationTypePayment.String(), TransactionID: "33", TransactionHash: "d9521ed7057d4d1e9b9dd22ab515cbf1", FromAddress: "GAYFAYPOECBT", ToAddress: "GDWDPNMALNIT", SrcAssetCode: "XLM", SrcAssetIssuer: "", SrcAssetType: xdr.AssetTypeAssetTypeNative.String(), SrcAmount: 30, DestAssetCode: "XLM", DestAssetIssuer: "", DestAssetType: xdr.AssetTypeAssetTypeNative.String(), DestAmount: 30, CreatedAt: time.Date(2024, 6, 23, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "4", OperationType: xdr.OperationTypePayment.String(), TransactionID: "44", TransactionHash: "2af98496a86741c6a6814200e06027fd", FromAddress: "GACKTNR2QQXU", ToAddress: "GBZ5KUZHAAVI", SrcAssetCode: "USDC", SrcAssetIssuer: "GAHLU7PDIQMZ", SrcAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), SrcAmount: 40, DestAssetCode: "USDC", DestAssetIssuer: "GAHLU7PDIQMZ", DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), DestAmount: 40, CreatedAt: time.Date(2024, 6, 24, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - {OperationID: "5", OperationType: xdr.OperationTypePayment.String(), TransactionID: "55", TransactionHash: "edfab36f9f104c4fb74b549de44cfbcc", FromAddress: "GA4CMYJEC5W5", ToAddress: "GAZ37ZO4TU3H", SrcAssetCode: "USDC", SrcAssetIssuer: "GAHLU7PDIQMZ", SrcAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), SrcAmount: 50, DestAssetCode: "USDC", DestAssetIssuer: "GAHLU7PDIQMZ", DestAssetType: xdr.AssetTypeAssetTypeCreditAlphanum4.String(), DestAmount: 50, CreatedAt: time.Date(2024, 6, 25, 0, 0, 0, 0, time.UTC), Memo: nil, MemoType: xdr.MemoTypeMemoNone.String()}, - } - data.InsertTestPayments(t, ctx, dbPayments, dbConnectionPool) - - t.Run("page_1", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - defer mockMetricsService.AssertExpectations(t) - - payments, pagination, err := service.GetPaymentsPaginated(ctx, "", "", "", data.DESC, 2) - require.NoError(t, err) - - assert.Equal(t, []data.Payment{ - dbPayments[4], - dbPayments[3], - }, payments) - assert.Equal(t, entities.Pagination{ - Links: entities.PaginationLinks{ - Self: "http://testing.com?limit=2&sort=DESC", - Prev: "", - Next: fmt.Sprintf("http://testing.com?afterId=%s&limit=2&sort=DESC", dbPayments[3].OperationID), - }, - }, pagination) - }) - - t.Run("page_2_after", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - defer mockMetricsService.AssertExpectations(t) - - payments, pagination, err := service.GetPaymentsPaginated(ctx, "", "", dbPayments[3].OperationID, data.DESC, 2) - require.NoError(t, err) - - assert.Equal(t, []data.Payment{ - dbPayments[2], - dbPayments[1], - }, payments) - assert.Equal(t, entities.Pagination{ - Links: entities.PaginationLinks{ - Self: fmt.Sprintf("http://testing.com?afterId=%s&limit=2&sort=DESC", dbPayments[3].OperationID), - Prev: fmt.Sprintf("http://testing.com?beforeId=%s&limit=2&sort=DESC", dbPayments[2].OperationID), - Next: fmt.Sprintf("http://testing.com?afterId=%s&limit=2&sort=DESC", dbPayments[1].OperationID), - }, - }, pagination) - }) - - t.Run("page_3_after", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - defer mockMetricsService.AssertExpectations(t) - - payments, pagination, err := service.GetPaymentsPaginated(ctx, "", "", dbPayments[1].OperationID, data.DESC, 2) - require.NoError(t, err) - - assert.Equal(t, []data.Payment{ - dbPayments[0], - }, payments) - assert.Equal(t, entities.Pagination{ - Links: entities.PaginationLinks{ - Self: fmt.Sprintf("http://testing.com?afterId=%s&limit=2&sort=DESC", dbPayments[1].OperationID), - Prev: fmt.Sprintf("http://testing.com?beforeId=%s&limit=2&sort=DESC", dbPayments[0].OperationID), - Next: "", - }, - }, pagination) - }) - - t.Run("page_2_before", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - defer mockMetricsService.AssertExpectations(t) - - payments, pagination, err := service.GetPaymentsPaginated(ctx, "", dbPayments[0].OperationID, "", data.DESC, 2) - require.NoError(t, err) - - assert.Equal(t, []data.Payment{ - dbPayments[2], - dbPayments[1], - }, payments) - assert.Equal(t, entities.Pagination{ - Links: entities.PaginationLinks{ - Self: fmt.Sprintf("http://testing.com?beforeId=%s&limit=2&sort=DESC", dbPayments[0].OperationID), - Prev: fmt.Sprintf("http://testing.com?beforeId=%s&limit=2&sort=DESC", dbPayments[2].OperationID), - Next: fmt.Sprintf("http://testing.com?afterId=%s&limit=2&sort=DESC", dbPayments[1].OperationID), - }, - }, pagination) - }) - - t.Run("page_1_before", func(t *testing.T) { - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "ingest_payments", mock.AnythingOfType("float64")).Times(2) - mockMetricsService.On("IncDBQuery", "SELECT", "ingest_payments").Times(2) - defer mockMetricsService.AssertExpectations(t) - - payments, pagination, err := service.GetPaymentsPaginated(ctx, "", dbPayments[2].OperationID, "", data.DESC, 2) - require.NoError(t, err) - - assert.Equal(t, []data.Payment{ - dbPayments[4], - dbPayments[3], - }, payments) - assert.Equal(t, entities.Pagination{ - Links: entities.PaginationLinks{ - Self: fmt.Sprintf("http://testing.com?beforeId=%s&limit=2&sort=DESC", dbPayments[2].OperationID), - Prev: "", - Next: fmt.Sprintf("http://testing.com?afterId=%s&limit=2&sort=DESC", dbPayments[3].OperationID), - }, - }, pagination) - }) -} diff --git a/internal/transactions/utils/operation_builder.go b/internal/transactions/utils/operation_builder.go index 8ef47496..7225249c 100644 --- a/internal/transactions/utils/operation_builder.go +++ b/internal/transactions/utils/operation_builder.go @@ -1,13 +1,9 @@ package utils import ( - "bytes" - "encoding/base64" "fmt" - xdr3 "github.com/stellar/go-xdr/xdr3" "github.com/stellar/go/txnbuild" - "github.com/stellar/go/xdr" "github.com/stellar/wallet-backend/pkg/utils" ) @@ -34,16 +30,3 @@ func BuildOperations(txOpXDRs []string) ([]txnbuild.Operation, error) { return operations, nil } - -func UnmarshallTransactionResultXDR(resultXDR string) (xdr.TransactionResult, error) { - decodedBytes, err := base64.StdEncoding.DecodeString(resultXDR) - if err != nil { - return xdr.TransactionResult{}, fmt.Errorf("unable to decode errorResultXDR %s: %w", resultXDR, err) - } - var txResultXDR xdr.TransactionResult - _, err = xdr3.Unmarshal(bytes.NewReader(decodedBytes), &txResultXDR) - if err != nil { - return xdr.TransactionResult{}, fmt.Errorf("unable to unmarshal errorResultXDR %s: %w", resultXDR, err) - } - return txResultXDR, nil -} diff --git a/internal/utils/ingestion_utils.go b/internal/utils/ingestion_utils.go index 7f8c1c3e..939ae186 100644 --- a/internal/utils/ingestion_utils.go +++ b/internal/utils/ingestion_utils.go @@ -3,24 +3,9 @@ package utils import ( "strconv" - "github.com/stellar/go/toid" "github.com/stellar/go/xdr" ) -func OperationID(ledgerNumber, txNumber, opNumber int32) string { - return toid.New(ledgerNumber, txNumber, opNumber).String() -} - -func OperationResult(txResult xdr.TransactionResult, opNumber int) *xdr.OperationResultTr { - results, _ := txResult.OperationResults() - tr := results[opNumber-1].MustTr() - return &tr -} - -func TransactionID(ledgerNumber, txNumber int32) string { - return toid.New(ledgerNumber, txNumber, 0).String() -} - // Memo returns the memo value parsed to string and its type. func Memo(memo xdr.Memo, txHash string) (*string, string) { memoType := memo.Type @@ -56,18 +41,3 @@ func Memo(memo xdr.Memo, txHash string) (*string, string) { // sentry.CaptureException(fmt.Errorf("failed to parse memo for type %q and transaction %s", memoType.String(), txHash)) return nil, memoType.String() } - -func SourceAccount(op xdr.Operation, txEnvelope xdr.TransactionEnvelope) string { - account := op.SourceAccount - if account != nil { - return account.ToAccountId().Address() - } - return txEnvelope.SourceAccount().ToAccountId().Address() -} - -func AssetCode(asset xdr.Asset) string { - if asset.Type == xdr.AssetTypeAssetTypeNative { - return "XLM" - } - return SanitizeUTF8(asset.GetCode()) -} diff --git a/openapi/main.yaml b/openapi/main.yaml index e85f2c3c..ae1333b9 100644 --- a/openapi/main.yaml +++ b/openapi/main.yaml @@ -15,9 +15,7 @@ servers: tags: - name: Account Registration x-displayName: Account Registration - description: Clients of the Wallet Backed API can register and de-register stellar accounts whose payments they want to track, and whose transactions they want wrapped in fee bump transactions. - - name: Payments - description: Endpoint to retrieve paginated incoming and outgoing payments of a stellar account. + description: Clients of the Wallet Backed API can register and de-register stellar accounts whose transactions they want wrapped in fee bump transactions. - name: Transactions description: Endpoints to build transactions. paths: @@ -115,168 +113,6 @@ paths: example: status: 500 error: An error occurred while processing this request. - /payments: - get: - tags: - - Payments - summary: Get paginated list of payments - description: Get a paginated list of incoming and outgoing payments of a stellar account - operationId: GetPayments - parameters: - - name: address - in: query - description: The stellar address whose payments we want to fetch. - required: true - schema: - type: string - - name: afterId - in: query - description: The starting operation id of the list of payments - required: false - schema: - type: string - - name: beforeId - in: query - description: The ending operation id of the list of payments. - required: false - schema: - type: string - - name: sort - in: query - description: sort order. - required: false - schema: - type: string - enum: - - ASC - - DESC - default: DESC - - name: limit - in: query - description: number of payments to return. Default is 50, maximum is 200 - required: false - schema: - type: integer - default: 50 - maximum: 200 - minimum: 1 - responses: - '200': - description: Returns a list of payments and urls to fetch further payments - content: - application/json: - schema: - type: object - properties: - _links: - type: object - properties: - self: - type: string - next: - type: string - prev: - type: string - payments: - type: array - description: list of payments - items: - type: object - properties: - createdAt: - type: string - destAmount: - type: integer - destAssetCode: - type: string - destAssetIssuer: - type: string - fromAddress: - type: string - memo: - type: string - operationId: - type: string - operationType: - type: string - srcAmount: - type: integer - srcAssetCode: - type: string - srcAssetIssuer: - type: string - toAddress: - type: string - transactionHash: - type: string - transactionId: - type: string - example: - _links: - self: https://wallet-backend.com/payments?limit=50&sort=DESC - next: https://wallet-backend.com/payments?afterId=123&limit=50&sort=DESC - prev: https://wallet-backend.com/payments?afterId=123&limit=50&sort=DESC - payments: - - createdAt: 2024-06-22T00:00:00Z - destAmount: 20 - destAssetCode: XLM - destAssetIssuer: "" - fromAddress: GASP7HTICNNA2U5RKMPRQELEUJFO7PBB3AKKRGTAG23QVG255ESPZW2L - memo: null - operationId: 2 - operationType: OperationTypePayment - srcAmount: 20 - srcAssetCode: XLM - srcAssetIssuer: "" - toAddress: GDB4RW6QFWMGHGI6JTIKMGVUUQO7NNOLSFDMCOMUCCWHMAMFL3FH4Q2J - transactionHash: 30850d8fc7d1439782885103390cd975 - transactionId: 22 - - createdAt: 2024-06-23T00:00:00Z - destAmount: 22 - destAssetCode: XLM - destAssetIssuer: "" - fromAddress: GASP7HTICNNA2U5RKMPRQELEUJFO7PBB3AKKRGTAG23QVG255ESPZW2L - memo: null - operationId: 2 - operationType: OperationTypePayment - srcAmount: 20 - srcAssetCode: XLM - srcAssetIssuer: "" - toAddress: GDB4RW6QFWMGHGI6JTIKMGVUUQO7NNOLSFDMCOMUCCWHMAMFL3FH4Q2J - transactionHash: 30850d8fc7d1439782885103390cd975 - transactionId: 23 - '400': - description: Bad Request - content: - application/json: - schema: - type: object - properties: - status: - type: string - error: - type: string - description: Details about the error - example: - status: 400 - error: Invalid request URL params. - '500': - description: Internal Server Error - content: - application/json: - schema: - type: object - properties: - status: - type: string - error: - type: string - example: - status: 500 - error: An error occurred while processing this request. - example: - status: 500 - error: An error occurred while processing this request. /tx/create-fee-bump: post: tags: From b5f73b4fd05fc10e614fb5457999249b511609ca Mon Sep 17 00:00:00 2001 From: Jake Urban <10968980+JakeUrban@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:58:19 -0700 Subject: [PATCH 10/16] Make JWT authentication optional (#291) * make JWT auth optional * update custom env var parser * remove start-ledger * update custom value setter test to expect passing on empty list --- cmd/integrationtests.go | 16 ++++++++++------ cmd/serve.go | 4 ++-- cmd/utils/custom_set_value.go | 11 ++++++++++- cmd/utils/custom_set_value_test.go | 4 ++-- internal/serve/serve.go | 18 ++++++++++++------ pkg/wbclient/client.go | 8 +++++--- 6 files changed, 41 insertions(+), 20 deletions(-) diff --git a/cmd/integrationtests.go b/cmd/integrationtests.go index f2670b6e..3f97d7f0 100644 --- a/cmd/integrationtests.go +++ b/cmd/integrationtests.go @@ -49,11 +49,11 @@ func (c *integrationTestsCmd) Command() *cobra.Command { utils.RPCURLOption(&cfg.RPCURL), { Name: "client-auth-private-key", - Usage: "The private key used to authenticate the client when making HTTP requests to the wallet-backend.", + Usage: "The private key used to authenticate the client when making HTTP requests to the wallet-backend. If not provided, authentication is disabled.", OptType: types.String, CustomSetValue: utils.SetConfigOptionStellarPrivateKey, ConfigKey: &cfg.ClientAuthPrivateKey, - Required: true, + Required: false, }, { Name: "primary-source-account-private-key", @@ -114,11 +114,15 @@ func (c *integrationTestsCmd) Command() *cobra.Command { return fmt.Errorf("instantiating rpc service: %w", err) } - jwtTokenGenerator, err := auth.NewJWTTokenGenerator(cfg.ClientAuthPrivateKey) - if err != nil { - return fmt.Errorf("instantiating jwt token generator: %w", err) + var requestSigner auth.HTTPRequestSigner + if cfg.ClientAuthPrivateKey != "" { + jwtTokenGenerator, err := auth.NewJWTTokenGenerator(cfg.ClientAuthPrivateKey) + if err != nil { + return fmt.Errorf("instantiating jwt token generator: %w", err) + } + requestSigner = auth.NewHTTPRequestSigner(jwtTokenGenerator) } - wbClient := wbclient.NewClient(cfg.ServerBaseURL, auth.NewHTTPRequestSigner(jwtTokenGenerator)) + wbClient := wbclient.NewClient(cfg.ServerBaseURL, requestSigner) primaryKP, err := keypair.ParseFull(cfg.PrimarySourceAccountPrivateKey) if err != nil { diff --git a/cmd/serve.go b/cmd/serve.go index 855ddaaf..22b4478d 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -44,11 +44,11 @@ func (c *serveCmd) Command() *cobra.Command { }, { Name: "client-auth-public-keys", - Usage: "A comma-separated list of public keys whose private keys are authorized to sign the payloads when making HTTP requests to this server.", + Usage: "A comma-separated list of public keys whose private keys are authorized to sign the payloads when making HTTP requests to this server. If not provided or empty, authentication is disabled.", OptType: types.String, CustomSetValue: utils.SetConfigOptionStellarPublicKeyList, ConfigKey: &cfg.ClientAuthPublicKeys, - Required: true, + Required: false, }, { Name: "supported-assets", diff --git a/cmd/utils/custom_set_value.go b/cmd/utils/custom_set_value.go index 5f64ef3c..e3b9dade 100644 --- a/cmd/utils/custom_set_value.go +++ b/cmd/utils/custom_set_value.go @@ -66,7 +66,16 @@ func SetConfigOptionStellarPublicKeyList(co *config.ConfigOption) error { publicKeysStr := viper.GetString(co.Name) publicKeysStr = strings.TrimSpace(publicKeysStr) if publicKeysStr == "" { - return fmt.Errorf("no public keys provided in %s", co.Name) + if co.Required { + return fmt.Errorf("no public keys provided in %s", co.Name) + } + // If not required and empty, set to empty slice + key, ok := co.ConfigKey.(*[]string) + if !ok { + return unexpectedTypeError(key, co) + } + *key = []string{} + return nil } publicKeysStr = strings.ReplaceAll(publicKeysStr, " ", "") publicKeys := strings.Split(publicKeysStr, ",") diff --git a/cmd/utils/custom_set_value_test.go b/cmd/utils/custom_set_value_test.go index 0d7b0253..21145180 100644 --- a/cmd/utils/custom_set_value_test.go +++ b/cmd/utils/custom_set_value_test.go @@ -143,8 +143,8 @@ func TestSetConfigOptionStellarPublicKeyList(t *testing.T) { testCases := []customSetterTestCase[[]string]{ { - name: "🔴returns_an_error_if_the_public_keys_are_empty", - wantErrContains: "no public keys provided in client-auth-public-keys", + name: "🟢allows_empty_public_keys_when_not_required", + wantResult: []string{}, }, { name: "🔴returns_an_error_if_the_public_key_is_invalid", diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 82ad9f91..eced605d 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -119,11 +119,14 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { return handlerDeps{}, fmt.Errorf("creating models for Serve: %w", err) } - jwtTokenParser, err := auth.NewMultiJWTTokenParser(time.Second*5, cfg.ClientAuthPublicKeys...) - if err != nil { - return handlerDeps{}, fmt.Errorf("instantiating multi JWT token parser: %w", err) + var requestAuthVerifier auth.HTTPRequestVerifier + if len(cfg.ClientAuthPublicKeys) > 0 { + jwtTokenParser, err := auth.NewMultiJWTTokenParser(time.Second*5, cfg.ClientAuthPublicKeys...) + if err != nil { + return handlerDeps{}, fmt.Errorf("instantiating multi JWT token parser: %w", err) + } + requestAuthVerifier = auth.NewHTTPRequestVerifier(jwtTokenParser, auth.DefaultMaxBodySize) } - requestAuthVerifier := auth.NewHTTPRequestVerifier(jwtTokenParser, auth.DefaultMaxBodySize) httpClient := http.Client{Timeout: 30 * time.Second} rpcService, err := services.NewRPCService(cfg.RPCURL, cfg.NetworkPassphrase, &httpClient, metricsService) @@ -216,9 +219,12 @@ func handler(deps handlerDeps) http.Handler { }.GetHealth) mux.Get("/api-metrics", promhttp.HandlerFor(deps.MetricsService.GetRegistry(), promhttp.HandlerOpts{}).ServeHTTP) - // Authenticated routes + // API routes (conditionally authenticated) mux.Group(func(r chi.Router) { - r.Use(middleware.AuthenticationMiddleware(deps.RequestAuthVerifier, deps.AppTracker, deps.MetricsService)) + // Apply authentication middleware only if auth verifier is configured + if deps.RequestAuthVerifier != nil { + r.Use(middleware.AuthenticationMiddleware(deps.RequestAuthVerifier, deps.AppTracker, deps.MetricsService)) + } r.Route("/graphql", func(r chi.Router) { r.Use(middleware.DataloaderMiddleware(deps.Models)) diff --git a/pkg/wbclient/client.go b/pkg/wbclient/client.go index 02bfb0b9..a1c39851 100644 --- a/pkg/wbclient/client.go +++ b/pkg/wbclient/client.go @@ -213,9 +213,11 @@ func (c *Client) request(ctx context.Context, method, path string, bodyObj any) return nil, fmt.Errorf("creating request: %w", err) } - err = c.RequestSigner.SignHTTPRequest(request, 5*time.Second) - if err != nil { - return nil, fmt.Errorf("signing request: %w", err) + if c.RequestSigner != nil { + err = c.RequestSigner.SignHTTPRequest(request, 5*time.Second) + if err != nil { + return nil, fmt.Errorf("signing request: %w", err) + } } request.Header.Set("Content-Type", "application/json") From e31c712c39af673c65ef86472fde8a3296f8a101 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Mon, 18 Aug 2025 15:26:25 -0400 Subject: [PATCH 11/16] Paginate through an account's transactions and operations (#279) --- internal/data/accounts_test.go | 4 +- internal/data/operations.go | 40 +- internal/data/operations_test.go | 14 +- internal/data/query_utils.go | 102 + internal/data/transactions.go | 36 +- internal/data/transactions_test.go | 15 +- internal/indexer/types/types.go | 8 +- internal/serve/graphql/dataloaders/loaders.go | 2 - .../graphql/dataloaders/operation_loaders.go | 25 - .../dataloaders/transaction_loaders.go | 25 - internal/serve/graphql/generated/generated.go | 1666 ++++++++++++++--- .../serve/graphql/generated/models_gen.go | 27 + .../graphql/resolvers/account.resolvers.go | 77 +- .../resolvers/account_resolvers_test.go | 227 ++- internal/serve/graphql/resolvers/utils.go | 180 ++ .../serve/graphql/schema/account.graphqls | 6 +- .../serve/graphql/schema/pagination.graphqls | 26 + 17 files changed, 2062 insertions(+), 418 deletions(-) create mode 100644 internal/data/query_utils.go create mode 100644 internal/serve/graphql/schema/pagination.graphqls diff --git a/internal/data/accounts_test.go b/internal/data/accounts_test.go index 046f8e9f..00c257ec 100644 --- a/internal/data/accounts_test.go +++ b/internal/data/accounts_test.go @@ -91,8 +91,8 @@ func TestAccountModelDelete(t *testing.T) { ctx := context.Background() address := keypair.MustRandom().Address() - result, err := m.DB.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) - require.NoError(t, err) + result, insertErr := m.DB.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, insertErr) rowAffected, err := result.RowsAffected() require.NoError(t, err) require.Equal(t, int64(1), rowAffected) diff --git a/internal/data/operations.go b/internal/data/operations.go index 8f7e7874..b5698a26 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -59,30 +59,34 @@ func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []stri return operations, nil } -// BatchGetByAccountAddresses gets the operations that are associated with the given account addresses. -func (m *OperationModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string, columns string) ([]*types.OperationWithAccountID, error) { - if columns == "" { - columns = "operations.*" - } - query := fmt.Sprintf(` - SELECT %s, operations_accounts.account_id - FROM operations - INNER JOIN operations_accounts ON operations.id = operations_accounts.operation_id - WHERE operations_accounts.account_id = ANY($1) - ORDER BY operations.id DESC - `, columns) - - var operationsWithAccounts []*types.OperationWithAccountID +// BatchGetByAccountAddress gets the operations that are associated with a single account address. +func (m *OperationModel) BatchGetByAccountAddress(ctx context.Context, accountAddress string, columns string, limit *int32, cursor *int64, orderBy SortOrder) ([]*types.OperationWithCursor, error) { + // Prepare columns, ensuring operations.id is always included + columns = prepareColumnsWithID(columns, "operations", "id") + + // Build paginated query using shared utility + query, args := buildGetByAccountAddressQuery(paginatedQueryConfig{ + TableName: "operations", + CursorColumn: "id", + JoinTable: "operations_accounts", + JoinCondition: "operations_accounts.operation_id = operations.id", + Columns: columns, + AccountAddress: accountAddress, + Limit: limit, + Cursor: cursor, + OrderBy: orderBy, + }) + + var operations []*types.OperationWithCursor start := time.Now() - err := m.DB.SelectContext(ctx, &operationsWithAccounts, query, pq.Array(accountAddresses)) + err := m.DB.SelectContext(ctx, &operations, query, args...) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) if err != nil { - return nil, fmt.Errorf("getting operations by account addresses: %w", err) + return nil, fmt.Errorf("getting operations by account address: %w", err) } m.MetricsService.IncDBQuery("SELECT", "operations") - - return operationsWithAccounts, nil + return operations, nil } // BatchGetByStateChangeIDs gets the operations that are associated with the given state change IDs. diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index 4d719e15..f1522cb2 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -378,17 +378,11 @@ func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { require.NoError(t, err) // Test BatchGetByAccount - operations, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}, "") + operations, err := m.BatchGetByAccountAddress(ctx, address1, "", nil, nil, "ASC") require.NoError(t, err) - assert.Len(t, operations, 3) - - // Verify operations are for correct accounts - accountsFound := make(map[string]int) - for _, op := range operations { - accountsFound[op.AccountID]++ - } - assert.Equal(t, 2, accountsFound[address1]) - assert.Equal(t, 1, accountsFound[address2]) + assert.Len(t, operations, 2) + assert.Equal(t, int64(1), operations[0].Operation.ID) + assert.Equal(t, int64(2), operations[1].Operation.ID) } func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { diff --git a/internal/data/query_utils.go b/internal/data/query_utils.go new file mode 100644 index 00000000..27981c69 --- /dev/null +++ b/internal/data/query_utils.go @@ -0,0 +1,102 @@ +package data + +import ( + "fmt" + "strings" +) + +type SortOrder string + +const ( + ASC SortOrder = "ASC" + DESC SortOrder = "DESC" +) + +// PaginatedQueryConfig contains configuration for building paginated queries +type paginatedQueryConfig struct { + // Base table configuration + TableName string // e.g., "operations" or "transactions" + CursorColumn string // e.g., "id" or "to_id" + + // Join configuration + JoinTable string // e.g., "operations_accounts" or "transactions_accounts" + JoinCondition string // e.g., "operations_accounts.operation_id = operations.id" + + // Query parameters + Columns string + AccountAddress string + Limit *int32 + Cursor *int64 + OrderBy SortOrder +} + +// BuildPaginatedQuery constructs a paginated SQL query with cursor-based pagination +func buildGetByAccountAddressQuery(config paginatedQueryConfig) (string, []any) { + var queryBuilder strings.Builder + var args []any + argIndex := 1 + + // Base query with join + queryBuilder.WriteString(fmt.Sprintf(` + SELECT %s, %s.%s as cursor + FROM %s + INNER JOIN %s + ON %s + WHERE %s.account_id = $%d`, + config.Columns, + config.TableName, + config.CursorColumn, + config.TableName, + config.JoinTable, + config.JoinCondition, + config.JoinTable, + argIndex)) + args = append(args, config.AccountAddress) + argIndex++ + + // Add cursor condition if provided + if config.Cursor != nil { + // When paginating in descending order, we are going from greater cursor id to smaller cursor id + if config.OrderBy == DESC { + queryBuilder.WriteString(fmt.Sprintf(` AND %s.%s < $%d`, config.TableName, config.CursorColumn, argIndex)) + } else { + queryBuilder.WriteString(fmt.Sprintf(` AND %s.%s > $%d`, config.TableName, config.CursorColumn, argIndex)) + } + args = append(args, *config.Cursor) + argIndex++ + } + + // Add ordering + if config.OrderBy == DESC { + queryBuilder.WriteString(fmt.Sprintf(" ORDER BY %s.%s DESC", config.TableName, config.CursorColumn)) + } else { + queryBuilder.WriteString(fmt.Sprintf(" ORDER BY %s.%s ASC", config.TableName, config.CursorColumn)) + } + + // Add limit if provided + if config.Limit != nil { + queryBuilder.WriteString(fmt.Sprintf(` LIMIT $%d`, argIndex)) + args = append(args, *config.Limit) + } + + query := queryBuilder.String() + + // For backward pagination, wrap query to reverse the final order + // This ensures we always display the oldest items first in the output + if config.OrderBy == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS %s ORDER BY %s.cursor ASC`, + query, config.TableName, config.TableName) + } + + return query, args +} + +// PrepareColumnsWithID ensures that the specified ID column is always included in the column list +func prepareColumnsWithID(columns string, tableName string, idColumn string) string { + if columns == "" { + return fmt.Sprintf("%s.*", tableName) + } + // Always return the ID column as it is the primary key and can be used + // to build further queries + return fmt.Sprintf("%s, %s.%s", columns, tableName, idColumn) +} diff --git a/internal/data/transactions.go b/internal/data/transactions.go index eb2f22a6..6ac4c71f 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -60,25 +60,31 @@ func (m *TransactionModel) GetAll(ctx context.Context, limit *int32, columns str return transactions, nil } -// BatchGetByAccountAddresses gets the transactions that are associated with the given account addresses. -func (m *TransactionModel) BatchGetByAccountAddresses(ctx context.Context, accountAddresses []string, columns string) ([]*types.TransactionWithAccountID, error) { - if columns == "" { - columns = "transactions.*" - } - query := fmt.Sprintf(` - SELECT %s, transactions_accounts.account_id - FROM transactions_accounts - INNER JOIN transactions - ON transactions_accounts.tx_hash = transactions.hash - WHERE transactions_accounts.account_id = ANY($1) - ORDER BY transactions.to_id DESC`, columns) - var transactions []*types.TransactionWithAccountID +// BatchGetByAccountAddress gets the transactions that are associated with a single account address. +func (m *TransactionModel) BatchGetByAccountAddress(ctx context.Context, accountAddress string, columns string, limit *int32, cursor *int64, orderBy SortOrder) ([]*types.TransactionWithCursor, error) { + // Prepare columns, ensuring transactions.to_id is always included + columns = prepareColumnsWithID(columns, "transactions", "to_id") + + // Build paginated query using shared utility + query, args := buildGetByAccountAddressQuery(paginatedQueryConfig{ + TableName: "transactions", + CursorColumn: "to_id", + JoinTable: "transactions_accounts", + JoinCondition: "transactions_accounts.tx_hash = transactions.hash", + Columns: columns, + AccountAddress: accountAddress, + Limit: limit, + Cursor: cursor, + OrderBy: orderBy, + }) + + var transactions []*types.TransactionWithCursor start := time.Now() - err := m.DB.SelectContext(ctx, &transactions, query, pq.Array(accountAddresses)) + err := m.DB.SelectContext(ctx, &transactions, query, args...) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) if err != nil { - return nil, fmt.Errorf("getting transactions by accounts: %w", err) + return nil, fmt.Errorf("getting transactions by account address: %w", err) } m.MetricsService.IncDBQuery("SELECT", "transactions") return transactions, nil diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index ab88cda3..64465456 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -268,7 +268,7 @@ func TestTransactionModel_GetAll(t *testing.T) { assert.Len(t, transactions, 2) } -func TestTransactionModel_BatchGetByAccountAddresses(t *testing.T) { +func TestTransactionModel_BatchGetByAccountAddress(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -315,17 +315,12 @@ func TestTransactionModel_BatchGetByAccountAddresses(t *testing.T) { require.NoError(t, err) // Test BatchGetByAccount - transactions, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}, "") + transactions, err := m.BatchGetByAccountAddress(ctx, address1, "", nil, nil, "ASC") require.NoError(t, err) - assert.Len(t, transactions, 3) + assert.Len(t, transactions, 2) - // Verify transactions are for correct accounts - accountsFound := make(map[string]int) - for _, tx := range transactions { - accountsFound[tx.AccountID]++ - } - assert.Equal(t, 2, accountsFound[address1]) - assert.Equal(t, 1, accountsFound[address2]) + assert.Equal(t, int64(1), transactions[0].Cursor) + assert.Equal(t, int64(2), transactions[1].Cursor) } func TestTransactionModel_BatchGetByOperationIDs(t *testing.T) { diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index 440acf16..f11f2e49 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -40,9 +40,9 @@ type Transaction struct { StateChanges []StateChange `json:"stateChanges,omitempty"` } -type TransactionWithAccountID struct { +type TransactionWithCursor struct { Transaction - AccountID string `json:"accountId,omitempty" db:"account_id"` + Cursor int64 `json:"cursor,omitempty" db:"cursor"` } type TransactionWithStateChangeID struct { @@ -139,9 +139,9 @@ type Operation struct { StateChanges []StateChange `json:"stateChanges,omitempty"` } -type OperationWithAccountID struct { +type OperationWithCursor struct { Operation - AccountID string `db:"account_id"` + Cursor int64 `json:"cursor,omitempty" db:"cursor"` } type OperationWithStateChangeID struct { diff --git a/internal/serve/graphql/dataloaders/loaders.go b/internal/serve/graphql/dataloaders/loaders.go index 4a045b7e..4739c394 100644 --- a/internal/serve/graphql/dataloaders/loaders.go +++ b/internal/serve/graphql/dataloaders/loaders.go @@ -69,9 +69,7 @@ type Dataloaders struct { func NewDataloaders(models *data.Models) *Dataloaders { return &Dataloaders{ OperationsByTxHashLoader: operationsByTxHashLoader(models), - OperationsByAccountLoader: OperationsByAccountLoader(models), OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models), - TransactionsByAccountLoader: TransactionsByAccountLoader(models), TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models), TransactionsByOperationIDLoader: transactionByOperationIDLoader(models), StateChangesByAccountLoader: StateChangesByAccountLoader(models), diff --git a/internal/serve/graphql/dataloaders/operation_loaders.go b/internal/serve/graphql/dataloaders/operation_loaders.go index c5edc5ab..c0dfdeae 100644 --- a/internal/serve/graphql/dataloaders/operation_loaders.go +++ b/internal/serve/graphql/dataloaders/operation_loaders.go @@ -42,31 +42,6 @@ func operationsByTxHashLoader(models *data.Models) *dataloadgen.Loader[Operation ) } -// OperationsByAccountLoader creates a dataloader for fetching operations by account address -// This prevents N+1 queries when multiple accounts request their operations -// The loader batches multiple account addresses into a single database query -func OperationsByAccountLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { - return newOneToManyLoader( - func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithAccountID, error) { - accountIDs := make([]string, len(keys)) - columns := keys[0].Columns - for i, key := range keys { - accountIDs[i] = key.AccountID - } - return models.Operations.BatchGetByAccountAddresses(ctx, accountIDs, columns) - }, - func(item *types.OperationWithAccountID) string { - return item.AccountID - }, - func(key OperationColumnsKey) string { - return key.AccountID - }, - func(item *types.OperationWithAccountID) types.Operation { - return item.Operation - }, - ) -} - // operationByStateChangeIDLoader creates a dataloader for fetching operations by state change ID // This prevents N+1 queries when multiple state changes request their operations // The loader batches multiple state change IDs into a single database query diff --git a/internal/serve/graphql/dataloaders/transaction_loaders.go b/internal/serve/graphql/dataloaders/transaction_loaders.go index dfa7cd3e..234e0c92 100644 --- a/internal/serve/graphql/dataloaders/transaction_loaders.go +++ b/internal/serve/graphql/dataloaders/transaction_loaders.go @@ -17,31 +17,6 @@ type TransactionColumnsKey struct { Columns string } -// TransactionsByAccountLoader creates a dataloader for fetching transactions by account address -// This prevents N+1 queries when multiple accounts request their transactions -// The loader batches multiple account addresses into a single database query -func TransactionsByAccountLoader(models *data.Models) *dataloadgen.Loader[TransactionColumnsKey, []*types.Transaction] { - return newOneToManyLoader( - func(ctx context.Context, keys []TransactionColumnsKey) ([]*types.TransactionWithAccountID, error) { - accountIDs := make([]string, len(keys)) - columns := keys[0].Columns - for i, key := range keys { - accountIDs[i] = key.AccountID - } - return models.Transactions.BatchGetByAccountAddresses(ctx, accountIDs, columns) - }, - func(item *types.TransactionWithAccountID) string { - return item.AccountID - }, - func(key TransactionColumnsKey) string { - return key.AccountID - }, - func(item *types.TransactionWithAccountID) types.Transaction { - return item.Transaction - }, - ) -} - // txByOperationIDLoader creates a dataloader for fetching transactions by operation ID // This prevents N+1 queries when multiple operations request their transaction // The loader batches multiple operation IDs into a single database query diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 3cb44551..79e7d774 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -55,9 +55,9 @@ type DirectiveRoot struct { type ComplexityRoot struct { Account struct { Address func(childComplexity int) int - Operations func(childComplexity int) int + Operations func(childComplexity int, first *int32, after *string, last *int32, before *string) int StateChanges func(childComplexity int) int - Transactions func(childComplexity int) int + Transactions func(childComplexity int, first *int32, after *string, last *int32, before *string) int } BuildTransactionPayload struct { @@ -88,6 +88,23 @@ type ComplexityRoot struct { Transaction func(childComplexity int) int } + OperationConnection struct { + Edges func(childComplexity int) int + PageInfo func(childComplexity int) int + } + + OperationEdge struct { + Cursor func(childComplexity int) int + Node func(childComplexity int) int + } + + PageInfo struct { + EndCursor func(childComplexity int) int + HasNextPage func(childComplexity int) int + HasPreviousPage func(childComplexity int) int + StartCursor func(childComplexity int) int + } + Query struct { Account func(childComplexity int, address string) int Operations func(childComplexity int, limit *int32) int @@ -137,12 +154,22 @@ type ComplexityRoot struct { ResultXDR func(childComplexity int) int StateChanges func(childComplexity int) int } + + TransactionConnection struct { + Edges func(childComplexity int) int + PageInfo func(childComplexity int) int + } + + TransactionEdge struct { + Cursor func(childComplexity int) int + Node func(childComplexity int) int + } } type AccountResolver interface { Address(ctx context.Context, obj *types.Account) (string, error) - Transactions(ctx context.Context, obj *types.Account) ([]*types.Transaction, error) - Operations(ctx context.Context, obj *types.Account) ([]*types.Operation, error) + Transactions(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*TransactionConnection, error) + Operations(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*OperationConnection, error) StateChanges(ctx context.Context, obj *types.Account) ([]*types.StateChange, error) } type MutationResolver interface { @@ -216,7 +243,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Account.Operations(childComplexity), true + args, err := ec.field_Account_operations_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Account.Operations(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "Account.stateChanges": if e.complexity.Account.StateChanges == nil { @@ -230,7 +262,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Account.Transactions(childComplexity), true + args, err := ec.field_Account_transactions_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Account.Transactions(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "BuildTransactionPayload.success": if e.complexity.BuildTransactionPayload.Success == nil { @@ -359,6 +396,62 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Operation.Transaction(childComplexity), true + case "OperationConnection.edges": + if e.complexity.OperationConnection.Edges == nil { + break + } + + return e.complexity.OperationConnection.Edges(childComplexity), true + + case "OperationConnection.pageInfo": + if e.complexity.OperationConnection.PageInfo == nil { + break + } + + return e.complexity.OperationConnection.PageInfo(childComplexity), true + + case "OperationEdge.cursor": + if e.complexity.OperationEdge.Cursor == nil { + break + } + + return e.complexity.OperationEdge.Cursor(childComplexity), true + + case "OperationEdge.node": + if e.complexity.OperationEdge.Node == nil { + break + } + + return e.complexity.OperationEdge.Node(childComplexity), true + + case "PageInfo.endCursor": + if e.complexity.PageInfo.EndCursor == nil { + break + } + + return e.complexity.PageInfo.EndCursor(childComplexity), true + + case "PageInfo.hasNextPage": + if e.complexity.PageInfo.HasNextPage == nil { + break + } + + return e.complexity.PageInfo.HasNextPage(childComplexity), true + + case "PageInfo.hasPreviousPage": + if e.complexity.PageInfo.HasPreviousPage == nil { + break + } + + return e.complexity.PageInfo.HasPreviousPage(childComplexity), true + + case "PageInfo.startCursor": + if e.complexity.PageInfo.StartCursor == nil { + break + } + + return e.complexity.PageInfo.StartCursor(childComplexity), true + case "Query.account": if e.complexity.Query.Account == nil { break @@ -650,6 +743,34 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Transaction.StateChanges(childComplexity), true + case "TransactionConnection.edges": + if e.complexity.TransactionConnection.Edges == nil { + break + } + + return e.complexity.TransactionConnection.Edges(childComplexity), true + + case "TransactionConnection.pageInfo": + if e.complexity.TransactionConnection.PageInfo == nil { + break + } + + return e.complexity.TransactionConnection.PageInfo(childComplexity), true + + case "TransactionEdge.cursor": + if e.complexity.TransactionEdge.Cursor == nil { + break + } + + return e.complexity.TransactionEdge.Cursor(childComplexity), true + + case "TransactionEdge.node": + if e.complexity.TransactionEdge.Node == nil { + break + } + + return e.complexity.TransactionEdge.Node(childComplexity), true + } return 0, false } @@ -769,12 +890,10 @@ type Account{ # Each relationship resolver will be called when the field is requested # All transactions associated with this account - # Uses dataloader for efficient batching to prevent N+1 queries - transactions: [Transaction!]! + transactions(first: Int, after: String, last: Int, before: String): TransactionConnection # All operations associated with this account - # Uses dataloader for efficient batching to prevent N+1 queries - operations: [Operation!]! + operations(first: Int, after: String, last: Int, before: String): OperationConnection # All state changes associated with this account # Uses resolver to fetch related state changes @@ -959,6 +1078,33 @@ type Operation{ # Related state changes - uses resolver to fetch associated changes stateChanges: [StateChange!]! @goField(forceResolver: true) } +`, BuiltIn: false}, + {Name: "../schema/pagination.graphqls", Input: `type TransactionConnection { + edges: [TransactionEdge!] + pageInfo: PageInfo! +} + +type TransactionEdge { + node: Transaction + cursor: String! +} + +type OperationConnection { + edges: [OperationEdge!] + pageInfo: PageInfo! +} + +type OperationEdge { + node: Operation + cursor: String! +} + +type PageInfo { + startCursor: String + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! +} `, BuiltIn: false}, {Name: "../schema/queries.graphqls", Input: `# GraphQL Query root type - defines all available queries in the API # In GraphQL, the Query type is the entry point for read operations @@ -1057,6 +1203,160 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...) // region ***************************** args.gotpl ***************************** +func (ec *executionContext) field_Account_operations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Account_operations_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := ec.field_Account_operations_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Account_operations_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Account_operations_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} +func (ec *executionContext) field_Account_operations_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Account_operations_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Account_operations_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Account_operations_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Account_transactions_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Account_transactions_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := ec.field_Account_transactions_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Account_transactions_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Account_transactions_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} +func (ec *executionContext) field_Account_transactions_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Account_transactions_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Account_transactions_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Account_transactions_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_buildTransaction_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1422,24 +1722,21 @@ func (ec *executionContext) _Account_transactions(ctx context.Context, field gra }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Account().Transactions(rctx, obj) + return ec.resolvers.Account().Transactions(rctx, obj, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.Transaction) + res := resTmp.(*TransactionConnection) fc.Result = res - return ec.marshalNTransaction2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransactionᚄ(ctx, field.Selections, res) + return ec.marshalOTransactionConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Account_transactions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Account_transactions(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Account", Field: field, @@ -1447,30 +1744,25 @@ func (ec *executionContext) fieldContext_Account_transactions(_ context.Context, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "hash": - return ec.fieldContext_Transaction_hash(ctx, field) - case "envelopeXdr": - return ec.fieldContext_Transaction_envelopeXdr(ctx, field) - case "resultXdr": - return ec.fieldContext_Transaction_resultXdr(ctx, field) - case "metaXdr": - return ec.fieldContext_Transaction_metaXdr(ctx, field) - case "ledgerNumber": - return ec.fieldContext_Transaction_ledgerNumber(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) - case "ingestedAt": - return ec.fieldContext_Transaction_ingestedAt(ctx, field) - case "operations": - return ec.fieldContext_Transaction_operations(ctx, field) - case "accounts": - return ec.fieldContext_Transaction_accounts(ctx, field) - case "stateChanges": - return ec.fieldContext_Transaction_stateChanges(ctx, field) + case "edges": + return ec.fieldContext_TransactionConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_TransactionConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) + return nil, fmt.Errorf("no field named %q was found under type TransactionConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Account_transactions_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -1488,24 +1780,21 @@ func (ec *executionContext) _Account_operations(ctx context.Context, field graph }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Account().Operations(rctx, obj) + return ec.resolvers.Account().Operations(rctx, obj, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.Operation) + res := resTmp.(*OperationConnection) fc.Result = res - return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) + return ec.marshalOOperationConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Account_operations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Account_operations(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Account", Field: field, @@ -1513,28 +1802,25 @@ func (ec *executionContext) fieldContext_Account_operations(_ context.Context, f IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_Operation_id(ctx, field) - case "operationType": - return ec.fieldContext_Operation_operationType(ctx, field) - case "operationXdr": - return ec.fieldContext_Operation_operationXdr(ctx, field) - case "ledgerNumber": - return ec.fieldContext_Operation_ledgerNumber(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) - case "ingestedAt": - return ec.fieldContext_Operation_ingestedAt(ctx, field) - case "transaction": - return ec.fieldContext_Operation_transaction(ctx, field) - case "accounts": - return ec.fieldContext_Operation_accounts(ctx, field) - case "stateChanges": - return ec.fieldContext_Operation_stateChanges(ctx, field) + case "edges": + return ec.fieldContext_OperationConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_OperationConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + return nil, fmt.Errorf("no field named %q was found under type OperationConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Account_operations_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -2454,8 +2740,8 @@ func (ec *executionContext) fieldContext_Operation_stateChanges(_ context.Contex return fc, nil } -func (ec *executionContext) _Query_transactionByHash(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_transactionByHash(ctx, field) +func (ec *executionContext) _OperationConnection_edges(ctx context.Context, field graphql.CollectedField, obj *OperationConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_OperationConnection_edges(ctx, field) if err != nil { return graphql.Null } @@ -2468,7 +2754,7 @@ func (ec *executionContext) _Query_transactionByHash(ctx context.Context, field }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().TransactionByHash(rctx, fc.Args["hash"].(string)) + return obj.Edges, nil }) if err != nil { ec.Error(ctx, err) @@ -2477,18 +2763,394 @@ func (ec *executionContext) _Query_transactionByHash(ctx context.Context, field if resTmp == nil { return graphql.Null } - res := resTmp.(*types.Transaction) + res := resTmp.([]*OperationEdge) fc.Result = res - return ec.marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, field.Selections, res) + return ec.marshalOOperationEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationEdgeᚄ(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_transactionByHash(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_OperationConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "Query", + Object: "OperationConnection", Field: field, - IsMethod: true, - IsResolver: true, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "node": + return ec.fieldContext_OperationEdge_node(ctx, field) + case "cursor": + return ec.fieldContext_OperationEdge_cursor(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type OperationEdge", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _OperationConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *OperationConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_OperationConnection_pageInfo(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.PageInfo, 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.(*PageInfo) + fc.Result = res + return ec.marshalNPageInfo2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐPageInfo(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_OperationConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "OperationConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "startCursor": + return ec.fieldContext_PageInfo_startCursor(ctx, field) + case "endCursor": + return ec.fieldContext_PageInfo_endCursor(ctx, field) + case "hasNextPage": + return ec.fieldContext_PageInfo_hasNextPage(ctx, field) + case "hasPreviousPage": + return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _OperationEdge_node(ctx context.Context, field graphql.CollectedField, obj *OperationEdge) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_OperationEdge_node(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Node, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*types.Operation) + fc.Result = res + return ec.marshalOOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_OperationEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "OperationEdge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Operation_id(ctx, field) + case "operationType": + return ec.fieldContext_Operation_operationType(ctx, field) + case "operationXdr": + return ec.fieldContext_Operation_operationXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Operation_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Operation_ingestedAt(ctx, field) + case "transaction": + return ec.fieldContext_Operation_transaction(ctx, field) + case "accounts": + return ec.fieldContext_Operation_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Operation_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _OperationEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *OperationEdge) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_OperationEdge_cursor(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Cursor, 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_OperationEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "OperationEdge", + Field: field, + IsMethod: false, + 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) _PageInfo_startCursor(ctx context.Context, field graphql.CollectedField, obj *PageInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PageInfo_startCursor(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.StartCursor, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PageInfo_startCursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + IsMethod: false, + 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) _PageInfo_endCursor(ctx context.Context, field graphql.CollectedField, obj *PageInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PageInfo_endCursor(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.EndCursor, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PageInfo_endCursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + IsMethod: false, + 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) _PageInfo_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *PageInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PageInfo_hasNextPage(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.HasNextPage, 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PageInfo_hasNextPage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _PageInfo_hasPreviousPage(ctx context.Context, field graphql.CollectedField, obj *PageInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PageInfo_hasPreviousPage(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.HasPreviousPage, 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PageInfo_hasPreviousPage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_transactionByHash(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_transactionByHash(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().TransactionByHash(rctx, fc.Args["hash"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*types.Transaction) + fc.Result = res + return ec.marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_transactionByHash(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "hash": return ec.fieldContext_Transaction_hash(ctx, field) @@ -4266,38 +4928,291 @@ func (ec *executionContext) _Transaction_ingestedAt(ctx context.Context, field g }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return obj.IngestedAt, nil + return obj.IngestedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_ingestedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_operations(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_operations(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Transaction().Operations(rctx, obj) + }) + 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.([]*types.Operation) + fc.Result = res + return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_operations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Operation_id(ctx, field) + case "operationType": + return ec.fieldContext_Operation_operationType(ctx, field) + case "operationXdr": + return ec.fieldContext_Operation_operationXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Operation_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Operation_ingestedAt(ctx, field) + case "transaction": + return ec.fieldContext_Operation_transaction(ctx, field) + case "accounts": + return ec.fieldContext_Operation_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Operation_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_accounts(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_accounts(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Transaction().Accounts(rctx, obj) + }) + 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.([]*types.Account) + fc.Result = res + return ec.marshalNAccount2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccountᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_accounts(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "address": + return ec.fieldContext_Account_address(ctx, field) + case "transactions": + return ec.fieldContext_Account_transactions(ctx, field) + case "operations": + return ec.fieldContext_Account_operations(ctx, field) + case "stateChanges": + return ec.fieldContext_Account_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Account", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Transaction_stateChanges(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Transaction_stateChanges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Transaction().StateChanges(rctx, obj) + }) + 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.([]*types.StateChange) + fc.Result = res + return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Transaction_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Transaction", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "accountId": + return ec.fieldContext_StateChange_accountId(ctx, field) + case "stateChangeCategory": + return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) + case "stateChangeReason": + return ec.fieldContext_StateChange_stateChangeReason(ctx, field) + case "ingestedAt": + return ec.fieldContext_StateChange_ingestedAt(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) + case "ledgerNumber": + return ec.fieldContext_StateChange_ledgerNumber(ctx, field) + case "tokenId": + return ec.fieldContext_StateChange_tokenId(ctx, field) + case "amount": + return ec.fieldContext_StateChange_amount(ctx, field) + case "claimableBalanceId": + return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) + case "liquidityPoolId": + return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) + case "offerId": + return ec.fieldContext_StateChange_offerId(ctx, field) + case "signerAccountId": + return ec.fieldContext_StateChange_signerAccountId(ctx, field) + case "spenderAccountId": + return ec.fieldContext_StateChange_spenderAccountId(ctx, field) + case "sponsoredAccountId": + return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) + case "sponsorAccountId": + return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) + case "signerWeights": + return ec.fieldContext_StateChange_signerWeights(ctx, field) + case "thresholds": + return ec.fieldContext_StateChange_thresholds(ctx, field) + case "flags": + return ec.fieldContext_StateChange_flags(ctx, field) + case "keyValue": + return ec.fieldContext_StateChange_keyValue(ctx, field) + case "operation": + return ec.fieldContext_StateChange_operation(ctx, field) + case "transaction": + return ec.fieldContext_StateChange_transaction(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _TransactionConnection_edges(ctx context.Context, field graphql.CollectedField, obj *TransactionConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TransactionConnection_edges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Edges, 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.(time.Time) + res := resTmp.([]*TransactionEdge) fc.Result = res - return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) + return ec.marshalOTransactionEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdgeᚄ(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Transaction_ingestedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_TransactionConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "Transaction", + Object: "TransactionConnection", Field: field, IsMethod: false, IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Time does not have child fields") + switch field.Name { + case "node": + return ec.fieldContext_TransactionEdge_node(ctx, field) + case "cursor": + return ec.fieldContext_TransactionEdge_cursor(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type TransactionEdge", field.Name) }, } return fc, nil } -func (ec *executionContext) _Transaction_operations(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Transaction_operations(ctx, field) +func (ec *executionContext) _TransactionConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *TransactionConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TransactionConnection_pageInfo(ctx, field) if err != nil { return graphql.Null } @@ -4310,7 +5225,7 @@ func (ec *executionContext) _Transaction_operations(ctx context.Context, field g }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Transaction().Operations(rctx, obj) + return obj.PageInfo, nil }) if err != nil { ec.Error(ctx, err) @@ -4322,46 +5237,36 @@ func (ec *executionContext) _Transaction_operations(ctx context.Context, field g } return graphql.Null } - res := resTmp.([]*types.Operation) + res := resTmp.(*PageInfo) fc.Result = res - return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) + return ec.marshalNPageInfo2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐPageInfo(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Transaction_operations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_TransactionConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "Transaction", + Object: "TransactionConnection", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_Operation_id(ctx, field) - case "operationType": - return ec.fieldContext_Operation_operationType(ctx, field) - case "operationXdr": - return ec.fieldContext_Operation_operationXdr(ctx, field) - case "ledgerNumber": - return ec.fieldContext_Operation_ledgerNumber(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) - case "ingestedAt": - return ec.fieldContext_Operation_ingestedAt(ctx, field) - case "transaction": - return ec.fieldContext_Operation_transaction(ctx, field) - case "accounts": - return ec.fieldContext_Operation_accounts(ctx, field) - case "stateChanges": - return ec.fieldContext_Operation_stateChanges(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + case "startCursor": + return ec.fieldContext_PageInfo_startCursor(ctx, field) + case "endCursor": + return ec.fieldContext_PageInfo_endCursor(ctx, field) + case "hasNextPage": + return ec.fieldContext_PageInfo_hasNextPage(ctx, field) + case "hasPreviousPage": + return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name) }, } return fc, nil } -func (ec *executionContext) _Transaction_accounts(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Transaction_accounts(ctx, field) +func (ec *executionContext) _TransactionEdge_node(ctx context.Context, field graphql.CollectedField, obj *TransactionEdge) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TransactionEdge_node(ctx, field) if err != nil { return graphql.Null } @@ -4374,48 +5279,57 @@ func (ec *executionContext) _Transaction_accounts(ctx context.Context, field gra }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Transaction().Accounts(rctx, obj) + return obj.Node, 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.([]*types.Account) + res := resTmp.(*types.Transaction) fc.Result = res - return ec.marshalNAccount2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccountᚄ(ctx, field.Selections, res) + return ec.marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Transaction_accounts(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_TransactionEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "Transaction", + Object: "TransactionEdge", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "address": - return ec.fieldContext_Account_address(ctx, field) - case "transactions": - return ec.fieldContext_Account_transactions(ctx, field) + case "hash": + return ec.fieldContext_Transaction_hash(ctx, field) + case "envelopeXdr": + return ec.fieldContext_Transaction_envelopeXdr(ctx, field) + case "resultXdr": + return ec.fieldContext_Transaction_resultXdr(ctx, field) + case "metaXdr": + return ec.fieldContext_Transaction_metaXdr(ctx, field) + case "ledgerNumber": + return ec.fieldContext_Transaction_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Transaction_ingestedAt(ctx, field) case "operations": - return ec.fieldContext_Account_operations(ctx, field) + return ec.fieldContext_Transaction_operations(ctx, field) + case "accounts": + return ec.fieldContext_Transaction_accounts(ctx, field) case "stateChanges": - return ec.fieldContext_Account_stateChanges(ctx, field) + return ec.fieldContext_Transaction_stateChanges(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Account", field.Name) + return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) }, } return fc, nil } -func (ec *executionContext) _Transaction_stateChanges(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Transaction_stateChanges(ctx, field) +func (ec *executionContext) _TransactionEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *TransactionEdge) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TransactionEdge_cursor(ctx, field) if err != nil { return graphql.Null } @@ -4428,7 +5342,7 @@ func (ec *executionContext) _Transaction_stateChanges(ctx context.Context, field }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Transaction().StateChanges(rctx, obj) + return obj.Cursor, nil }) if err != nil { ec.Error(ctx, err) @@ -4440,63 +5354,19 @@ func (ec *executionContext) _Transaction_stateChanges(ctx context.Context, field } return graphql.Null } - res := resTmp.([]*types.StateChange) + res := resTmp.(string) fc.Result = res - return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) + return ec.marshalNString2string(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Transaction_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_TransactionEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "Transaction", + Object: "TransactionEdge", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "accountId": - return ec.fieldContext_StateChange_accountId(ctx, field) - case "stateChangeCategory": - return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) - case "stateChangeReason": - return ec.fieldContext_StateChange_stateChangeReason(ctx, field) - case "ingestedAt": - return ec.fieldContext_StateChange_ingestedAt(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) - case "ledgerNumber": - return ec.fieldContext_StateChange_ledgerNumber(ctx, field) - case "tokenId": - return ec.fieldContext_StateChange_tokenId(ctx, field) - case "amount": - return ec.fieldContext_StateChange_amount(ctx, field) - case "claimableBalanceId": - return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) - case "liquidityPoolId": - return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) - case "offerId": - return ec.fieldContext_StateChange_offerId(ctx, field) - case "signerAccountId": - return ec.fieldContext_StateChange_signerAccountId(ctx, field) - case "spenderAccountId": - return ec.fieldContext_StateChange_spenderAccountId(ctx, field) - case "sponsoredAccountId": - return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) - case "sponsorAccountId": - return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) - case "signerWeights": - return ec.fieldContext_StateChange_signerWeights(ctx, field) - case "thresholds": - return ec.fieldContext_StateChange_thresholds(ctx, field) - case "flags": - return ec.fieldContext_StateChange_flags(ctx, field) - case "keyValue": - return ec.fieldContext_StateChange_keyValue(ctx, field) - case "operation": - return ec.fieldContext_StateChange_operation(ctx, field) - case "transaction": - return ec.fieldContext_StateChange_transaction(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + return nil, errors.New("field of type String does not have child fields") }, } return fc, nil @@ -6695,16 +7565,13 @@ func (ec *executionContext) _Account(ctx context.Context, sel ast.SelectionSet, case "transactions": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._Account_transactions(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -6731,16 +7598,13 @@ func (ec *executionContext) _Account(ctx context.Context, sel ast.SelectionSet, case "operations": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._Account_operations(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -7083,43 +7947,173 @@ func (ec *executionContext) _Operation(ctx context.Context, sel ast.SelectionSet continue } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "stateChanges": - field := field + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "stateChanges": + 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._Operation_stateChanges(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) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var operationConnectionImplementors = []string{"OperationConnection"} + +func (ec *executionContext) _OperationConnection(ctx context.Context, sel ast.SelectionSet, obj *OperationConnection) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, operationConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("OperationConnection") + case "edges": + out.Values[i] = ec._OperationConnection_edges(ctx, field, obj) + case "pageInfo": + out.Values[i] = ec._OperationConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var operationEdgeImplementors = []string{"OperationEdge"} + +func (ec *executionContext) _OperationEdge(ctx context.Context, sel ast.SelectionSet, obj *OperationEdge) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, operationEdgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("OperationEdge") + case "node": + out.Values[i] = ec._OperationEdge_node(ctx, field, obj) + case "cursor": + out.Values[i] = ec._OperationEdge_cursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) - 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._Operation_stateChanges(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } - return res - } + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } - 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) - }) + return out +} - // don't run the out.Concurrently() call below - out.Values[i] = graphql.Null - continue - } +var pageInfoImplementors = []string{"PageInfo"} - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) +func (ec *executionContext) _PageInfo(ctx context.Context, sel ast.SelectionSet, obj *PageInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, pageInfoImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("PageInfo") + case "startCursor": + out.Values[i] = ec._PageInfo_startCursor(ctx, field, obj) + case "endCursor": + out.Values[i] = ec._PageInfo_endCursor(ctx, field, obj) + case "hasNextPage": + out.Values[i] = ec._PageInfo_hasNextPage(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "hasPreviousPage": + out.Values[i] = ec._PageInfo_hasPreviousPage(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8074,6 +9068,88 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS return out } +var transactionConnectionImplementors = []string{"TransactionConnection"} + +func (ec *executionContext) _TransactionConnection(ctx context.Context, sel ast.SelectionSet, obj *TransactionConnection) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, transactionConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("TransactionConnection") + case "edges": + out.Values[i] = ec._TransactionConnection_edges(ctx, field, obj) + case "pageInfo": + out.Values[i] = ec._TransactionConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var transactionEdgeImplementors = []string{"TransactionEdge"} + +func (ec *executionContext) _TransactionEdge(ctx context.Context, sel ast.SelectionSet, obj *TransactionEdge) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, transactionEdgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("TransactionEdge") + case "node": + out.Values[i] = ec._TransactionEdge_node(ctx, field, obj) + case "cursor": + out.Values[i] = ec._TransactionEdge_cursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var __DirectiveImplementors = []string{"__Directive"} func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler { @@ -8603,6 +9679,16 @@ func (ec *executionContext) marshalNOperation2ᚖgithubᚗcomᚋstellarᚋwallet return ec._Operation(ctx, sel, v) } +func (ec *executionContext) marshalNOperationEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationEdge(ctx context.Context, sel ast.SelectionSet, v *OperationEdge) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._OperationEdge(ctx, sel, v) +} + func (ec *executionContext) unmarshalNOperationType2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationType(ctx context.Context, v any) (types.OperationType, error) { tmp, err := graphql.UnmarshalString(v) res := types.OperationType(tmp) @@ -8620,6 +9706,16 @@ func (ec *executionContext) marshalNOperationType2githubᚗcomᚋstellarᚋwalle return res } +func (ec *executionContext) marshalNPageInfo2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐPageInfo(ctx context.Context, sel ast.SelectionSet, v *PageInfo) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._PageInfo(ctx, sel, v) +} + func (ec *executionContext) unmarshalNRegisterAccountInput2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐRegisterAccountInput(ctx context.Context, v any) (RegisterAccountInput, error) { res, err := ec.unmarshalInputRegisterAccountInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -8830,6 +9926,16 @@ func (ec *executionContext) marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwall return ec._Transaction(ctx, sel, v) } +func (ec *executionContext) marshalNTransactionEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdge(ctx context.Context, sel ast.SelectionSet, v *TransactionEdge) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._TransactionEdge(ctx, sel, v) +} + func (ec *executionContext) unmarshalNTransactionInput2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionInput(ctx context.Context, v any) (*TransactionInput, error) { res, err := ec.unmarshalInputTransactionInput(ctx, v) return &res, graphql.ErrorOnPath(ctx, err) @@ -9166,6 +10272,60 @@ func (ec *executionContext) marshalOOperation2ᚖgithubᚗcomᚋstellarᚋwallet return ec._Operation(ctx, sel, v) } +func (ec *executionContext) marshalOOperationConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationConnection(ctx context.Context, sel ast.SelectionSet, v *OperationConnection) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._OperationConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalOOperationEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []*OperationEdge) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNOperationEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationEdge(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalOSimulationResultInput2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐSimulationResultInput(ctx context.Context, v any) (*SimulationResultInput, error) { if v == nil { return nil, nil @@ -9254,6 +10414,60 @@ func (ec *executionContext) marshalOTransaction2ᚖgithubᚗcomᚋstellarᚋwall return ec._Transaction(ctx, sel, v) } +func (ec *executionContext) marshalOTransactionConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionConnection(ctx context.Context, sel ast.SelectionSet, v *TransactionConnection) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._TransactionConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalOTransactionEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []*TransactionEdge) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNTransactionEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionEdge(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/serve/graphql/generated/models_gen.go b/internal/serve/graphql/generated/models_gen.go index 19fc5545..d6fd7bfe 100644 --- a/internal/serve/graphql/generated/models_gen.go +++ b/internal/serve/graphql/generated/models_gen.go @@ -27,6 +27,23 @@ type DeregisterAccountPayload struct { type Mutation struct { } +type OperationConnection struct { + Edges []*OperationEdge `json:"edges,omitempty"` + PageInfo *PageInfo `json:"pageInfo"` +} + +type OperationEdge struct { + Node *types.Operation `json:"node,omitempty"` + Cursor string `json:"cursor"` +} + +type PageInfo struct { + StartCursor *string `json:"startCursor,omitempty"` + EndCursor *string `json:"endCursor,omitempty"` + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` +} + type Query struct { } @@ -48,6 +65,16 @@ type SimulationResultInput struct { Error *string `json:"error,omitempty"` } +type TransactionConnection struct { + Edges []*TransactionEdge `json:"edges,omitempty"` + PageInfo *PageInfo `json:"pageInfo"` +} + +type TransactionEdge struct { + Node *types.Transaction `json:"node,omitempty"` + Cursor string `json:"cursor"` +} + type TransactionInput struct { Operations []string `json:"operations"` Timeout int32 `json:"timeout"` diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go index d9e145d8..8d52bdf9 100644 --- a/internal/serve/graphql/resolvers/account.resolvers.go +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "fmt" "strings" "github.com/stellar/wallet-backend/internal/indexer/types" @@ -23,44 +24,68 @@ func (r *accountResolver) Address(ctx context.Context, obj *types.Account) (stri // This is a field resolver - it resolves the "transactions" field on an Account object // gqlgen calls this when a GraphQL query requests the transactions field on an Account // Field resolvers receive the parent object (Account) and return the field value -func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account) ([]*types.Transaction, error) { - // Extract dataloaders from GraphQL context - // Dataloaders are injected by middleware to batch database queries - loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") - - loaderKey := dataloaders.TransactionColumnsKey{ - AccountID: obj.StellarAddress, - Columns: strings.Join(dbColumns, ", "), +func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*graphql1.TransactionConnection, error) { + params, err := parsePaginationParams(first, after, last, before, 100) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) } + queryLimit := *params.Limit + 1 // +1 to check if there is a next page - // Use dataloader to efficiently batch-load transactions for this account - // This prevents N+1 queries when multiple accounts request their transactions - transactions, err := loaders.TransactionsByAccountLoader.Load(ctx, loaderKey) + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + transactions, err := r.models.Transactions.BatchGetByAccountAddress(ctx, obj.StellarAddress, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) if err != nil { - return nil, err + return nil, fmt.Errorf("getting transactions from db for account %s: %w", obj.StellarAddress, err) + } + + conn := NewConnectionWithRelayPagination(transactions, params, func(tx *types.TransactionWithCursor) int64 { + return tx.Cursor + }) + + edges := make([]*graphql1.TransactionEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.TransactionEdge{ + Node: &edge.Node.Transaction, + Cursor: edge.Cursor, + } } - return transactions, nil + + return &graphql1.TransactionConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // Operations is the resolver for the operations field. // This field resolver handles the "operations" field on an Account object -// Demonstrates the same dataloader pattern as Transactions resolver -func (r *accountResolver) Operations(ctx context.Context, obj *types.Account) ([]*types.Operation, error) { - loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "operations") - - loaderKey := dataloaders.OperationColumnsKey{ - AccountID: obj.StellarAddress, - Columns: strings.Join(dbColumns, ", "), +func (r *accountResolver) Operations(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*graphql1.OperationConnection, error) { + params, err := parsePaginationParams(first, after, last, before, 100) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) } + queryLimit := *params.Limit + 1 // +1 to check if there is a next page - // Use dataloader to batch-load operations for this account - operations, err := loaders.OperationsByAccountLoader.Load(ctx, loaderKey) + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "operations") + operations, err := r.models.Operations.BatchGetByAccountAddress(ctx, obj.StellarAddress, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) if err != nil { - return nil, err + return nil, fmt.Errorf("getting operations from db for account %s: %w", obj.StellarAddress, err) + } + + conn := NewConnectionWithRelayPagination(operations, params, func(op *types.OperationWithCursor) int64 { + return op.Cursor + }) + + edges := make([]*graphql1.OperationEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.OperationEdge{ + Node: &edge.Node.Operation, + Cursor: edge.Cursor, + } } - return operations, nil + + return &graphql1.OperationConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // StateChanges is the resolver for the stateChanges field. diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index 6229a907..aabeb3fd 100644 --- a/internal/serve/graphql/resolvers/account_resolvers_test.go +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -22,7 +22,6 @@ func TestAccountResolver_Transactions(t *testing.T) { mockMetricsService := &metrics.MockMetricsService{} mockMetricsService.On("IncDBQuery", "SELECT", "transactions").Return() mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "transactions", mock.Anything).Return() - defer mockMetricsService.AssertExpectations(t) resolver := &accountResolver{ &Resolver{ @@ -35,42 +34,113 @@ func TestAccountResolver_Transactions(t *testing.T) { }, } - t.Run("success", func(t *testing.T) { - loaders := &dataloaders.Dataloaders{ - TransactionsByAccountLoader: dataloaders.TransactionsByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - transactions, err := resolver.Transactions(ctx, parentAccount) + t.Run("get all transactions", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + transactions, err := resolver.Transactions(ctx, parentAccount, nil, nil, nil, nil) require.NoError(t, err) - require.Len(t, transactions, 4) - assert.Equal(t, "tx4", transactions[0].Hash) - assert.Equal(t, "tx3", transactions[1].Hash) - assert.Equal(t, "tx2", transactions[2].Hash) - assert.Equal(t, "tx1", transactions[3].Hash) + require.Len(t, transactions.Edges, 4) + assert.Equal(t, "tx1", transactions.Edges[0].Node.Hash) + assert.Equal(t, "tx2", transactions.Edges[1].Node.Hash) + assert.Equal(t, "tx3", transactions.Edges[2].Node.Hash) + assert.Equal(t, "tx4", transactions.Edges[3].Node.Hash) + mockMetricsService.AssertExpectations(t) }) - t.Run("nil account panics", func(t *testing.T) { - loaders := &dataloaders.Dataloaders{ - TransactionsByAccountLoader: dataloaders.TransactionsByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) + t.Run("get transactions with first/after limit and cursor", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + first := int32(2) + txs, err := resolver.Transactions(ctx, parentAccount, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, txs.Edges, 2) + assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) + assert.Equal(t, "tx2", txs.Edges[1].Node.Hash) + assert.True(t, txs.PageInfo.HasNextPage) + assert.False(t, txs.PageInfo.HasPreviousPage) - assert.Panics(t, func() { - _, _ = resolver.Transactions(ctx, nil) //nolint:errcheck - }) + // Get the next cursor + nextCursor := txs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + txs, err = resolver.Transactions(ctx, parentAccount, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, txs.Edges, 2) + assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) + assert.Equal(t, "tx4", txs.Edges[1].Node.Hash) + assert.False(t, txs.PageInfo.HasNextPage) + assert.True(t, txs.PageInfo.HasPreviousPage) + mockMetricsService.AssertExpectations(t) + }) + + t.Run("get transactions with last/before limit and cursor", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + last := int32(2) + txs, err := resolver.Transactions(ctx, parentAccount, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, txs.Edges, 2) + assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) + assert.Equal(t, "tx4", txs.Edges[1].Node.Hash) + assert.False(t, txs.PageInfo.HasNextPage) + assert.True(t, txs.PageInfo.HasPreviousPage) + + // Get the next cursor + last = int32(1) + nextCursor := txs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + txs, err = resolver.Transactions(ctx, parentAccount, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, txs.Edges, 1) + assert.Equal(t, "tx2", txs.Edges[0].Node.Hash) + assert.True(t, txs.PageInfo.HasNextPage) + assert.True(t, txs.PageInfo.HasPreviousPage) + + nextCursor = txs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + last = int32(10) + txs, err = resolver.Transactions(ctx, parentAccount, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, txs.Edges, 1) + assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) + assert.True(t, txs.PageInfo.HasNextPage) + assert.False(t, txs.PageInfo.HasPreviousPage) + mockMetricsService.AssertExpectations(t) }) t.Run("account with no transactions", func(t *testing.T) { nonExistentAccount := &types.Account{StellarAddress: "non-existent-account"} - loaders := &dataloaders.Dataloaders{ - TransactionsByAccountLoader: dataloaders.TransactionsByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("transactions", []string{"hash"}), middleware.LoadersKey, loaders) - transactions, err := resolver.Transactions(ctx, nonExistentAccount) + ctx := getTestCtx("transactions", []string{"hash"}) + transactions, err := resolver.Transactions(ctx, nonExistentAccount, nil, nil, nil, nil) require.NoError(t, err) - assert.Empty(t, transactions) + assert.Empty(t, transactions.Edges) + mockMetricsService.AssertExpectations(t) + }) + + t.Run("invalid pagination params", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + first := int32(0) + last := int32(1) + after := encodeCursor(int64(4)) + before := encodeCursor(int64(1)) + _, err := resolver.Transactions(ctx, parentAccount, &first, &after, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + + first = int32(1) + _, err = resolver.Transactions(ctx, parentAccount, &first, nil, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + + _, err = resolver.Transactions(ctx, parentAccount, nil, &after, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + + _, err = resolver.Transactions(ctx, parentAccount, &first, nil, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + + _, err = resolver.Transactions(ctx, parentAccount, nil, &after, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") }) } @@ -91,42 +161,97 @@ func TestAccountResolver_Operations(t *testing.T) { }, }} - t.Run("success", func(t *testing.T) { - loaders := &dataloaders.Dataloaders{ - OperationsByAccountLoader: dataloaders.OperationsByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - operations, err := resolver.Operations(ctx, parentAccount) + t.Run("get all operations", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"operation_type"}) + operations, err := resolver.Operations(ctx, parentAccount, nil, nil, nil, nil) require.NoError(t, err) - require.Len(t, operations, 8) - assert.Equal(t, int64(1008), operations[0].ID) - assert.Equal(t, int64(1007), operations[1].ID) - assert.Equal(t, int64(1006), operations[2].ID) - assert.Equal(t, int64(1005), operations[3].ID) + require.Len(t, operations.Edges, 8) + assert.Equal(t, int64(1001), operations.Edges[0].Node.ID) + assert.Equal(t, int64(1002), operations.Edges[1].Node.ID) + assert.Equal(t, int64(1003), operations.Edges[2].Node.ID) + assert.Equal(t, int64(1004), operations.Edges[3].Node.ID) }) - t.Run("nil account panics", func(t *testing.T) { - loaders := &dataloaders.Dataloaders{ - OperationsByAccountLoader: dataloaders.OperationsByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + t.Run("get operations with first/after limit and cursor", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"operation_type"}) + first := int32(2) + ops, err := resolver.Operations(ctx, parentAccount, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 2) + assert.Equal(t, int64(1001), ops.Edges[0].Node.ID) + assert.Equal(t, int64(1002), ops.Edges[1].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) - assert.Panics(t, func() { - _, _ = resolver.Operations(ctx, nil) //nolint:errcheck - }) + // Get the next cursor + nextCursor := ops.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + ops, err = resolver.Operations(ctx, parentAccount, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 2) + assert.Equal(t, int64(1003), ops.Edges[0].Node.ID) + assert.Equal(t, int64(1004), ops.Edges[1].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + + first = int32(10) + nextCursor = ops.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + ops, err = resolver.Operations(ctx, parentAccount, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 4) + assert.Equal(t, int64(1005), ops.Edges[0].Node.ID) + assert.Equal(t, int64(1006), ops.Edges[1].Node.ID) + assert.Equal(t, int64(1007), ops.Edges[2].Node.ID) + assert.Equal(t, int64(1008), ops.Edges[3].Node.ID) + assert.False(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + }) + + t.Run("get operations with last/before limit and cursor", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"operation_type"}) + last := int32(2) + ops, err := resolver.Operations(ctx, parentAccount, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 2) + assert.Equal(t, int64(1007), ops.Edges[0].Node.ID) + assert.Equal(t, int64(1008), ops.Edges[1].Node.ID) + assert.True(t, ops.PageInfo.HasPreviousPage) + assert.False(t, ops.PageInfo.HasNextPage) + + // Get the next cursor + nextCursor := ops.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + ops, err = resolver.Operations(ctx, parentAccount, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, ops.Edges, 2) + assert.Equal(t, int64(1005), ops.Edges[0].Node.ID) + assert.Equal(t, int64(1006), ops.Edges[1].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + + nextCursor = ops.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + last = int32(10) + ops, err = resolver.Operations(ctx, parentAccount, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, ops.Edges, 4) + assert.Equal(t, int64(1001), ops.Edges[0].Node.ID) + assert.Equal(t, int64(1002), ops.Edges[1].Node.ID) + assert.Equal(t, int64(1003), ops.Edges[2].Node.ID) + assert.Equal(t, int64(1004), ops.Edges[3].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) }) t.Run("account with no operations", func(t *testing.T) { nonExistentAccount := &types.Account{StellarAddress: "non-existent-account"} - loaders := &dataloaders.Dataloaders{ - OperationsByAccountLoader: dataloaders.OperationsByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - operations, err := resolver.Operations(ctx, nonExistentAccount) + ctx := getTestCtx("operations", []string{"id"}) + operations, err := resolver.Operations(ctx, nonExistentAccount, nil, nil, nil, nil) require.NoError(t, err) - assert.Empty(t, operations) + assert.Empty(t, operations.Edges) }) } diff --git a/internal/serve/graphql/resolvers/utils.go b/internal/serve/graphql/resolvers/utils.go index 9964adfa..374af8ab 100644 --- a/internal/serve/graphql/resolvers/utils.go +++ b/internal/serve/graphql/resolvers/utils.go @@ -2,17 +2,136 @@ package resolvers import ( "context" + "encoding/base64" + "fmt" "reflect" + "strconv" "strings" "github.com/99designs/gqlgen/graphql" + + "github.com/stellar/wallet-backend/internal/data" + generated "github.com/stellar/wallet-backend/internal/serve/graphql/generated" ) +// GenericEdge is a generic wrapper for a GraphQL edge. +type GenericEdge[T any] struct { + Node T + Cursor string +} + +// GenericConnection is a generic wrapper for a GraphQL connection. +type GenericConnection[T any] struct { + Edges []*GenericEdge[T] + PageInfo *generated.PageInfo +} + +type PaginationParams struct { + Limit *int32 + Cursor *int64 + ForwardPagination bool + SortOrder data.SortOrder +} + +// NewConnectionWithRelayPagination builds a connection supporting both forward and backward pagination. +func NewConnectionWithRelayPagination[T any, C int64 | string](nodes []T, params PaginationParams, getCursorID func(T) C) *GenericConnection[T] { + hasNextPage := false + hasPreviousPage := false + + if params.ForwardPagination { + if int32(len(nodes)) > *params.Limit { + hasNextPage = true + nodes = nodes[:*params.Limit] + } + hasPreviousPage = params.Cursor != nil + } else { + if int32(len(nodes)) > *params.Limit { + hasPreviousPage = true + nodes = nodes[1:] + } + // In backward pagination, presence of a before-cursor implies there may be newer items (a "next page") + hasNextPage = params.Cursor != nil + } + + edges := make([]*GenericEdge[T], len(nodes)) + for i, node := range nodes { + edges[i] = &GenericEdge[T]{ + Node: node, + Cursor: encodeCursor(getCursorID(node)), + } + } + + var startCursor, endCursor *string + if len(edges) > 0 { + startCursor = &edges[0].Cursor + if params.ForwardPagination { + endCursor = &edges[len(edges)-1].Cursor + } else { + endCursor = &edges[0].Cursor + } + } + + pageInfo := &generated.PageInfo{ + StartCursor: startCursor, + EndCursor: endCursor, + HasNextPage: hasNextPage, + HasPreviousPage: hasPreviousPage, + } + + return &GenericConnection[T]{ + Edges: edges, + PageInfo: pageInfo, + } +} + func GetDBColumnsForFields(ctx context.Context, model any, prefix string) []string { + opCtx := graphql.GetOperationContext(ctx) fields := graphql.CollectFieldsCtx(ctx, nil) + + for _, field := range fields { + if field.Name == "edges" { + edgeFields := graphql.CollectFields(opCtx, field.Selections, nil) + for _, edgeField := range edgeFields { + if edgeField.Name == "node" { + nodeFields := graphql.CollectFields(opCtx, edgeField.Selections, nil) + return prefixDBColumns(prefix, getDBColumns(model, nodeFields)) + } + } + } + } + return prefixDBColumns(prefix, getDBColumns(model, fields)) } +func encodeCursor[T int64 | string](i T) string { + switch v := any(i).(type) { + case int64: + return base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(v, 10))) + case string: + return base64.StdEncoding.EncodeToString([]byte(v)) + default: + panic(fmt.Sprintf("unsupported type: %T", i)) + } +} + +func decodeInt64Cursor(s *string) (*int64, error) { + if s == nil { + return nil, nil + } + + decoded, err := base64.StdEncoding.DecodeString(*s) + if err != nil { + return nil, fmt.Errorf("decoding cursor string %s: %w", *s, err) + } + + id, err := strconv.ParseInt(string(decoded), 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing cursor %s: %w", string(decoded), err) + } + + return &id, nil +} + func getDBColumns(model any, fields []graphql.CollectedField) []string { fieldToColumnMap := getColumnMap(model) dbColumns := make([]string, 0, len(fields)) @@ -50,3 +169,64 @@ func getColumnMap(model any) map[string]string { } return fieldToColumnMap } + +func parsePaginationParams(first *int32, after *string, last *int32, before *string, defaultLimit int32) (PaginationParams, error) { + err := validatePaginationParams(first, after, last, before) + if err != nil { + return PaginationParams{}, fmt.Errorf("validating pagination params: %w", err) + } + + var cursor *string + limit := defaultLimit + forwardPagination := true + sortOrder := data.ASC + if first != nil { + cursor = after + limit = *first + } else if last != nil { + cursor = before + limit = *last + forwardPagination = false + sortOrder = data.DESC + } + + decodedCursor, err := decodeInt64Cursor(cursor) + if err != nil { + return PaginationParams{}, fmt.Errorf("decoding cursor: %w", err) + } + + return PaginationParams{ + Cursor: decodedCursor, + Limit: &limit, + ForwardPagination: forwardPagination, + SortOrder: sortOrder, + }, nil +} + +func validatePaginationParams(first *int32, after *string, last *int32, before *string) error { + if first != nil && last != nil { + return fmt.Errorf("first and last cannot be used together") + } + + if after != nil && before != nil { + return fmt.Errorf("after and before cannot be used together") + } + + if first != nil && *first <= 0 { + return fmt.Errorf("first must be greater than 0") + } + + if last != nil && *last <= 0 { + return fmt.Errorf("last must be greater than 0") + } + + if first != nil && before != nil { + return fmt.Errorf("first and before cannot be used together") + } + + if last != nil && after != nil { + return fmt.Errorf("last and after cannot be used together") + } + + return nil +} diff --git a/internal/serve/graphql/schema/account.graphqls b/internal/serve/graphql/schema/account.graphqls index bb1ee321..9cc95f1f 100644 --- a/internal/serve/graphql/schema/account.graphqls +++ b/internal/serve/graphql/schema/account.graphqls @@ -7,12 +7,10 @@ type Account{ # Each relationship resolver will be called when the field is requested # All transactions associated with this account - # Uses dataloader for efficient batching to prevent N+1 queries - transactions: [Transaction!]! + transactions(first: Int, after: String, last: Int, before: String): TransactionConnection # All operations associated with this account - # Uses dataloader for efficient batching to prevent N+1 queries - operations: [Operation!]! + operations(first: Int, after: String, last: Int, before: String): OperationConnection # All state changes associated with this account # Uses resolver to fetch related state changes diff --git a/internal/serve/graphql/schema/pagination.graphqls b/internal/serve/graphql/schema/pagination.graphqls new file mode 100644 index 00000000..e47a5ed5 --- /dev/null +++ b/internal/serve/graphql/schema/pagination.graphqls @@ -0,0 +1,26 @@ +type TransactionConnection { + edges: [TransactionEdge!] + pageInfo: PageInfo! +} + +type TransactionEdge { + node: Transaction + cursor: String! +} + +type OperationConnection { + edges: [OperationEdge!] + pageInfo: PageInfo! +} + +type OperationEdge { + node: Operation + cursor: String! +} + +type PageInfo { + startCursor: String + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! +} From 042d542a29ee776df9f77d97fcb0d8a44a9edda6 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Tue, 19 Aug 2025 12:54:45 -0400 Subject: [PATCH 12/16] Paginate through an account's state changes (#286) --- internal/data/statechanges.go | 75 ++- internal/data/statechanges_test.go | 25 +- internal/indexer/types/types.go | 10 + internal/serve/graphql/dataloaders/loaders.go | 13 - .../dataloaders/statechange_loaders.go | 23 - internal/serve/graphql/generated/generated.go | 596 ++++++++++++++++-- .../serve/graphql/generated/models_gen.go | 10 + .../graphql/resolvers/account.resolvers.go | 43 +- .../resolvers/account_resolvers_test.go | 162 +++-- .../resolvers/operation_resolvers_test.go | 14 +- .../resolvers/queries_resolvers_test.go | 17 +- .../resolvers/statechange_resolvers_test.go | 7 +- .../serve/graphql/resolvers/test_utils.go | 21 +- .../resolvers/transaction_resolvers_test.go | 22 +- internal/serve/graphql/resolvers/utils.go | 83 ++- .../serve/graphql/schema/account.graphqls | 2 +- .../serve/graphql/schema/pagination.graphqls | 10 + 17 files changed, 891 insertions(+), 242 deletions(-) diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index 61d3255a..68380722 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -3,6 +3,7 @@ package data import ( "context" "fmt" + "strings" "time" "github.com/lib/pq" @@ -17,26 +18,56 @@ type StateChangeModel struct { MetricsService metrics.MetricsService } -// BatchGetByAccountAddresses gets the state changes that are associated with the given account addresses. -func (m *StateChangeModel) BatchGetByAccountAddresses( - ctx context.Context, - accountAddresses []string, - columns string, -) ([]*types.StateChange, error) { +// BatchGetByAccountAddress gets the state changes that are associated with the given account addresses. +func (m *StateChangeModel) BatchGetByAccountAddress(ctx context.Context, accountAddress string, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { if columns == "" { columns = "*" + } else { + columns = fmt.Sprintf("%s, to_id, state_change_order", columns) } - query := fmt.Sprintf(` - SELECT %s FROM state_changes WHERE account_id = ANY($1) - ORDER BY to_id DESC, state_change_order DESC - `, columns) - var stateChanges []*types.StateChange + + var queryBuilder strings.Builder + queryBuilder.WriteString(fmt.Sprintf(` + SELECT %s, to_id as "cursor.cursor_to_id", state_change_order as "cursor.cursor_state_change_order" + FROM state_changes + WHERE account_id = $1 + `, columns)) + + if cursor != nil { + if sortOrder == DESC { + queryBuilder.WriteString(fmt.Sprintf(` + AND (to_id < %d OR (to_id = %d AND state_change_order < %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } else { + queryBuilder.WriteString(fmt.Sprintf(` + AND (to_id > %d OR (to_id = %d AND state_change_order > %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } + } + + if sortOrder == DESC { + queryBuilder.WriteString(" ORDER BY to_id DESC, state_change_order DESC") + } else { + queryBuilder.WriteString(" ORDER BY to_id ASC, state_change_order ASC") + } + + if limit != nil && *limit > 0 { + queryBuilder.WriteString(fmt.Sprintf(" LIMIT %d", *limit)) + } + + query := queryBuilder.String() + + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS statechanges ORDER BY to_id ASC, state_change_order ASC`, query) + } + + var stateChanges []*types.StateChangeWithCursor start := time.Now() - err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(accountAddresses)) + err := m.DB.SelectContext(ctx, &stateChanges, query, accountAddress) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) if err != nil { - return nil, fmt.Errorf("getting state changes by account addresses: %w", err) + return nil, fmt.Errorf("getting state changes by account address: %w", err) } m.MetricsService.IncDBQuery("SELECT", "state_changes") return stateChanges, nil @@ -45,11 +76,13 @@ func (m *StateChangeModel) BatchGetByAccountAddresses( func (m *StateChangeModel) GetAll(ctx context.Context, limit *int32, columns string) ([]*types.StateChange, error) { if columns == "" { columns = "*" + } else { + columns = fmt.Sprintf("%s, to_id, state_change_order", columns) } // We always return the to_id, state_change_order since those are the primary keys. // This is used for subsequent queries for operation and transactions of a state change. - query := fmt.Sprintf(`SELECT to_id, state_change_order, %s FROM state_changes ORDER BY to_id DESC, state_change_order DESC`, columns) + query := fmt.Sprintf(`SELECT %s FROM state_changes ORDER BY to_id DESC, state_change_order DESC`, columns) var args []interface{} if limit != nil && *limit > 0 { query += ` LIMIT $1` @@ -258,11 +291,14 @@ func (m *StateChangeModel) BatchInsert( func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string) ([]*types.StateChange, error) { if columns == "" { columns = "*" + } else { + // We always return the to_id, state_change_order since those are the primary keys. + // This is used for subsequent queries for operation and transactions of a state change. + columns = fmt.Sprintf("%s, to_id, state_change_order", columns) } - // We always return the to_id, state_change_order since those are the primary keys. - // This is used for subsequent queries for operation and transactions of a state change. + query := fmt.Sprintf(` - SELECT to_id, state_change_order, %s, tx_hash + SELECT %s, tx_hash FROM state_changes WHERE tx_hash = ANY($1) ORDER BY to_id DESC, state_change_order DESC @@ -283,11 +319,14 @@ func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []st func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string) ([]*types.StateChange, error) { if columns == "" { columns = "*" + } else { + columns = fmt.Sprintf("%s, to_id, state_change_order", columns) } + // We always return the to_id, state_change_order since those are the primary keys. // This is used for subsequent queries for operation and transactions of a state change. query := fmt.Sprintf(` - SELECT to_id, state_change_order, %s, operation_id + SELECT %s, operation_id FROM state_changes WHERE operation_id = ANY($1) ORDER BY to_id DESC, state_change_order DESC diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 3d46f88e..82ee3b2a 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -201,7 +201,7 @@ func TestStateChangeModel_BatchInsert(t *testing.T) { } } -func TestStateChangeModel_BatchGetByAccountAddresses(t *testing.T) { +func TestStateChangeModel_BatchGetByAccountAddress(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -238,8 +238,8 @@ func TestStateChangeModel_BatchGetByAccountAddresses(t *testing.T) { require.NoError(t, err) mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return().Times(2) + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return().Times(2) defer mockMetricsService.AssertExpectations(t) m := &StateChangeModel{ @@ -247,18 +247,21 @@ func TestStateChangeModel_BatchGetByAccountAddresses(t *testing.T) { MetricsService: mockMetricsService, } - // Test BatchGetByAccount - stateChanges, err := m.BatchGetByAccountAddresses(ctx, []string{address1, address2}, "") + // Test BatchGetByAccount for address1 + stateChanges, err := m.BatchGetByAccountAddress(ctx, address1, "", nil, nil, ASC) require.NoError(t, err) - assert.Len(t, stateChanges, 3) + assert.Len(t, stateChanges, 2) + for _, sc := range stateChanges { + assert.Equal(t, address1, sc.AccountID) + } - // Verify state changes are for correct accounts - accountsFound := make(map[string]int) + // Test BatchGetByAccount for address2 + stateChanges, err = m.BatchGetByAccountAddress(ctx, address2, "", nil, nil, ASC) + require.NoError(t, err) + assert.Len(t, stateChanges, 1) for _, sc := range stateChanges { - accountsFound[sc.AccountID]++ + assert.Equal(t, address2, sc.AccountID) } - assert.Equal(t, 2, accountsFound[address1]) - assert.Equal(t, 1, accountsFound[address2]) } func TestStateChangeModel_GetAll(t *testing.T) { diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index f11f2e49..c83f82dc 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -224,6 +224,16 @@ type StateChange struct { TxID int64 `json:"-"` } +type StateChangeWithCursor struct { + StateChange + Cursor StateChangeCursor `db:"cursor"` +} + +type StateChangeCursor struct { + ToID int64 `db:"cursor_to_id"` + StateChangeOrder int64 `db:"cursor_state_change_order"` +} + type NullableJSONB map[string]any // NullableJSON represents a nullable JSON array of strings diff --git a/internal/serve/graphql/dataloaders/loaders.go b/internal/serve/graphql/dataloaders/loaders.go index 4739c394..d345892f 100644 --- a/internal/serve/graphql/dataloaders/loaders.go +++ b/internal/serve/graphql/dataloaders/loaders.go @@ -21,18 +21,6 @@ type Dataloaders struct { // Used by Transaction.operations field resolver to prevent N+1 queries OperationsByTxHashLoader *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] - // TransactionsByAccountLoader batches requests for transactions by account address - // Used by Account.transactions field resolver to prevent N+1 queries - TransactionsByAccountLoader *dataloadgen.Loader[TransactionColumnsKey, []*types.Transaction] - - // OperationsByAccountLoader batches requests for operations by account address - // Used by Account.operations field resolver to prevent N+1 queries - OperationsByAccountLoader *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] - - // StateChangesByAccountLoader batches requests for state changes by account address - // Used by Account.statechanges field resolver to prevent N+1 queries - StateChangesByAccountLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] - // AccountsByTxHashLoader batches requests for accounts by transaction hash // Used by Transaction.accounts field resolver to prevent N+1 queries AccountsByTxHashLoader *dataloadgen.Loader[AccountColumnsKey, []*types.Account] @@ -72,7 +60,6 @@ func NewDataloaders(models *data.Models) *Dataloaders { OperationByStateChangeIDLoader: operationByStateChangeIDLoader(models), TransactionByStateChangeIDLoader: transactionByStateChangeIDLoader(models), TransactionsByOperationIDLoader: transactionByOperationIDLoader(models), - StateChangesByAccountLoader: StateChangesByAccountLoader(models), StateChangesByTxHashLoader: stateChangesByTxHashLoader(models), StateChangesByOperationIDLoader: stateChangesByOperationIDLoader(models), AccountsByTxHashLoader: accountsByTxHashLoader(models), diff --git a/internal/serve/graphql/dataloaders/statechange_loaders.go b/internal/serve/graphql/dataloaders/statechange_loaders.go index 056da54e..8d5bc634 100644 --- a/internal/serve/graphql/dataloaders/statechange_loaders.go +++ b/internal/serve/graphql/dataloaders/statechange_loaders.go @@ -16,29 +16,6 @@ type StateChangeColumnsKey struct { Columns string } -// StateChangesByAccountLoader creates a dataloader for fetching state changes by account address -func StateChangesByAccountLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { - return newOneToManyLoader( - func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChange, error) { - accountIDs := make([]string, len(keys)) - columns := keys[0].Columns - for i, key := range keys { - accountIDs[i] = key.AccountID - } - return models.StateChanges.BatchGetByAccountAddresses(ctx, accountIDs, columns) - }, - func(item *types.StateChange) string { - return item.AccountID - }, - func(key StateChangeColumnsKey) string { - return key.AccountID - }, - func(item *types.StateChange) types.StateChange { - return *item - }, - ) -} - // stateChangesByTxHashLoader creates a dataloader for fetching state changes by transaction hash // This prevents N+1 queries when multiple transactions request their state changes // The loader batches multiple transaction hashes into a single database query diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 79e7d774..345962fe 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -56,7 +56,7 @@ type ComplexityRoot struct { Account struct { Address func(childComplexity int) int Operations func(childComplexity int, first *int32, after *string, last *int32, before *string) int - StateChanges func(childComplexity int) int + StateChanges func(childComplexity int, first *int32, after *string, last *int32, before *string) int Transactions func(childComplexity int, first *int32, after *string, last *int32, before *string) int } @@ -142,6 +142,16 @@ type ComplexityRoot struct { Transaction func(childComplexity int) int } + StateChangeConnection struct { + Edges func(childComplexity int) int + PageInfo func(childComplexity int) int + } + + StateChangeEdge struct { + Cursor func(childComplexity int) int + Node func(childComplexity int) int + } + Transaction struct { Accounts func(childComplexity int) int EnvelopeXDR func(childComplexity int) int @@ -170,7 +180,7 @@ type AccountResolver interface { Address(ctx context.Context, obj *types.Account) (string, error) Transactions(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*TransactionConnection, error) Operations(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*OperationConnection, error) - StateChanges(ctx context.Context, obj *types.Account) ([]*types.StateChange, error) + StateChanges(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) } type MutationResolver interface { RegisterAccount(ctx context.Context, input RegisterAccountInput) (*RegisterAccountPayload, error) @@ -255,7 +265,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Account.StateChanges(childComplexity), true + args, err := ec.field_Account_stateChanges_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Account.StateChanges(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "Account.transactions": if e.complexity.Account.Transactions == nil { @@ -673,6 +688,34 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.StateChange.Transaction(childComplexity), true + case "StateChangeConnection.edges": + if e.complexity.StateChangeConnection.Edges == nil { + break + } + + return e.complexity.StateChangeConnection.Edges(childComplexity), true + + case "StateChangeConnection.pageInfo": + if e.complexity.StateChangeConnection.PageInfo == nil { + break + } + + return e.complexity.StateChangeConnection.PageInfo(childComplexity), true + + case "StateChangeEdge.cursor": + if e.complexity.StateChangeEdge.Cursor == nil { + break + } + + return e.complexity.StateChangeEdge.Cursor(childComplexity), true + + case "StateChangeEdge.node": + if e.complexity.StateChangeEdge.Node == nil { + break + } + + return e.complexity.StateChangeEdge.Node(childComplexity), true + case "Transaction.accounts": if e.complexity.Transaction.Accounts == nil { break @@ -897,7 +940,7 @@ type Account{ # All state changes associated with this account # Uses resolver to fetch related state changes - stateChanges: [StateChange!]! + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } `, BuiltIn: false}, {Name: "../schema/directives.graphqls", Input: `# GraphQL Directive - provides metadata to control gqlgen code generation @@ -1099,6 +1142,16 @@ type OperationEdge { cursor: String! } +type StateChangeConnection { + edges: [StateChangeEdge!] + pageInfo: PageInfo! +} + +type StateChangeEdge { + node: StateChange + cursor: String! +} + type PageInfo { startCursor: String endCursor: String @@ -1280,6 +1333,83 @@ func (ec *executionContext) field_Account_operations_argsBefore( return zeroVal, nil } +func (ec *executionContext) field_Account_stateChanges_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Account_stateChanges_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := ec.field_Account_stateChanges_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Account_stateChanges_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Account_stateChanges_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} +func (ec *executionContext) field_Account_stateChanges_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Account_stateChanges_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Account_stateChanges_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Account_stateChanges_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field_Account_transactions_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1838,24 +1968,21 @@ func (ec *executionContext) _Account_stateChanges(ctx context.Context, field gra }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Account().StateChanges(rctx, obj) + return ec.resolvers.Account().StateChanges(rctx, obj, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.StateChange) + res := resTmp.(*StateChangeConnection) fc.Result = res - return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) + return ec.marshalOStateChangeConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Account_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Account_stateChanges(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Account", Field: field, @@ -1863,52 +1990,25 @@ func (ec *executionContext) fieldContext_Account_stateChanges(_ context.Context, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "accountId": - return ec.fieldContext_StateChange_accountId(ctx, field) - case "stateChangeCategory": - return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) - case "stateChangeReason": - return ec.fieldContext_StateChange_stateChangeReason(ctx, field) - case "ingestedAt": - return ec.fieldContext_StateChange_ingestedAt(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) - case "ledgerNumber": - return ec.fieldContext_StateChange_ledgerNumber(ctx, field) - case "tokenId": - return ec.fieldContext_StateChange_tokenId(ctx, field) - case "amount": - return ec.fieldContext_StateChange_amount(ctx, field) - case "claimableBalanceId": - return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) - case "liquidityPoolId": - return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) - case "offerId": - return ec.fieldContext_StateChange_offerId(ctx, field) - case "signerAccountId": - return ec.fieldContext_StateChange_signerAccountId(ctx, field) - case "spenderAccountId": - return ec.fieldContext_StateChange_spenderAccountId(ctx, field) - case "sponsoredAccountId": - return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) - case "sponsorAccountId": - return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) - case "signerWeights": - return ec.fieldContext_StateChange_signerWeights(ctx, field) - case "thresholds": - return ec.fieldContext_StateChange_thresholds(ctx, field) - case "flags": - return ec.fieldContext_StateChange_flags(ctx, field) - case "keyValue": - return ec.fieldContext_StateChange_keyValue(ctx, field) - case "operation": - return ec.fieldContext_StateChange_operation(ctx, field) - case "transaction": - return ec.fieldContext_StateChange_transaction(ctx, field) + case "edges": + return ec.fieldContext_StateChangeConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_StateChangeConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + return nil, fmt.Errorf("no field named %q was found under type StateChangeConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Account_stateChanges_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -4650,6 +4750,236 @@ func (ec *executionContext) fieldContext_StateChange_transaction(_ context.Conte return fc, nil } +func (ec *executionContext) _StateChangeConnection_edges(ctx context.Context, field graphql.CollectedField, obj *StateChangeConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChangeConnection_edges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Edges, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]*StateChangeEdge) + fc.Result = res + return ec.marshalOStateChangeEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeEdgeᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChangeConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChangeConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "node": + return ec.fieldContext_StateChangeEdge_node(ctx, field) + case "cursor": + return ec.fieldContext_StateChangeEdge_cursor(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StateChangeEdge", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChangeConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *StateChangeConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChangeConnection_pageInfo(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.PageInfo, 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.(*PageInfo) + fc.Result = res + return ec.marshalNPageInfo2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐPageInfo(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChangeConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChangeConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "startCursor": + return ec.fieldContext_PageInfo_startCursor(ctx, field) + case "endCursor": + return ec.fieldContext_PageInfo_endCursor(ctx, field) + case "hasNextPage": + return ec.fieldContext_PageInfo_hasNextPage(ctx, field) + case "hasPreviousPage": + return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChangeEdge_node(ctx context.Context, field graphql.CollectedField, obj *StateChangeEdge) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChangeEdge_node(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Node, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*types.StateChange) + fc.Result = res + return ec.marshalOStateChange2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChange(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_StateChangeEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChangeEdge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "accountId": + return ec.fieldContext_StateChange_accountId(ctx, field) + case "stateChangeCategory": + return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) + case "stateChangeReason": + return ec.fieldContext_StateChange_stateChangeReason(ctx, field) + case "ingestedAt": + return ec.fieldContext_StateChange_ingestedAt(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) + case "ledgerNumber": + return ec.fieldContext_StateChange_ledgerNumber(ctx, field) + case "tokenId": + return ec.fieldContext_StateChange_tokenId(ctx, field) + case "amount": + return ec.fieldContext_StateChange_amount(ctx, field) + case "claimableBalanceId": + return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) + case "liquidityPoolId": + return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) + case "offerId": + return ec.fieldContext_StateChange_offerId(ctx, field) + case "signerAccountId": + return ec.fieldContext_StateChange_signerAccountId(ctx, field) + case "spenderAccountId": + return ec.fieldContext_StateChange_spenderAccountId(ctx, field) + case "sponsoredAccountId": + return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) + case "sponsorAccountId": + return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) + case "signerWeights": + return ec.fieldContext_StateChange_signerWeights(ctx, field) + case "thresholds": + return ec.fieldContext_StateChange_thresholds(ctx, field) + case "flags": + return ec.fieldContext_StateChange_flags(ctx, field) + case "keyValue": + return ec.fieldContext_StateChange_keyValue(ctx, field) + case "operation": + return ec.fieldContext_StateChange_operation(ctx, field) + case "transaction": + return ec.fieldContext_StateChange_transaction(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _StateChangeEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *StateChangeEdge) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_StateChangeEdge_cursor(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Cursor, 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_StateChangeEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StateChangeEdge", + Field: field, + IsMethod: false, + 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_hash(ctx context.Context, field graphql.CollectedField, obj *types.Transaction) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Transaction_hash(ctx, field) if err != nil { @@ -7631,16 +7961,13 @@ func (ec *executionContext) _Account(ctx context.Context, sel ast.SelectionSet, case "stateChanges": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._Account_stateChanges(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -8891,6 +9218,88 @@ func (ec *executionContext) _StateChange(ctx context.Context, sel ast.SelectionS return out } +var stateChangeConnectionImplementors = []string{"StateChangeConnection"} + +func (ec *executionContext) _StateChangeConnection(ctx context.Context, sel ast.SelectionSet, obj *StateChangeConnection) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, stateChangeConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("StateChangeConnection") + case "edges": + out.Values[i] = ec._StateChangeConnection_edges(ctx, field, obj) + case "pageInfo": + out.Values[i] = ec._StateChangeConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var stateChangeEdgeImplementors = []string{"StateChangeEdge"} + +func (ec *executionContext) _StateChangeEdge(ctx context.Context, sel ast.SelectionSet, obj *StateChangeEdge) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, stateChangeEdgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("StateChangeEdge") + case "node": + out.Values[i] = ec._StateChangeEdge_node(ctx, field, obj) + case "cursor": + out.Values[i] = ec._StateChangeEdge_cursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var transactionImplementors = []string{"Transaction"} func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionSet, obj *types.Transaction) graphql.Marshaler { @@ -9806,6 +10215,16 @@ func (ec *executionContext) marshalNStateChangeCategory2githubᚗcomᚋstellar return res } +func (ec *executionContext) marshalNStateChangeEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeEdge(ctx context.Context, sel ast.SelectionSet, v *StateChangeEdge) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._StateChangeEdge(ctx, sel, v) +} + func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { res, err := graphql.UnmarshalString(v) return res, graphql.ErrorOnPath(ctx, err) @@ -10334,6 +10753,67 @@ func (ec *executionContext) unmarshalOSimulationResultInput2ᚖgithubᚗcomᚋst return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalOStateChange2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChange(ctx context.Context, sel ast.SelectionSet, v *types.StateChange) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._StateChange(ctx, sel, v) +} + +func (ec *executionContext) marshalOStateChangeConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeConnection(ctx context.Context, sel ast.SelectionSet, v *StateChangeConnection) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._StateChangeConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalOStateChangeEdge2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []*StateChangeEdge) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNStateChangeEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeEdge(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalOStateChangeReason2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeReason(ctx context.Context, v any) (*types.StateChangeReason, error) { if v == nil { return nil, nil diff --git a/internal/serve/graphql/generated/models_gen.go b/internal/serve/graphql/generated/models_gen.go index d6fd7bfe..efe8126c 100644 --- a/internal/serve/graphql/generated/models_gen.go +++ b/internal/serve/graphql/generated/models_gen.go @@ -65,6 +65,16 @@ type SimulationResultInput struct { Error *string `json:"error,omitempty"` } +type StateChangeConnection struct { + Edges []*StateChangeEdge `json:"edges,omitempty"` + PageInfo *PageInfo `json:"pageInfo"` +} + +type StateChangeEdge struct { + Node *types.StateChange `json:"node,omitempty"` + Cursor string `json:"cursor"` +} + type TransactionConnection struct { Edges []*TransactionEdge `json:"edges,omitempty"` PageInfo *PageInfo `json:"pageInfo"` diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go index 8d52bdf9..44858553 100644 --- a/internal/serve/graphql/resolvers/account.resolvers.go +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -10,9 +10,7 @@ import ( "strings" "github.com/stellar/wallet-backend/internal/indexer/types" - "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" - "github.com/stellar/wallet-backend/internal/serve/middleware" ) // Address is the resolver for the address field. @@ -25,7 +23,7 @@ func (r *accountResolver) Address(ctx context.Context, obj *types.Account) (stri // gqlgen calls this when a GraphQL query requests the transactions field on an Account // Field resolvers receive the parent object (Account) and return the field value func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*graphql1.TransactionConnection, error) { - params, err := parsePaginationParams(first, after, last, before, 100) + params, err := parsePaginationParams(first, after, last, before, false) if err != nil { return nil, fmt.Errorf("parsing pagination params: %w", err) } @@ -58,7 +56,7 @@ func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account, // Operations is the resolver for the operations field. // This field resolver handles the "operations" field on an Account object func (r *accountResolver) Operations(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*graphql1.OperationConnection, error) { - params, err := parsePaginationParams(first, after, last, before, 100) + params, err := parsePaginationParams(first, after, last, before, false) if err != nil { return nil, fmt.Errorf("parsing pagination params: %w", err) } @@ -89,20 +87,35 @@ func (r *accountResolver) Operations(ctx context.Context, obj *types.Account, fi } // StateChanges is the resolver for the stateChanges field. -func (r *accountResolver) StateChanges(ctx context.Context, obj *types.Account) ([]*types.StateChange, error) { - loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "state_changes") - - // Use dataloader to batch-load state changes for this account - loaderKey := dataloaders.StateChangeColumnsKey{ - AccountID: obj.StellarAddress, - Columns: strings.Join(dbColumns, ", "), +func (r *accountResolver) StateChanges(ctx context.Context, obj *types.Account, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { + params, err := parsePaginationParams(first, after, last, before, true) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) } - stateChanges, err := loaders.StateChangesByAccountLoader.Load(ctx, loaderKey) + queryLimit := *params.Limit + 1 // +1 to check if there is a next page + + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "state_changes") + stateChanges, err := r.models.StateChanges.BatchGetByAccountAddress(ctx, obj.StellarAddress, strings.Join(dbColumns, ", "), &queryLimit, params.StateChangeCursor, params.SortOrder) if err != nil { - return nil, err + return nil, fmt.Errorf("getting state changes from db for account %s: %w", obj.StellarAddress, err) + } + + conn := NewConnectionWithRelayPagination(stateChanges, params, func(sc *types.StateChangeWithCursor) string { + return fmt.Sprintf("%d:%d", sc.Cursor.ToID, sc.Cursor.StateChangeOrder) + }) + + edges := make([]*graphql1.StateChangeEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.StateChangeEdge{ + Node: &edge.Node.StateChange, + Cursor: edge.Cursor, + } } - return stateChanges, nil + + return &graphql1.StateChangeConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // Account returns graphql1.AccountResolver implementation. diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index aabeb3fd..41d4b3f9 100644 --- a/internal/serve/graphql/resolvers/account_resolvers_test.go +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -1,7 +1,6 @@ package resolvers import ( - "context" "fmt" "testing" @@ -9,11 +8,11 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/stellar/go/toid" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/metrics" - "github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders" - "github.com/stellar/wallet-backend/internal/serve/middleware" ) func TestAccountResolver_Transactions(t *testing.T) { @@ -162,25 +161,25 @@ func TestAccountResolver_Operations(t *testing.T) { }} t.Run("get all operations", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_type"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) operations, err := resolver.Operations(ctx, parentAccount, nil, nil, nil, nil) require.NoError(t, err) require.Len(t, operations.Edges, 8) - assert.Equal(t, int64(1001), operations.Edges[0].Node.ID) - assert.Equal(t, int64(1002), operations.Edges[1].Node.ID) - assert.Equal(t, int64(1003), operations.Edges[2].Node.ID) - assert.Equal(t, int64(1004), operations.Edges[3].Node.ID) + assert.Equal(t, "opxdr1", operations.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr2", operations.Edges[1].Node.OperationXDR) + assert.Equal(t, "opxdr3", operations.Edges[2].Node.OperationXDR) + assert.Equal(t, "opxdr4", operations.Edges[3].Node.OperationXDR) }) t.Run("get operations with first/after limit and cursor", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_type"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) first := int32(2) ops, err := resolver.Operations(ctx, parentAccount, &first, nil, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, int64(1001), ops.Edges[0].Node.ID) - assert.Equal(t, int64(1002), ops.Edges[1].Node.ID) + assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) @@ -190,8 +189,8 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, int64(1003), ops.Edges[0].Node.ID) - assert.Equal(t, int64(1004), ops.Edges[1].Node.ID) + assert.Equal(t, "opxdr3", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr4", ops.Edges[1].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -201,22 +200,22 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 4) - assert.Equal(t, int64(1005), ops.Edges[0].Node.ID) - assert.Equal(t, int64(1006), ops.Edges[1].Node.ID) - assert.Equal(t, int64(1007), ops.Edges[2].Node.ID) - assert.Equal(t, int64(1008), ops.Edges[3].Node.ID) + assert.Equal(t, "opxdr5", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr6", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, "opxdr7", ops.Edges[2].Node.OperationXDR) + assert.Equal(t, "opxdr8", ops.Edges[3].Node.OperationXDR) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) }) t.Run("get operations with last/before limit and cursor", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"operation_type"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) last := int32(2) ops, err := resolver.Operations(ctx, parentAccount, nil, nil, &last, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, int64(1007), ops.Edges[0].Node.ID) - assert.Equal(t, int64(1008), ops.Edges[1].Node.ID) + assert.Equal(t, "opxdr7", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr8", ops.Edges[1].Node.OperationXDR) assert.True(t, ops.PageInfo.HasPreviousPage) assert.False(t, ops.PageInfo.HasNextPage) @@ -226,8 +225,8 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, int64(1005), ops.Edges[0].Node.ID) - assert.Equal(t, int64(1006), ops.Edges[1].Node.ID) + assert.Equal(t, "opxdr5", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr6", ops.Edges[1].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -237,10 +236,10 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, ops.Edges, 4) - assert.Equal(t, int64(1001), ops.Edges[0].Node.ID) - assert.Equal(t, int64(1002), ops.Edges[1].Node.ID) - assert.Equal(t, int64(1003), ops.Edges[2].Node.ID) - assert.Equal(t, int64(1004), ops.Edges[3].Node.ID) + assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, "opxdr3", ops.Edges[2].Node.OperationXDR) + assert.Equal(t, "opxdr4", ops.Edges[3].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) }) @@ -272,42 +271,103 @@ func TestAccountResolver_StateChanges(t *testing.T) { }, }} - t.Run("success", func(t *testing.T) { - loaders := &dataloaders.Dataloaders{ - StateChangesByAccountLoader: dataloaders.StateChangesByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("state_changes", []string{"to_id", "state_change_order"}), middleware.LoadersKey, loaders) - stateChanges, err := resolver.StateChanges(ctx, parentAccount) + t.Run("get all state changes", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{""}) + stateChanges, err := resolver.StateChanges(ctx, parentAccount, nil, nil, nil, nil) require.NoError(t, err) - require.Len(t, stateChanges, 20) + require.Len(t, stateChanges.Edges, 20) // With 16 state changes ordered by ToID descending, check first few - assert.Equal(t, "1008:2", fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) - assert.Equal(t, "1008:1", fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) - assert.Equal(t, "1007:2", fmt.Sprintf("%d:%d", stateChanges[2].ToID, stateChanges[2].StateChangeOrder)) - assert.Equal(t, "1007:1", fmt.Sprintf("%d:%d", stateChanges[3].ToID, stateChanges[3].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[3].Node.ToID, stateChanges.Edges[3].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[3].Node.ToID, stateChanges.Edges[3].Node.StateChangeOrder)) + }) + + t.Run("get state changes with first/after limit and cursor", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{""}) + first := int32(3) + stateChanges, err := resolver.StateChanges(ctx, parentAccount, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 3) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) + + // Get the next cursor + nextCursor := stateChanges.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + stateChanges, err = resolver.StateChanges(ctx, parentAccount, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 3) + // Fee state change with no operation + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 2, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + + // Get the next cursor + first = int32(100) + nextCursor = stateChanges.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + stateChanges, err = resolver.StateChanges(ctx, parentAccount, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 14) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 2, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 2, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 2, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 2, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[3].Node.ToID, stateChanges.Edges[3].Node.StateChangeOrder)) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) }) - t.Run("nil account panics", func(t *testing.T) { - loaders := &dataloaders.Dataloaders{ - StateChangesByAccountLoader: dataloaders.StateChangesByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("state_changes", []string{"to_id", "state_change_order"}), middleware.LoadersKey, loaders) + t.Run("get state changes with last/before limit and cursor", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{""}) + last := int32(3) + stateChanges, err := resolver.StateChanges(ctx, parentAccount, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 3) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + assert.False(t, stateChanges.PageInfo.HasNextPage) - assert.Panics(t, func() { - _, _ = resolver.StateChanges(ctx, nil) //nolint:errcheck - }) + // Get the next cursor + nextCursor := stateChanges.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + stateChanges, err = resolver.StateChanges(ctx, parentAccount, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 3) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 3, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 4, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 4, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + + nextCursor = stateChanges.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + last = int32(100) + stateChanges, err = resolver.StateChanges(ctx, parentAccount, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 14) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) t.Run("account with no state changes", func(t *testing.T) { nonExistentAccount := &types.Account{StellarAddress: "non-existent-account"} - loaders := &dataloaders.Dataloaders{ - StateChangesByAccountLoader: dataloaders.StateChangesByAccountLoader(resolver.models), - } - ctx := context.WithValue(getTestCtx("state_changes", []string{"to_id", "state_change_order"}), middleware.LoadersKey, loaders) - stateChanges, err := resolver.StateChanges(ctx, nonExistentAccount) + ctx := getTestCtx("state_changes", []string{"to_id", "state_change_order"}) + stateChanges, err := resolver.StateChanges(ctx, nonExistentAccount, nil, nil, nil, nil) require.NoError(t, err) - assert.Empty(t, stateChanges) + assert.Empty(t, stateChanges.Edges) }) } diff --git a/internal/serve/graphql/resolvers/operation_resolvers_test.go b/internal/serve/graphql/resolvers/operation_resolvers_test.go index 72a8b85d..dfc6c2e5 100644 --- a/internal/serve/graphql/resolvers/operation_resolvers_test.go +++ b/internal/serve/graphql/resolvers/operation_resolvers_test.go @@ -2,8 +2,10 @@ package resolvers import ( "context" + "fmt" "testing" + "github.com/stellar/go/toid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -29,7 +31,7 @@ func TestOperationResolver_Transaction(t *testing.T) { }, }, }} - parentOperation := &types.Operation{ID: 1001} + parentOperation := &types.Operation{ID: toid.New(1000, 1, 1).ToInt64()} t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) @@ -77,7 +79,7 @@ func TestOperationResolver_Accounts(t *testing.T) { }, }, }} - parentOperation := &types.Operation{ID: 1001} + parentOperation := &types.Operation{ID: toid.New(1000, 1, 1).ToInt64()} t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) @@ -129,7 +131,7 @@ func TestOperationResolver_StateChanges(t *testing.T) { }, }, }} - parentOperation := &types.Operation{ID: 1001} + parentOperation := &types.Operation{ID: toid.New(1000, 1, 1).ToInt64()} t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) @@ -139,10 +141,8 @@ func TestOperationResolver_StateChanges(t *testing.T) { require.NoError(t, err) require.Len(t, stateChanges, 2) - assert.Equal(t, int64(1001), stateChanges[0].ToID) - assert.Equal(t, int64(2), stateChanges[0].StateChangeOrder) - assert.Equal(t, int64(1001), stateChanges[1].ToID) - assert.Equal(t, int64(1), stateChanges[1].StateChangeOrder) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) }) t.Run("nil operation panics", func(t *testing.T) { diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index 6aa41ecf..34e41d0e 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -1,8 +1,10 @@ package resolvers import ( + "fmt" "testing" + "github.com/stellar/go/toid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -34,7 +36,7 @@ func TestQueryResolver_TransactionByHash(t *testing.T) { require.NoError(t, err) assert.Equal(t, "tx1", tx.Hash) - assert.Equal(t, int64(1), tx.ToID) + assert.Equal(t, toid.New(1000, 1, 0).ToInt64(), tx.ToID) assert.Equal(t, "envelope1", tx.EnvelopeXDR) assert.Equal(t, "result1", tx.ResultXDR) assert.Equal(t, "meta1", tx.MetaXDR) @@ -231,15 +233,12 @@ func TestQueryResolver_StateChanges(t *testing.T) { t.Run("get with limit", func(t *testing.T) { limit := int32(3) ctx := getTestCtx("state_changes", []string{"stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) - scs, err := resolver.StateChanges(ctx, &limit) + stateChanges, err := resolver.StateChanges(ctx, &limit) require.NoError(t, err) - assert.Len(t, scs, 3) - assert.Equal(t, int64(1008), scs[0].ToID) - assert.Equal(t, int64(2), scs[0].StateChangeOrder) - assert.Equal(t, int64(1008), scs[1].ToID) - assert.Equal(t, int64(1), scs[1].StateChangeOrder) - assert.Equal(t, int64(1007), scs[2].ToID) - assert.Equal(t, int64(2), scs[2].StateChangeOrder) + assert.Len(t, stateChanges, 3) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[2].ToID, stateChanges[2].StateChangeOrder)) }) t.Run("negative limit error", func(t *testing.T) { diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go index 1f96cc92..74497320 100644 --- a/internal/serve/graphql/resolvers/statechange_resolvers_test.go +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "testing" + "github.com/stellar/go/toid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -162,7 +163,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { }, }, }} - parentSC := &types.StateChange{ToID: 1001, StateChangeOrder: 1} + parentSC := &types.StateChange{ToID: toid.New(1000, 1, 1).ToInt64(), StateChangeOrder: 1} t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) @@ -170,7 +171,7 @@ func TestStateChangeResolver_Operation(t *testing.T) { op, err := resolver.Operation(ctx, parentSC) require.NoError(t, err) - assert.Equal(t, int64(1001), op.ID) + assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), op.ID) }) t.Run("nil state change panics", func(t *testing.T) { @@ -207,7 +208,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { }, }, }} - parentSC := &types.StateChange{ToID: 1, StateChangeOrder: 1} + parentSC := &types.StateChange{ToID: toid.New(1000, 1, 0).ToInt64(), StateChangeOrder: 1} t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) diff --git a/internal/serve/graphql/resolvers/test_utils.go b/internal/serve/graphql/resolvers/test_utils.go index 77daf454..f751255c 100644 --- a/internal/serve/graphql/resolvers/test_utils.go +++ b/internal/serve/graphql/resolvers/test_utils.go @@ -7,6 +7,7 @@ import ( "time" "github.com/99designs/gqlgen/graphql" + "github.com/stellar/go/toid" "github.com/stretchr/testify/require" "github.com/vektah/gqlparser/v2/ast" @@ -41,27 +42,27 @@ func getTestCtx(table string, columns []string) context.Context { } func setupDB(ctx context.Context, t *testing.T, dbConnectionPool db.ConnectionPool) { + testLedger := int32(1000) parentAccount := &types.Account{StellarAddress: "test-account"} txns := make([]*types.Transaction, 0, 4) ops := make([]*types.Operation, 0, 8) + opIdx := 1 for i := range 4 { - txns = append(txns, &types.Transaction{ + txn := &types.Transaction{ Hash: fmt.Sprintf("tx%d", i+1), - ToID: int64(i + 1), + ToID: toid.New(testLedger, int32(i+1), 0).ToInt64(), EnvelopeXDR: fmt.Sprintf("envelope%d", i+1), ResultXDR: fmt.Sprintf("result%d", i+1), MetaXDR: fmt.Sprintf("meta%d", i+1), LedgerNumber: 1, LedgerCreatedAt: time.Now(), - }) - } + } + txns = append(txns, txn) - // Add 2 operations for each transaction - opIdx := 1 - for _, txn := range txns { - for range 2 { + // Add 2 operations for each transaction + for j := range 2 { ops = append(ops, &types.Operation{ - ID: int64(opIdx + 1000), + ID: toid.New(testLedger, int32(i+1), int32(j+1)).ToInt64(), TxHash: txn.Hash, OperationType: "payment", OperationXDR: fmt.Sprintf("opxdr%d", opIdx), @@ -97,7 +98,7 @@ func setupDB(ctx context.Context, t *testing.T, dbConnectionPool db.ConnectionPo TxHash: txn.Hash, AccountID: parentAccount.StellarAddress, LedgerCreatedAt: time.Now(), - LedgerNumber: 1, + LedgerNumber: 1000, }) } diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index ac06103d..eb5baa54 100644 --- a/internal/serve/graphql/resolvers/transaction_resolvers_test.go +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -2,12 +2,15 @@ package resolvers import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/stellar/go/toid" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/indexer/types" "github.com/stellar/wallet-backend/internal/metrics" @@ -39,8 +42,8 @@ func TestTransactionResolver_Operations(t *testing.T) { require.NoError(t, err) require.Len(t, operations, 2) - assert.Equal(t, int64(1001), operations[0].ID) - assert.Equal(t, int64(1002), operations[1].ID) + assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), operations[0].ID) + assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), operations[1].ID) }) t.Run("nil transaction panics", func(t *testing.T) { @@ -141,16 +144,11 @@ func TestTransactionResolver_StateChanges(t *testing.T) { require.NoError(t, err) require.Len(t, stateChanges, 5) // For tx1: operations 1 and 2, each with 2 state changes and 1 fee change - assert.Equal(t, int64(1002), stateChanges[0].ToID) - assert.Equal(t, int64(2), stateChanges[0].StateChangeOrder) - assert.Equal(t, int64(1002), stateChanges[1].ToID) - assert.Equal(t, int64(1), stateChanges[1].StateChangeOrder) - assert.Equal(t, int64(1001), stateChanges[2].ToID) - assert.Equal(t, int64(2), stateChanges[2].StateChangeOrder) - assert.Equal(t, int64(1001), stateChanges[3].ToID) - assert.Equal(t, int64(1), stateChanges[3].StateChangeOrder) - assert.Equal(t, int64(1), stateChanges[4].ToID) - assert.Equal(t, int64(1), stateChanges[4].StateChangeOrder) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[2].ToID, stateChanges[2].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[3].ToID, stateChanges[3].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[4].ToID, stateChanges[4].StateChangeOrder)) }) t.Run("nil transaction panics", func(t *testing.T) { diff --git a/internal/serve/graphql/resolvers/utils.go b/internal/serve/graphql/resolvers/utils.go index 374af8ab..50f46f56 100644 --- a/internal/serve/graphql/resolvers/utils.go +++ b/internal/serve/graphql/resolvers/utils.go @@ -11,9 +11,14 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/stellar/wallet-backend/internal/data" + "github.com/stellar/wallet-backend/internal/indexer/types" generated "github.com/stellar/wallet-backend/internal/serve/graphql/generated" ) +const ( + DefaultLimit = int32(50) +) + // GenericEdge is a generic wrapper for a GraphQL edge. type GenericEdge[T any] struct { Node T @@ -29,6 +34,7 @@ type GenericConnection[T any] struct { type PaginationParams struct { Limit *int32 Cursor *int64 + StateChangeCursor *types.StateChangeCursor ForwardPagination bool SortOrder data.SortOrder } @@ -43,14 +49,14 @@ func NewConnectionWithRelayPagination[T any, C int64 | string](nodes []T, params hasNextPage = true nodes = nodes[:*params.Limit] } - hasPreviousPage = params.Cursor != nil + hasPreviousPage = (params.Cursor != nil || params.StateChangeCursor != nil) } else { if int32(len(nodes)) > *params.Limit { hasPreviousPage = true nodes = nodes[1:] } // In backward pagination, presence of a before-cursor implies there may be newer items (a "next page") - hasNextPage = params.Cursor != nil + hasNextPage = (params.Cursor != nil || params.StateChangeCursor != nil) } edges := make([]*GenericEdge[T], len(nodes)) @@ -132,6 +138,20 @@ func decodeInt64Cursor(s *string) (*int64, error) { return &id, nil } +func decodeStringCursor(s *string) (*string, error) { + if s == nil { + return nil, nil + } + + decoded, err := base64.StdEncoding.DecodeString(*s) + if err != nil { + return nil, fmt.Errorf("decoding cursor string %s: %w", *s, err) + } + decodedStr := string(decoded) + + return &decodedStr, nil +} + func getDBColumns(model any, fields []graphql.CollectedField) []string { fieldToColumnMap := getColumnMap(model) dbColumns := make([]string, 0, len(fields)) @@ -170,14 +190,14 @@ func getColumnMap(model any) map[string]string { return fieldToColumnMap } -func parsePaginationParams(first *int32, after *string, last *int32, before *string, defaultLimit int32) (PaginationParams, error) { +func parsePaginationParams(first *int32, after *string, last *int32, before *string, isStateChange bool) (PaginationParams, error) { err := validatePaginationParams(first, after, last, before) if err != nil { return PaginationParams{}, fmt.Errorf("validating pagination params: %w", err) } var cursor *string - limit := defaultLimit + limit := DefaultLimit forwardPagination := true sortOrder := data.ASC if first != nil { @@ -190,16 +210,57 @@ func parsePaginationParams(first *int32, after *string, last *int32, before *str sortOrder = data.DESC } - decodedCursor, err := decodeInt64Cursor(cursor) + paginationParams := PaginationParams{ + Limit: &limit, + SortOrder: sortOrder, + ForwardPagination: forwardPagination, + } + + if isStateChange { + stateChangeCursor, err := parseStateChangeCursor(cursor) + if err != nil { + return PaginationParams{}, fmt.Errorf("parsing state change cursor: %w", err) + } + paginationParams.StateChangeCursor = stateChangeCursor + } else { + decodedCursor, err := decodeInt64Cursor(cursor) + if err != nil { + return PaginationParams{}, fmt.Errorf("decoding cursor: %w", err) + } + paginationParams.Cursor = decodedCursor + } + + return paginationParams, nil +} + +func parseStateChangeCursor(s *string) (*types.StateChangeCursor, error) { + if s == nil { + return nil, nil + } + + decodedCursor, err := decodeStringCursor(s) if err != nil { - return PaginationParams{}, fmt.Errorf("decoding cursor: %w", err) + return nil, fmt.Errorf("decoding cursor: %w", err) } - return PaginationParams{ - Cursor: decodedCursor, - Limit: &limit, - ForwardPagination: forwardPagination, - SortOrder: sortOrder, + parts := strings.Split(*decodedCursor, ":") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid cursor format: %s", *s) + } + + toID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing to_id: %w", err) + } + + stateChangeOrder, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing state_change_order: %w", err) + } + + return &types.StateChangeCursor{ + ToID: toID, + StateChangeOrder: stateChangeOrder, }, nil } diff --git a/internal/serve/graphql/schema/account.graphqls b/internal/serve/graphql/schema/account.graphqls index 9cc95f1f..4d05071c 100644 --- a/internal/serve/graphql/schema/account.graphqls +++ b/internal/serve/graphql/schema/account.graphqls @@ -14,5 +14,5 @@ type Account{ # All state changes associated with this account # Uses resolver to fetch related state changes - stateChanges: [StateChange!]! + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } diff --git a/internal/serve/graphql/schema/pagination.graphqls b/internal/serve/graphql/schema/pagination.graphqls index e47a5ed5..00020c75 100644 --- a/internal/serve/graphql/schema/pagination.graphqls +++ b/internal/serve/graphql/schema/pagination.graphqls @@ -18,6 +18,16 @@ type OperationEdge { cursor: String! } +type StateChangeConnection { + edges: [StateChangeEdge!] + pageInfo: PageInfo! +} + +type StateChangeEdge { + node: StateChange + cursor: String! +} + type PageInfo { startCursor: String endCursor: String From a44d6b704d383b8c347669c0685945efe6d96919 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Tue, 19 Aug 2025 15:11:11 -0400 Subject: [PATCH 13/16] Paginate through all transactions and operations (#293) --- internal/data/operations.go | 39 ++- internal/data/operations_test.go | 13 +- internal/data/transactions.go | 36 ++- internal/data/transactions_test.go | 13 +- internal/serve/graphql/generated/generated.go | 266 ++++++++-------- .../graphql/resolvers/queries.resolvers.go | 68 ++++- .../resolvers/queries_resolvers_test.go | 285 +++++++++++++++--- .../serve/graphql/schema/queries.graphqls | 4 +- 8 files changed, 519 insertions(+), 205 deletions(-) diff --git a/internal/data/operations.go b/internal/data/operations.go index b5698a26..ca55209f 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -19,23 +19,40 @@ type OperationModel struct { MetricsService metrics.MetricsService } -func (m *OperationModel) GetAll(ctx context.Context, limit *int32, columns string) ([]*types.Operation, error) { - if columns == "" { - columns = "*" +func (m *OperationModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *int64, sortOrder SortOrder) ([]*types.OperationWithCursor, error) { + columns = prepareColumnsWithID(columns, "operations", "id") + queryBuilder := strings.Builder{} + queryBuilder.WriteString(fmt.Sprintf(`SELECT %s, id as cursor FROM operations`, columns)) + + if cursor != nil { + if sortOrder == DESC { + queryBuilder.WriteString(fmt.Sprintf(" WHERE id < %d", *cursor)) + } else { + queryBuilder.WriteString(fmt.Sprintf(" WHERE id > %d", *cursor)) + } } - query := fmt.Sprintf(`SELECT %s FROM operations ORDER BY ledger_created_at DESC`, columns) - var args []interface{} - if limit != nil && *limit > 0 { - query += ` LIMIT $1` - args = append(args, *limit) + + if sortOrder == DESC { + queryBuilder.WriteString(" ORDER BY id DESC") + } else { + queryBuilder.WriteString(" ORDER BY id ASC") } - var operations []*types.Operation + + if limit != nil { + queryBuilder.WriteString(fmt.Sprintf(" LIMIT %d", *limit)) + } + query := queryBuilder.String() + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS operations ORDER BY cursor ASC`, query) + } + + var operations []*types.OperationWithCursor start := time.Now() - err := m.DB.SelectContext(ctx, &operations, query, args...) + err := m.DB.SelectContext(ctx, &operations, query) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) if err != nil { - return nil, fmt.Errorf("getting all operations: %w", err) + return nil, fmt.Errorf("getting operations: %w", err) } m.MetricsService.IncDBQuery("SELECT", "operations") return operations, nil diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index f1522cb2..019a0e58 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -256,16 +256,21 @@ func TestOperationModel_GetAll(t *testing.T) { `, now) require.NoError(t, err) - // Test GetAll without limit - operations, err := m.GetAll(ctx, nil, "") + // Test GetAll without limit (gets all operations) + operations, err := m.GetAll(ctx, "", nil, nil, ASC) require.NoError(t, err) assert.Len(t, operations, 3) + assert.Equal(t, int64(1), operations[0].Cursor) + assert.Equal(t, int64(2), operations[1].Cursor) + assert.Equal(t, int64(3), operations[2].Cursor) - // Test GetAll with limit + // Test GetAll with smaller limit limit := int32(2) - operations, err = m.GetAll(ctx, &limit, "") + operations, err = m.GetAll(ctx, "", &limit, nil, ASC) require.NoError(t, err) assert.Len(t, operations, 2) + assert.Equal(t, int64(1), operations[0].Cursor) + assert.Equal(t, int64(2), operations[1].Cursor) } func TestOperationModel_BatchGetByTxHashes(t *testing.T) { diff --git a/internal/data/transactions.go b/internal/data/transactions.go index 6ac4c71f..6f953bf2 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -36,21 +36,37 @@ func (m *TransactionModel) GetByHash(ctx context.Context, hash string, columns s return &transaction, nil } -func (m *TransactionModel) GetAll(ctx context.Context, limit *int32, columns string) ([]*types.Transaction, error) { - if columns == "" { - columns = "*" +func (m *TransactionModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *int64, sortOrder SortOrder) ([]*types.TransactionWithCursor, error) { + columns = prepareColumnsWithID(columns, "transactions", "to_id") + queryBuilder := strings.Builder{} + queryBuilder.WriteString(fmt.Sprintf(`SELECT %s, to_id as cursor FROM transactions`, columns)) + + if cursor != nil { + if sortOrder == DESC { + queryBuilder.WriteString(fmt.Sprintf(" WHERE to_id < %d", *cursor)) + } else { + queryBuilder.WriteString(fmt.Sprintf(" WHERE to_id > %d", *cursor)) + } + } + + if sortOrder == DESC { + queryBuilder.WriteString(" ORDER BY to_id DESC") + } else { + queryBuilder.WriteString(" ORDER BY to_id ASC") + } + + if limit != nil { + queryBuilder.WriteString(fmt.Sprintf(" LIMIT %d", *limit)) } - query := fmt.Sprintf(`SELECT %s FROM transactions ORDER BY ledger_created_at DESC`, columns) - args := []interface{}{} - if limit != nil && *limit > 0 { - query += ` LIMIT $1` - args = append(args, *limit) + query := queryBuilder.String() + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS transactions ORDER BY cursor ASC`, query) } - var transactions []*types.Transaction + var transactions []*types.TransactionWithCursor start := time.Now() - err := m.DB.SelectContext(ctx, &transactions, query, args...) + err := m.DB.SelectContext(ctx, &transactions, query) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "transactions", duration) if err != nil { diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index 64465456..95a351a8 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -256,16 +256,21 @@ func TestTransactionModel_GetAll(t *testing.T) { `, now) require.NoError(t, err) - // Test GetAll without limit - transactions, err := m.GetAll(ctx, nil, "") + // Test GetAll without specifying cursor and limit (gets all transactions) + transactions, err := m.GetAll(ctx, "", nil, nil, ASC) require.NoError(t, err) assert.Len(t, transactions, 3) + assert.Equal(t, int64(1), transactions[0].Cursor) + assert.Equal(t, int64(2), transactions[1].Cursor) + assert.Equal(t, int64(3), transactions[2].Cursor) - // Test GetAll with limit + // Test GetAll with smaller limit limit := int32(2) - transactions, err = m.GetAll(ctx, &limit, "") + transactions, err = m.GetAll(ctx, "", &limit, nil, ASC) require.NoError(t, err) assert.Len(t, transactions, 2) + assert.Equal(t, int64(1), transactions[0].Cursor) + assert.Equal(t, int64(2), transactions[1].Cursor) } func TestTransactionModel_BatchGetByAccountAddress(t *testing.T) { diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 345962fe..74f06d30 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -107,10 +107,10 @@ type ComplexityRoot struct { Query struct { Account func(childComplexity int, address string) int - Operations func(childComplexity int, limit *int32) int + Operations func(childComplexity int, first *int32, after *string, last *int32, before *string) int StateChanges func(childComplexity int, limit *int32) int TransactionByHash func(childComplexity int, hash string) int - Transactions func(childComplexity int, limit *int32) int + Transactions func(childComplexity int, first *int32, after *string, last *int32, before *string) int } RegisterAccountPayload struct { @@ -194,9 +194,9 @@ type OperationResolver interface { } type QueryResolver interface { TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) - Transactions(ctx context.Context, limit *int32) ([]*types.Transaction, error) + Transactions(ctx context.Context, first *int32, after *string, last *int32, before *string) (*TransactionConnection, error) Account(ctx context.Context, address string) (*types.Account, error) - Operations(ctx context.Context, limit *int32) ([]*types.Operation, error) + Operations(ctx context.Context, first *int32, after *string, last *int32, before *string) (*OperationConnection, error) StateChanges(ctx context.Context, limit *int32) ([]*types.StateChange, error) } type StateChangeResolver interface { @@ -489,7 +489,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.complexity.Query.Operations(childComplexity, args["limit"].(*int32)), true + return e.complexity.Query.Operations(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "Query.stateChanges": if e.complexity.Query.StateChanges == nil { @@ -525,7 +525,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.complexity.Query.Transactions(childComplexity, args["limit"].(*int32)), true + return e.complexity.Query.Transactions(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "RegisterAccountPayload.account": if e.complexity.RegisterAccountPayload.Account == nil { @@ -1163,9 +1163,9 @@ type PageInfo { # In GraphQL, the Query type is the entry point for read operations type Query { transactionByHash(hash: String!): Transaction - transactions(limit: Int): [Transaction!]! + transactions(first: Int, after: String, last: Int, before: String): TransactionConnection account(address: String!): Account - operations(limit: Int): [Operation!]! + operations(first: Int, after: String, last: Int, before: String): OperationConnection stateChanges(limit: Int): [StateChange!]! } `, BuiltIn: false}, @@ -1605,19 +1605,60 @@ func (ec *executionContext) field_Query_account_argsAddress( func (ec *executionContext) field_Query_operations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_operations_argsLimit(ctx, rawArgs) + arg0, err := ec.field_Query_operations_argsFirst(ctx, rawArgs) if err != nil { return nil, err } - args["limit"] = arg0 + args["first"] = arg0 + arg1, err := ec.field_Query_operations_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Query_operations_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Query_operations_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 return args, nil } -func (ec *executionContext) field_Query_operations_argsLimit( +func (ec *executionContext) field_Query_operations_argsFirst( ctx context.Context, rawArgs map[string]any, ) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Query_operations_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_operations_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { return ec.unmarshalOInt2ᚖint32(ctx, tmp) } @@ -1625,6 +1666,19 @@ func (ec *executionContext) field_Query_operations_argsLimit( return zeroVal, nil } +func (ec *executionContext) field_Query_operations_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field_Query_stateChanges_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1674,19 +1728,34 @@ func (ec *executionContext) field_Query_transactionByHash_argsHash( func (ec *executionContext) field_Query_transactions_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_transactions_argsLimit(ctx, rawArgs) + arg0, err := ec.field_Query_transactions_argsFirst(ctx, rawArgs) if err != nil { return nil, err } - args["limit"] = arg0 + args["first"] = arg0 + arg1, err := ec.field_Query_transactions_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Query_transactions_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Query_transactions_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 return args, nil } -func (ec *executionContext) field_Query_transactions_argsLimit( +func (ec *executionContext) field_Query_transactions_argsFirst( ctx context.Context, rawArgs map[string]any, ) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { return ec.unmarshalOInt2ᚖint32(ctx, tmp) } @@ -1694,6 +1763,45 @@ func (ec *executionContext) field_Query_transactions_argsLimit( return zeroVal, nil } +func (ec *executionContext) field_Query_transactions_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_transactions_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Query_transactions_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -3304,21 +3412,18 @@ func (ec *executionContext) _Query_transactions(ctx context.Context, field graph }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().Transactions(rctx, fc.Args["limit"].(*int32)) + return ec.resolvers.Query().Transactions(rctx, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.Transaction) + res := resTmp.(*TransactionConnection) fc.Result = res - return ec.marshalNTransaction2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransactionᚄ(ctx, field.Selections, res) + return ec.marshalOTransactionConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐTransactionConnection(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Query_transactions(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -3329,28 +3434,12 @@ func (ec *executionContext) fieldContext_Query_transactions(ctx context.Context, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "hash": - return ec.fieldContext_Transaction_hash(ctx, field) - case "envelopeXdr": - return ec.fieldContext_Transaction_envelopeXdr(ctx, field) - case "resultXdr": - return ec.fieldContext_Transaction_resultXdr(ctx, field) - case "metaXdr": - return ec.fieldContext_Transaction_metaXdr(ctx, field) - case "ledgerNumber": - return ec.fieldContext_Transaction_ledgerNumber(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_Transaction_ledgerCreatedAt(ctx, field) - case "ingestedAt": - return ec.fieldContext_Transaction_ingestedAt(ctx, field) - case "operations": - return ec.fieldContext_Transaction_operations(ctx, field) - case "accounts": - return ec.fieldContext_Transaction_accounts(ctx, field) - case "stateChanges": - return ec.fieldContext_Transaction_stateChanges(ctx, field) + case "edges": + return ec.fieldContext_TransactionConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_TransactionConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Transaction", field.Name) + return nil, fmt.Errorf("no field named %q was found under type TransactionConnection", field.Name) }, } defer func() { @@ -3443,21 +3532,18 @@ func (ec *executionContext) _Query_operations(ctx context.Context, field graphql }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().Operations(rctx, fc.Args["limit"].(*int32)) + return ec.resolvers.Query().Operations(rctx, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.Operation) + res := resTmp.(*OperationConnection) fc.Result = res - return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) + return ec.marshalOOperationConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationConnection(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Query_operations(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -3468,26 +3554,12 @@ func (ec *executionContext) fieldContext_Query_operations(ctx context.Context, f IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_Operation_id(ctx, field) - case "operationType": - return ec.fieldContext_Operation_operationType(ctx, field) - case "operationXdr": - return ec.fieldContext_Operation_operationXdr(ctx, field) - case "ledgerNumber": - return ec.fieldContext_Operation_ledgerNumber(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) - case "ingestedAt": - return ec.fieldContext_Operation_ingestedAt(ctx, field) - case "transaction": - return ec.fieldContext_Operation_transaction(ctx, field) - case "accounts": - return ec.fieldContext_Operation_accounts(ctx, field) - case "stateChanges": - return ec.fieldContext_Operation_stateChanges(ctx, field) + case "edges": + return ec.fieldContext_OperationConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_OperationConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + return nil, fmt.Errorf("no field named %q was found under type OperationConnection", field.Name) }, } defer func() { @@ -8505,16 +8577,13 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr case "transactions": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._Query_transactions(ctx, field) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -8546,16 +8615,13 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr case "operations": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._Query_operations(ctx, field) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -10291,50 +10357,6 @@ func (ec *executionContext) marshalNTransaction2githubᚗcomᚋstellarᚋwallet return ec._Transaction(ctx, sel, &v) } -func (ec *executionContext) marshalNTransaction2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransactionᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.Transaction) graphql.Marshaler { - ret := make(graphql.Array, len(v)) - var wg sync.WaitGroup - isLen1 := len(v) == 1 - if !isLen1 { - wg.Add(len(v)) - } - for i := range v { - i := i - fc := &graphql.FieldContext{ - Index: &i, - Result: &v[i], - } - ctx := graphql.WithFieldContext(ctx, fc) - f := func(i int) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = nil - } - }() - if !isLen1 { - defer wg.Done() - } - ret[i] = ec.marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx, sel, v[i]) - } - if isLen1 { - f(i) - } else { - go f(i) - } - - } - wg.Wait() - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - func (ec *executionContext) marshalNTransaction2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐTransaction(ctx context.Context, sel ast.SelectionSet, v *types.Transaction) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go index 6142442e..356a50f7 100644 --- a/internal/serve/graphql/resolvers/queries.resolvers.go +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -24,15 +24,35 @@ func (r *queryResolver) TransactionByHash(ctx context.Context, hash string) (*ty // Transactions is the resolver for the transactions field. // This resolver handles the "transactions" query. // It demonstrates handling optional arguments (limit can be nil) -func (r *queryResolver) Transactions(ctx context.Context, limit *int32) ([]*types.Transaction, error) { - if limit != nil && *limit < 0 { - return nil, fmt.Errorf("limit must be non-negative, got %d", *limit) - } - if limit != nil && *limit == 0 { - return []*types.Transaction{}, nil +func (r *queryResolver) Transactions(ctx context.Context, first *int32, after *string, last *int32, before *string) (*graphql1.TransactionConnection, error) { + params, err := parsePaginationParams(first, after, last, before, false) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) } + queryLimit := *params.Limit + 1 // +1 to check if there is a next page + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "") - return r.models.Transactions.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) + transactions, err := r.models.Transactions.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) + if err != nil { + return nil, fmt.Errorf("getting transactions from db: %w", err) + } + + conn := NewConnectionWithRelayPagination(transactions, params, func(t *types.TransactionWithCursor) int64 { + return t.Cursor + }) + + edges := make([]*graphql1.TransactionEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.TransactionEdge{ + Node: &edge.Node.Transaction, + Cursor: edge.Cursor, + } + } + + return &graphql1.TransactionConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // Account is the resolver for the account field. @@ -44,15 +64,35 @@ func (r *queryResolver) Account(ctx context.Context, address string) (*types.Acc // Operations is the resolver for the operations field. // This resolver handles the "operations" query. -func (r *queryResolver) Operations(ctx context.Context, limit *int32) ([]*types.Operation, error) { - if limit != nil && *limit < 0 { - return nil, fmt.Errorf("limit must be non-negative, got %d", *limit) - } - if limit != nil && *limit == 0 { - return []*types.Operation{}, nil +func (r *queryResolver) Operations(ctx context.Context, first *int32, after *string, last *int32, before *string) (*graphql1.OperationConnection, error) { + params, err := parsePaginationParams(first, after, last, before, false) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) } + queryLimit := *params.Limit + 1 // +1 to check if there is a next page + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "") - return r.models.Operations.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) + operations, err := r.models.Operations.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) + if err != nil { + return nil, fmt.Errorf("getting operations from db: %w", err) + } + + conn := NewConnectionWithRelayPagination(operations, params, func(o *types.OperationWithCursor) int64 { + return o.Cursor + }) + + edges := make([]*graphql1.OperationEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.OperationEdge{ + Node: &edge.Node.Operation, + Cursor: edge.Cursor, + } + } + + return &graphql1.OperationConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // StateChanges is the resolver for the stateChanges field. diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index 34e41d0e..d35155a7 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -77,44 +77,139 @@ func TestQueryResolver_Transactions(t *testing.T) { }, } - t.Run("get all", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) - txs, err := resolver.Transactions(ctx, nil) + t.Run("get all transactions", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + transactions, err := resolver.Transactions(ctx, nil, nil, nil, nil) + require.NoError(t, err) - assert.Len(t, txs, 4) + require.Len(t, transactions.Edges, 4) + assert.Equal(t, "tx1", transactions.Edges[0].Node.Hash) + assert.Equal(t, "tx2", transactions.Edges[1].Node.Hash) + assert.Equal(t, "tx3", transactions.Edges[2].Node.Hash) + assert.Equal(t, "tx4", transactions.Edges[3].Node.Hash) }) - t.Run("get with limit", func(t *testing.T) { - ctx := getTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "resultXdr", "metaXdr", "ledgerNumber", "ledgerCreatedAt"}) - limit := int32(1) - txs, err := resolver.Transactions(ctx, &limit) + t.Run("get transactions with first/after limit and cursor", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + first := int32(2) + txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, txs.Edges, 2) + assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) + assert.Equal(t, "tx2", txs.Edges[1].Node.Hash) + assert.True(t, txs.PageInfo.HasNextPage) + assert.False(t, txs.PageInfo.HasPreviousPage) + + // Get the next cursor + first = int32(1) + nextCursor := txs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + txs, err = resolver.Transactions(ctx, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, txs.Edges, 1) + assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) + assert.True(t, txs.PageInfo.HasNextPage) + assert.True(t, txs.PageInfo.HasPreviousPage) + + // Get the previous cursor + first = int32(10) + nextCursor = txs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + txs, err = resolver.Transactions(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) - assert.Len(t, txs, 1) + assert.Len(t, txs.Edges, 1) + assert.Equal(t, "tx4", txs.Edges[0].Node.Hash) + assert.False(t, txs.PageInfo.HasNextPage) + assert.True(t, txs.PageInfo.HasPreviousPage) }) - t.Run("negative limit error", func(t *testing.T) { + t.Run("get transactions with last/before limit and cursor", func(t *testing.T) { ctx := getTestCtx("transactions", []string{"hash"}) - limit := int32(-1) - txs, err := resolver.Transactions(ctx, &limit) + last := int32(2) + txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, txs.Edges, 2) + assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) + assert.Equal(t, "tx4", txs.Edges[1].Node.Hash) + assert.False(t, txs.PageInfo.HasNextPage) + assert.True(t, txs.PageInfo.HasPreviousPage) + + // Get the next cursor + last = int32(1) + nextCursor := txs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + txs, err = resolver.Transactions(ctx, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, txs.Edges, 1) + assert.Equal(t, "tx2", txs.Edges[0].Node.Hash) + assert.True(t, txs.PageInfo.HasNextPage) + assert.True(t, txs.PageInfo.HasPreviousPage) + + nextCursor = txs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + last = int32(10) + txs, err = resolver.Transactions(ctx, nil, nil, &last, nextCursor) + require.NoError(t, err) + assert.Len(t, txs.Edges, 1) + assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) + assert.True(t, txs.PageInfo.HasNextPage) + assert.False(t, txs.PageInfo.HasPreviousPage) + }) + + t.Run("returns error when first is negative", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + first := int32(-1) + txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) require.Error(t, err) assert.Nil(t, txs) - assert.Contains(t, err.Error(), "limit must be non-negative") + assert.Contains(t, err.Error(), "first must be greater than 0") }) - t.Run("zero limit", func(t *testing.T) { + t.Run("returns error when last is negative", func(t *testing.T) { ctx := getTestCtx("transactions", []string{"hash"}) - limit := int32(0) - txs, err := resolver.Transactions(ctx, &limit) + last := int32(-1) + txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) + require.Error(t, err) + assert.Nil(t, txs) + assert.Contains(t, err.Error(), "last must be greater than 0") + }) + + t.Run("returns error when first is zero", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + first := int32(0) + txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) + require.Error(t, err) + assert.Nil(t, txs) + assert.Contains(t, err.Error(), "first must be greater than 0") + }) + + t.Run("returns error when last is zero", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + last := int32(0) + txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) + require.Error(t, err) + assert.Nil(t, txs) + assert.Contains(t, err.Error(), "last must be greater than 0") + }) + + t.Run("first parameter's value larger than available data", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + first := int32(100) + txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) require.NoError(t, err) - assert.Len(t, txs, 0) + assert.Len(t, txs.Edges, 4) + assert.False(t, txs.PageInfo.HasNextPage) + assert.False(t, txs.PageInfo.HasPreviousPage) }) - t.Run("limit larger than available data", func(t *testing.T) { + t.Run("last parameter's value larger than available data", func(t *testing.T) { ctx := getTestCtx("transactions", []string{"hash"}) - limit := int32(100) - txs, err := resolver.Transactions(ctx, &limit) + last := int32(100) + txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) require.NoError(t, err) - assert.Len(t, txs, 4) + assert.Len(t, txs.Edges, 4) + assert.False(t, txs.PageInfo.HasNextPage) + assert.False(t, txs.PageInfo.HasPreviousPage) }) } @@ -171,36 +266,150 @@ func TestQueryResolver_Operations(t *testing.T) { }, } - t.Run("get all", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) - ops, err := resolver.Operations(ctx, nil) + t.Run("get all operations", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + operations, err := resolver.Operations(ctx, nil, nil, nil, nil) + require.NoError(t, err) - assert.Len(t, ops, 8) + require.Len(t, operations.Edges, 8) + // Operations are ordered by ID ascending + assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), operations.Edges[0].Node.ID) + assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), operations.Edges[1].Node.ID) + assert.Equal(t, toid.New(1000, 2, 1).ToInt64(), operations.Edges[2].Node.ID) + assert.Equal(t, toid.New(1000, 2, 2).ToInt64(), operations.Edges[3].Node.ID) }) - t.Run("get with limit", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id", "operationType", "operationXdr", "txHash", "ledgerNumber", "ledgerCreatedAt"}) - limit := int32(1) - ops, err := resolver.Operations(ctx, &limit) + t.Run("get operations with first/after limit and cursor", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + first := int32(2) + ops, err := resolver.Operations(ctx, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 2) + assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), ops.Edges[0].Node.ID) + assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), ops.Edges[1].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) + + // Get the next cursor + first = int32(1) + nextCursor := ops.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) - assert.Len(t, ops, 1) + assert.Len(t, ops.Edges, 1) + assert.Equal(t, toid.New(1000, 2, 1).ToInt64(), ops.Edges[0].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + + // Get the next page + first = int32(10) + nextCursor = ops.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 5) + assert.Equal(t, toid.New(1000, 2, 2).ToInt64(), ops.Edges[0].Node.ID) + assert.Equal(t, toid.New(1000, 3, 1).ToInt64(), ops.Edges[1].Node.ID) + assert.Equal(t, toid.New(1000, 3, 2).ToInt64(), ops.Edges[2].Node.ID) + assert.Equal(t, toid.New(1000, 4, 1).ToInt64(), ops.Edges[3].Node.ID) + assert.Equal(t, toid.New(1000, 4, 2).ToInt64(), ops.Edges[4].Node.ID) + assert.False(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) }) - t.Run("negative limit error", func(t *testing.T) { + t.Run("get operations with last/before limit and cursor", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + last := int32(2) + ops, err := resolver.Operations(ctx, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 2) + // With backward pagination, we get the last 2 items + assert.Equal(t, toid.New(1000, 4, 1).ToInt64(), ops.Edges[0].Node.ID) + assert.Equal(t, toid.New(1000, 4, 2).ToInt64(), ops.Edges[1].Node.ID) + assert.False(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + + // Get the previous page + last = int32(1) + prevCursor := ops.PageInfo.EndCursor + assert.NotNil(t, prevCursor) + ops, err = resolver.Operations(ctx, nil, nil, &last, prevCursor) + require.NoError(t, err) + assert.Len(t, ops.Edges, 1) + assert.Equal(t, toid.New(1000, 3, 2).ToInt64(), ops.Edges[0].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + + prevCursor = ops.PageInfo.EndCursor + assert.NotNil(t, prevCursor) + last = int32(10) + ops, err = resolver.Operations(ctx, nil, nil, &last, prevCursor) + require.NoError(t, err) + // There are 5 operations before (2,1): (2,2), (3,1), (3,2), (4,1), (4,2) + assert.Len(t, ops.Edges, 5) + assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), ops.Edges[0].Node.ID) + assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), ops.Edges[1].Node.ID) + assert.Equal(t, toid.New(1000, 2, 1).ToInt64(), ops.Edges[2].Node.ID) + assert.Equal(t, toid.New(1000, 2, 2).ToInt64(), ops.Edges[3].Node.ID) + assert.Equal(t, toid.New(1000, 3, 1).ToInt64(), ops.Edges[4].Node.ID) + assert.True(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) + }) + + t.Run("returns error when first is negative", func(t *testing.T) { ctx := getTestCtx("operations", []string{"id"}) - limit := int32(-5) - ops, err := resolver.Operations(ctx, &limit) + first := int32(-1) + ops, err := resolver.Operations(ctx, &first, nil, nil, nil) require.Error(t, err) assert.Nil(t, ops) - assert.Contains(t, err.Error(), "limit must be non-negative") + assert.Contains(t, err.Error(), "first must be greater than 0") }) - t.Run("zero limit", func(t *testing.T) { + t.Run("returns error when last is negative", func(t *testing.T) { ctx := getTestCtx("operations", []string{"id"}) - limit := int32(0) - ops, err := resolver.Operations(ctx, &limit) + last := int32(-1) + ops, err := resolver.Operations(ctx, nil, nil, &last, nil) + require.Error(t, err) + assert.Nil(t, ops) + assert.Contains(t, err.Error(), "last must be greater than 0") + }) + + t.Run("returns error when first is zero", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + first := int32(0) + ops, err := resolver.Operations(ctx, &first, nil, nil, nil) + require.Error(t, err) + assert.Nil(t, ops) + assert.Contains(t, err.Error(), "first must be greater than 0") + }) + + t.Run("returns error when last is zero", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + last := int32(0) + ops, err := resolver.Operations(ctx, nil, nil, &last, nil) + require.Error(t, err) + assert.Nil(t, ops) + assert.Contains(t, err.Error(), "last must be greater than 0") + }) + + t.Run("first parameter's value larger than available data", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + first := int32(100) + ops, err := resolver.Operations(ctx, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 8) + assert.False(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) + }) + + t.Run("last parameter's value larger than available data", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + last := int32(100) + ops, err := resolver.Operations(ctx, nil, nil, &last, nil) require.NoError(t, err) - assert.Len(t, ops, 0) + assert.Len(t, ops.Edges, 8) + assert.False(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) }) } diff --git a/internal/serve/graphql/schema/queries.graphqls b/internal/serve/graphql/schema/queries.graphqls index 88179c3a..26443241 100644 --- a/internal/serve/graphql/schema/queries.graphqls +++ b/internal/serve/graphql/schema/queries.graphqls @@ -2,8 +2,8 @@ # In GraphQL, the Query type is the entry point for read operations type Query { transactionByHash(hash: String!): Transaction - transactions(limit: Int): [Transaction!]! + transactions(first: Int, after: String, last: Int, before: String): TransactionConnection account(address: String!): Account - operations(limit: Int): [Operation!]! + operations(first: Int, after: String, last: Int, before: String): OperationConnection stateChanges(limit: Int): [StateChange!]! } From 9617171960075701f1e4214a235e81759c3c27c1 Mon Sep 17 00:00:00 2001 From: Jake Urban <10968980+JakeUrban@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:49:18 -0700 Subject: [PATCH 14/16] update docker compose to remove warning logs (#298) --- docker-compose.yaml | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 983cab97..de947ad1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,6 +20,7 @@ services: stellar-rpc: container_name: stellar-rpc image: stellar/stellar-rpc:stable + platform: linux/amd64 healthcheck: test: "curl --location 'http://localhost:8000/' -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":8675309,\"method\":\"getHealth\"}' | grep -q '\"status\":\"healthy\"'" interval: 10s @@ -67,31 +68,31 @@ services: PORT: 8001 SERVER_BASE_URL: http://api:8001 LOG_LEVEL: TRACE - CLIENT_AUTH_PUBLIC_KEYS: ${CLIENT_AUTH_PUBLIC_KEYS} - DISTRIBUTION_ACCOUNT_PUBLIC_KEY: ${DISTRIBUTION_ACCOUNT_PUBLIC_KEY} - DISTRIBUTION_ACCOUNT_SIGNATURE_PROVIDER: ${DISTRIBUTION_ACCOUNT_SIGNATURE_PROVIDER} + CLIENT_AUTH_PUBLIC_KEYS: ${CLIENT_AUTH_PUBLIC_KEYS:-} + DISTRIBUTION_ACCOUNT_PUBLIC_KEY: ${DISTRIBUTION_ACCOUNT_PUBLIC_KEY:-} + DISTRIBUTION_ACCOUNT_SIGNATURE_PROVIDER: ${DISTRIBUTION_ACCOUNT_SIGNATURE_PROVIDER:-ENV} NUMBER_CHANNEL_ACCOUNTS: ${NUMBER_CHANNEL_ACCOUNTS:-2} # Env Signature Client - DISTRIBUTION_ACCOUNT_PRIVATE_KEY: ${DISTRIBUTION_ACCOUNT_PRIVATE_KEY} + DISTRIBUTION_ACCOUNT_PRIVATE_KEY: ${DISTRIBUTION_ACCOUNT_PRIVATE_KEY:-} # (optional) KMS Signature Client - KMS_KEY_ARN: ${KMS_KEY_ARN} - AWS_REGIONG: ${AWS_REGION} + KMS_KEY_ARN: ${KMS_KEY_ARN:-} + AWS_REGION: ${AWS_REGION:-} # (optional) Using KMS locally is necessary to inject the AWS credentials envs. - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-} # Channel Account - CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE} - TRACKER_DSN: ${TRACKER_DSN} - STELLAR_ENVIRONMENT: ${STELLAR_ENVIRONMENT} + CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE:-} + TRACKER_DSN: ${TRACKER_DSN:-} + STELLAR_ENVIRONMENT: ${STELLAR_ENVIRONMENT:-} # (optional) Integration Tests - CLIENT_AUTH_PRIVATE_KEY: ${CLIENT_AUTH_PRIVATE_KEY} - PRIMARY_SOURCE_ACCOUNT_PRIVATE_KEY: ${PRIMARY_SOURCE_ACCOUNT_PRIVATE_KEY} - SECONDARY_SOURCE_ACCOUNT_PRIVATE_KEY: ${SECONDARY_SOURCE_ACCOUNT_PRIVATE_KEY} + CLIENT_AUTH_PRIVATE_KEY: ${CLIENT_AUTH_PRIVATE_KEY:-} + PRIMARY_SOURCE_ACCOUNT_PRIVATE_KEY: ${PRIMARY_SOURCE_ACCOUNT_PRIVATE_KEY:-} + SECONDARY_SOURCE_ACCOUNT_PRIVATE_KEY: ${SECONDARY_SOURCE_ACCOUNT_PRIVATE_KEY:-} ingest: container_name: ingest @@ -116,7 +117,7 @@ services: - | ./wallet-backend migrate up if [ "$STELLAR_ENVIRONMENT" = "GITHUB_WORKFLOW" ]; then - HEALTH_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' "${RPC_URL}") + HEALTH_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' "$$RPC_URL") LATEST_LEDGER=$(echo "$$HEALTH_RESPONSE" | grep -oE '"latestLedger":[0-9]+' | grep -oE '[0-9]+' || true) if [ -z "$$LATEST_LEDGER" ] || [ "$$LATEST_LEDGER" = "" ]; then ./wallet-backend ingest @@ -129,8 +130,8 @@ services: environment: RPC_URL: ${RPC_URL:-http://stellar-rpc:8000} DATABASE_URL: postgres://postgres@db:5432/wallet-backend?sslmode=disable - TRACKER_DSN: ${TRACKER_DSN} - STELLAR_ENVIRONMENT: ${STELLAR_ENVIRONMENT} + TRACKER_DSN: ${TRACKER_DSN:-} + STELLAR_ENVIRONMENT: ${STELLAR_ENVIRONMENT:-} volumes: postgres-db: driver: local From f33383174002eb4c851b533d5f9e4e24151e0c3b Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Tue, 26 Aug 2025 16:21:02 -0400 Subject: [PATCH 15/16] Add relay pagination to remaining queries (#297) --- internal/data/accounts.go | 8 +- internal/data/operations.go | 110 ++- internal/data/operations_test.go | 238 ++++- internal/data/query_utils.go | 58 +- internal/data/statechanges.go | 252 ++++- internal/data/statechanges_test.go | 296 +++++- internal/data/transactions.go | 21 +- internal/serve/graphql/dataloaders/loaders.go | 6 +- .../graphql/dataloaders/operation_loaders.go | 35 +- .../dataloaders/statechange_loaders.go | 64 +- internal/serve/graphql/generated/generated.go | 880 +++++++++++------- .../graphql/resolvers/account.resolvers.go | 6 +- .../graphql/resolvers/operation.resolvers.go | 39 +- .../resolvers/operation_resolvers_test.go | 115 ++- .../graphql/resolvers/queries.resolvers.go | 52 +- .../resolvers/queries_resolvers_test.go | 266 ++++-- .../resolvers/statechange.resolvers.go | 4 +- .../resolvers/transaction.resolvers.go | 77 +- .../resolvers/transaction_resolvers_test.go | 222 ++++- internal/serve/graphql/resolvers/utils.go | 19 +- .../serve/graphql/schema/operation.graphqls | 2 +- .../serve/graphql/schema/queries.graphqls | 7 +- .../serve/graphql/schema/transaction.graphqls | 4 +- 23 files changed, 2146 insertions(+), 635 deletions(-) diff --git a/internal/data/accounts.go b/internal/data/accounts.go index 86782b5d..f991d61c 100644 --- a/internal/data/accounts.go +++ b/internal/data/accounts.go @@ -107,9 +107,7 @@ func (m *AccountModel) IsAccountFeeBumpEligible(ctx context.Context, address str // BatchGetByTxHashes gets the accounts that are associated with the given transaction hashes. func (m *AccountModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string) ([]*types.AccountWithTxHash, error) { - if columns == "" { - columns = "accounts.*" - } + columns = prepareColumnsWithID(columns, types.Account{}, "accounts", "stellar_address") query := fmt.Sprintf(` SELECT %s, transactions_accounts.tx_hash FROM transactions_accounts @@ -130,9 +128,7 @@ func (m *AccountModel) BatchGetByTxHashes(ctx context.Context, txHashes []string // BatchGetByOperationIDs gets the accounts that are associated with the given operation IDs. func (m *AccountModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string) ([]*types.AccountWithOperationID, error) { - if columns == "" { - columns = "accounts.*" - } + columns = prepareColumnsWithID(columns, types.Account{}, "accounts", "stellar_address") query := fmt.Sprintf(` SELECT %s, operations_accounts.operation_id FROM operations_accounts diff --git a/internal/data/operations.go b/internal/data/operations.go index ca55209f..0f0b5f57 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -19,8 +19,23 @@ type OperationModel struct { MetricsService metrics.MetricsService } +func (m *OperationModel) GetByID(ctx context.Context, id int64, columns string) (*types.Operation, error) { + columns = prepareColumnsWithID(columns, types.Operation{}, "", "id") + query := fmt.Sprintf(`SELECT %s FROM operations WHERE id = $1`, columns) + var operation types.Operation + start := time.Now() + err := m.DB.GetContext(ctx, &operation, query, id) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) + if err != nil { + return nil, fmt.Errorf("getting operation by id: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "operations") + return &operation, nil +} + func (m *OperationModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *int64, sortOrder SortOrder) ([]*types.OperationWithCursor, error) { - columns = prepareColumnsWithID(columns, "operations", "id") + columns = prepareColumnsWithID(columns, types.Operation{}, "", "id") queryBuilder := strings.Builder{} queryBuilder.WriteString(fmt.Sprintf(`SELECT %s, id as cursor FROM operations`, columns)) @@ -59,12 +74,41 @@ func (m *OperationModel) GetAll(ctx context.Context, columns string, limit *int3 } // BatchGetByTxHashes gets the operations that are associated with the given transaction hashes. -func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string) ([]*types.Operation, error) { - if columns == "" { - columns = "*" +func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string, limit *int32, sortOrder SortOrder) ([]*types.OperationWithCursor, error) { + columns = prepareColumnsWithID(columns, types.Operation{}, "", "id") + queryBuilder := strings.Builder{} + // This CTE query implements per-transaction pagination to ensure balanced results. + // Instead of applying a global LIMIT that could return all operations from just a few + // transactions, we use ROW_NUMBER() with PARTITION BY tx_hash to limit results per transaction. + // This guarantees that each transaction gets at most 'limit' operations, providing + // more balanced and predictable pagination across multiple transactions. + query := ` + WITH + inputs (tx_hash) AS ( + SELECT * FROM UNNEST($1::text[]) + ), + + ranked_operations_per_tx_hash AS ( + SELECT + o.*, + ROW_NUMBER() OVER (PARTITION BY o.tx_hash ORDER BY o.id %s) AS rn + FROM + operations o + JOIN + inputs i ON o.tx_hash = i.tx_hash + ) + SELECT %s, id as cursor FROM ranked_operations_per_tx_hash + ` + queryBuilder.WriteString(fmt.Sprintf(query, sortOrder, columns)) + if limit != nil { + queryBuilder.WriteString(fmt.Sprintf(" WHERE rn <= %d", *limit)) + } + query = queryBuilder.String() + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS operations ORDER BY cursor ASC`, query) } - query := fmt.Sprintf(`SELECT %s, tx_hash FROM operations WHERE tx_hash = ANY($1)`, columns) - var operations []*types.Operation + + var operations []*types.OperationWithCursor start := time.Now() err := m.DB.SelectContext(ctx, &operations, query, pq.Array(txHashes)) duration := time.Since(start).Seconds() @@ -76,10 +120,56 @@ func (m *OperationModel) BatchGetByTxHashes(ctx context.Context, txHashes []stri return operations, nil } +// BatchGetByTxHash gets operations for a single transaction with pagination support. +func (m *OperationModel) BatchGetByTxHash(ctx context.Context, txHash string, columns string, limit *int32, cursor *int64, sortOrder SortOrder) ([]*types.OperationWithCursor, error) { + columns = prepareColumnsWithID(columns, types.Operation{}, "", "id") + queryBuilder := strings.Builder{} + queryBuilder.WriteString(fmt.Sprintf(`SELECT %s, id as cursor FROM operations WHERE tx_hash = $1`, columns)) + + args := []interface{}{txHash} + argIndex := 2 + + if cursor != nil { + if sortOrder == DESC { + queryBuilder.WriteString(fmt.Sprintf(" AND id < $%d", argIndex)) + } else { + queryBuilder.WriteString(fmt.Sprintf(" AND id > $%d", argIndex)) + } + args = append(args, *cursor) + argIndex++ + } + + if sortOrder == DESC { + queryBuilder.WriteString(" ORDER BY id DESC") + } else { + queryBuilder.WriteString(" ORDER BY id ASC") + } + + if limit != nil { + queryBuilder.WriteString(fmt.Sprintf(" LIMIT $%d", argIndex)) + args = append(args, *limit) + } + + query := queryBuilder.String() + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS operations ORDER BY cursor ASC`, query) + } + + var operations []*types.OperationWithCursor + start := time.Now() + err := m.DB.SelectContext(ctx, &operations, query, args...) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "operations", duration) + if err != nil { + return nil, fmt.Errorf("getting paginated operations by tx hash: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "operations") + return operations, nil +} + // BatchGetByAccountAddress gets the operations that are associated with a single account address. func (m *OperationModel) BatchGetByAccountAddress(ctx context.Context, accountAddress string, columns string, limit *int32, cursor *int64, orderBy SortOrder) ([]*types.OperationWithCursor, error) { - // Prepare columns, ensuring operations.id is always included - columns = prepareColumnsWithID(columns, "operations", "id") + columns = prepareColumnsWithID(columns, types.Operation{}, "operations", "id") // Build paginated query using shared utility query, args := buildGetByAccountAddressQuery(paginatedQueryConfig{ @@ -108,9 +198,7 @@ func (m *OperationModel) BatchGetByAccountAddress(ctx context.Context, accountAd // BatchGetByStateChangeIDs gets the operations that are associated with the given state change IDs. func (m *OperationModel) BatchGetByStateChangeIDs(ctx context.Context, scToIDs []int64, scOrders []int64, columns string) ([]*types.OperationWithStateChangeID, error) { - if columns == "" { - columns = "operations.*" - } + columns = prepareColumnsWithID(columns, types.Operation{}, "operations", "id") // Build tuples for the IN clause. Since (to_id, state_change_order) is the primary key of state_changes, // it will be faster to search on this tuple. diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index 019a0e58..7838ccc9 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -280,6 +280,183 @@ func TestOperationModel_BatchGetByTxHashes(t *testing.T) { require.NoError(t, err) defer dbConnectionPool.Close() + ctx := context.Background() + now := time.Now() + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) + `, now) + require.NoError(t, err) + + // Create test operations - multiple operations per transaction to test ranking + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) + VALUES + (1, 'tx1', 'payment', 'xdr1', 1, $1), + (2, 'tx2', 'create_account', 'xdr2', 2, $1), + (3, 'tx1', 'payment', 'xdr3', 3, $1), + (4, 'tx1', 'manage_offer', 'xdr4', 4, $1), + (5, 'tx2', 'payment', 'xdr5', 5, $1), + (6, 'tx3', 'trust_line', 'xdr6', 6, $1) + `, now) + require.NoError(t, err) + + testCases := []struct { + name string + txHashes []string + limit *int32 + sortOrder SortOrder + expectedCount int + expectedTxCounts map[string]int + expectMetricCalls int + }{ + { + name: "🟢 basic functionality with multiple tx hashes", + txHashes: []string{"tx1", "tx2"}, + limit: nil, + sortOrder: ASC, + expectedCount: 5, // 3 ops for tx1 + 2 ops for tx2 + expectedTxCounts: map[string]int{"tx1": 3, "tx2": 2}, + expectMetricCalls: 1, + }, + { + name: "🟢 with limit parameter", + txHashes: []string{"tx1", "tx2"}, + limit: int32Ptr(2), + sortOrder: ASC, + expectedCount: 4, // 2 ops per tx hash (limited by ROW_NUMBER) + expectedTxCounts: map[string]int{"tx1": 2, "tx2": 2}, + expectMetricCalls: 1, + }, + { + name: "🟢 DESC sort order", + txHashes: []string{"tx1"}, + limit: nil, + sortOrder: DESC, + expectedCount: 3, + expectedTxCounts: map[string]int{"tx1": 3}, + expectMetricCalls: 1, + }, + { + name: "🟢 single transaction", + txHashes: []string{"tx3"}, + limit: nil, + sortOrder: ASC, + expectedCount: 1, + expectedTxCounts: map[string]int{"tx3": 1}, + expectMetricCalls: 1, + }, + { + name: "🟡 empty tx hashes array", + txHashes: []string{}, + limit: nil, + sortOrder: ASC, + expectedCount: 0, + expectedTxCounts: map[string]int{}, + expectMetricCalls: 1, + }, + { + name: "🟡 non-existent transaction hash", + txHashes: []string{"nonexistent"}, + limit: nil, + sortOrder: ASC, + expectedCount: 0, + expectedTxCounts: map[string]int{}, + expectMetricCalls: 1, + }, + { + name: "🟡 mixed existing and non-existent hashes", + txHashes: []string{"tx1", "nonexistent", "tx2"}, + limit: nil, + sortOrder: ASC, + expectedCount: 5, + expectedTxCounts: map[string]int{"tx1": 3, "tx2": 2}, + expectMetricCalls: 1, + }, + { + name: "🟢 limit smaller than operations per transaction", + txHashes: []string{"tx1"}, + limit: int32Ptr(1), + sortOrder: ASC, + expectedCount: 1, // Only first operation due to ROW_NUMBER ranking + expectedTxCounts: map[string]int{"tx1": 1}, + expectMetricCalls: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return().Times(tc.expectMetricCalls) + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return().Times(tc.expectMetricCalls) + defer mockMetricsService.AssertExpectations(t) + + m := &OperationModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + operations, err := m.BatchGetByTxHashes(ctx, tc.txHashes, "", tc.limit, tc.sortOrder) + require.NoError(t, err) + assert.Len(t, operations, tc.expectedCount) + + // Verify operations are for correct tx hashes + txHashesFound := make(map[string]int) + for _, op := range operations { + txHashesFound[op.TxHash]++ + } + assert.Equal(t, tc.expectedTxCounts, txHashesFound) + + // Verify within-transaction ordering + // The CTE uses ROW_NUMBER() OVER (PARTITION BY o.tx_hash ORDER BY o.id %s) + // This means operations within each transaction should be ordered by ID + if len(operations) > 0 { + operationsByTxHash := make(map[string][]*types.OperationWithCursor) + for _, op := range operations { + operationsByTxHash[op.TxHash] = append(operationsByTxHash[op.TxHash], op) + } + + // Verify ordering within each transaction + for txHash, txOperations := range operationsByTxHash { + if len(txOperations) > 1 { + for i := 1; i < len(txOperations); i++ { + prevID := txOperations[i-1].ID + currID := txOperations[i].ID + // After final transformation, operations should be in ascending ID order within each tx + assert.True(t, prevID <= currID, + "operations within tx %s should be ordered by ID: prev=%d, curr=%d", + txHash, prevID, currID) + } + } + } + } + + // Verify limit behavior when specified + if tc.limit != nil && len(tc.expectedTxCounts) > 0 { + for txHash, count := range tc.expectedTxCounts { + assert.True(t, count <= int(*tc.limit), "number of operations for %s should not exceed limit %d", txHash, *tc.limit) + } + } + }) + } +} + +func int32Ptr(v int32) *int32 { + return &v +} + +func TestOperationModel_BatchGetByTxHash(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + mockMetricsService := metrics.NewMockMetricsService() mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() @@ -313,17 +490,11 @@ func TestOperationModel_BatchGetByTxHashes(t *testing.T) { require.NoError(t, err) // Test BatchGetByTxHash - operations, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}, "") + operations, err := m.BatchGetByTxHash(ctx, "tx1", "", nil, nil, ASC) require.NoError(t, err) - assert.Len(t, operations, 3) - - // Verify operations are for correct tx hashes - txHashesFound := make(map[string]int) - for _, op := range operations { - txHashesFound[op.TxHash]++ - } - assert.Equal(t, 2, txHashesFound["tx1"]) - assert.Equal(t, 1, txHashesFound["tx2"]) + assert.Len(t, operations, 2) + assert.Equal(t, "xdr1", operations[0].OperationXDR) + assert.Equal(t, "xdr3", operations[1].OperationXDR) } func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { @@ -390,6 +561,53 @@ func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { assert.Equal(t, int64(2), operations[1].Operation.ID) } +func TestOperationModel_GetByID(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + now := time.Now() + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1) + `, now) + require.NoError(t, err) + + // Create test operations + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO operations (id, tx_hash, operation_type, operation_xdr, ledger_number, ledger_created_at) + VALUES + (1, 'tx1', 'payment', 'xdr1', 1, $1), + (2, 'tx2', 'create_account', 'xdr2', 2, $1) + `, now) + require.NoError(t, err) + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &OperationModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + operation, err := m.GetByID(ctx, 1, "") + require.NoError(t, err) + assert.Equal(t, int64(1), operation.ID) + assert.Equal(t, "tx1", operation.TxHash) + assert.Equal(t, "xdr1", operation.OperationXDR) + assert.Equal(t, uint32(1), operation.LedgerNumber) + assert.WithinDuration(t, now, operation.LedgerCreatedAt, time.Second) +} + func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() diff --git a/internal/data/query_utils.go b/internal/data/query_utils.go index 27981c69..390b31a9 100644 --- a/internal/data/query_utils.go +++ b/internal/data/query_utils.go @@ -2,7 +2,10 @@ package data import ( "fmt" + "reflect" "strings" + + set "github.com/deckarep/golang-set/v2" ) type SortOrder string @@ -91,12 +94,57 @@ func buildGetByAccountAddressQuery(config paginatedQueryConfig) (string, []any) return query, args } +func getDBColumns(model any) set.Set[string] { + modelType := reflect.TypeOf(model) + dbColumns := set.NewSet[string]() + for i := 0; i < modelType.NumField(); i++ { + field := modelType.Field(i) + dbTag := field.Tag.Get("db") + + if dbTag != "" && dbTag != "-" { + dbColumns.Add(dbTag) + } + } + return dbColumns +} + // PrepareColumnsWithID ensures that the specified ID column is always included in the column list -func prepareColumnsWithID(columns string, tableName string, idColumn string) string { +func prepareColumnsWithID(columns string, model any, prefix string, idColumns ...string) string { + var dbColumns set.Set[string] if columns == "" { - return fmt.Sprintf("%s.*", tableName) + dbColumns = getDBColumns(model) + } else { + dbColumns = set.NewSet[string]() + dbColumns.Add(columns) + } + + if prefix != "" { + dbColumns = addPrefixToColumns(dbColumns, prefix) + } + // State changes has both to_id and state_change_order as id columns + for _, idColumn := range idColumns { + dbColumns = addIDColumn(dbColumns, prefix, idColumn) + } + return strings.Join(dbColumns.ToSlice(), ", ") +} + +func addPrefixToColumns(columns set.Set[string], prefix string) set.Set[string] { + result := set.NewSet[string]() + for _, column := range columns.ToSlice() { + result.Add(fmt.Sprintf("%s.%s", prefix, column)) + } + return result +} + +func addIDColumn(columns set.Set[string], prefix string, idColumn string) set.Set[string] { + var columnToAdd string + if prefix == "" { + columnToAdd = idColumn + } else { + columnToAdd = fmt.Sprintf("%s.%s", prefix, idColumn) + } + if !columns.Contains(columnToAdd) { + columns.Add(columnToAdd) } - // Always return the ID column as it is the primary key and can be used - // to build further queries - return fmt.Sprintf("%s, %s.%s", columns, tableName, idColumn) + return columns } diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index 68380722..3804f9cc 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -20,12 +20,7 @@ type StateChangeModel struct { // BatchGetByAccountAddress gets the state changes that are associated with the given account addresses. func (m *StateChangeModel) BatchGetByAccountAddress(ctx context.Context, accountAddress string, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { - if columns == "" { - columns = "*" - } else { - columns = fmt.Sprintf("%s, to_id, state_change_order", columns) - } - + columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "state_change_order") var queryBuilder strings.Builder queryBuilder.WriteString(fmt.Sprintf(` SELECT %s, to_id as "cursor.cursor_to_id", state_change_order as "cursor.cursor_state_change_order" @@ -73,24 +68,45 @@ func (m *StateChangeModel) BatchGetByAccountAddress(ctx context.Context, account return stateChanges, nil } -func (m *StateChangeModel) GetAll(ctx context.Context, limit *int32, columns string) ([]*types.StateChange, error) { - if columns == "" { - columns = "*" +func (m *StateChangeModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { + columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "state_change_order") + var queryBuilder strings.Builder + queryBuilder.WriteString(fmt.Sprintf(` + SELECT %s, to_id as "cursor.cursor_to_id", state_change_order as "cursor.cursor_state_change_order" + FROM state_changes + `, columns)) + + if cursor != nil { + if sortOrder == DESC { + queryBuilder.WriteString(fmt.Sprintf(` + WHERE (to_id < %d OR (to_id = %d AND state_change_order < %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } else { + queryBuilder.WriteString(fmt.Sprintf(` + WHERE (to_id > %d OR (to_id = %d AND state_change_order > %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } + } + + if sortOrder == DESC { + queryBuilder.WriteString(" ORDER BY to_id DESC, state_change_order DESC") } else { - columns = fmt.Sprintf("%s, to_id, state_change_order", columns) + queryBuilder.WriteString(" ORDER BY to_id ASC, state_change_order ASC") } - // We always return the to_id, state_change_order since those are the primary keys. - // This is used for subsequent queries for operation and transactions of a state change. - query := fmt.Sprintf(`SELECT %s FROM state_changes ORDER BY to_id DESC, state_change_order DESC`, columns) - var args []interface{} if limit != nil && *limit > 0 { - query += ` LIMIT $1` - args = append(args, *limit) + queryBuilder.WriteString(fmt.Sprintf(" LIMIT %d", *limit)) + } + + query := queryBuilder.String() + + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS statechanges ORDER BY to_id ASC, state_change_order ASC`, query) } - var stateChanges []*types.StateChange + + var stateChanges []*types.StateChangeWithCursor start := time.Now() - err := m.DB.SelectContext(ctx, &stateChanges, query, args...) + err := m.DB.SelectContext(ctx, &stateChanges, query) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) if err != nil { @@ -287,23 +303,96 @@ func (m *StateChangeModel) BatchInsert( return insertedIDs, nil } -// BatchGetByTxHashes gets the state changes that are associated with the given transaction hashes. -func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string) ([]*types.StateChange, error) { - if columns == "" { - columns = "*" +// BatchGetByTxHash gets state changes for a single transaction with pagination support. +func (m *StateChangeModel) BatchGetByTxHash(ctx context.Context, txHash string, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { + columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "state_change_order") + var queryBuilder strings.Builder + queryBuilder.WriteString(fmt.Sprintf(` + SELECT %s, to_id as "cursor.cursor_to_id", state_change_order as "cursor.cursor_state_change_order" + FROM state_changes + WHERE tx_hash = $1 + `, columns)) + + args := []interface{}{txHash} + argIndex := 2 + + if cursor != nil { + if sortOrder == DESC { + queryBuilder.WriteString(fmt.Sprintf(` + AND (to_id < %d OR (to_id = %d AND state_change_order < %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } else { + queryBuilder.WriteString(fmt.Sprintf(` + AND (to_id > %d OR (to_id = %d AND state_change_order > %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } + } + + if sortOrder == DESC { + queryBuilder.WriteString(" ORDER BY to_id DESC, state_change_order DESC") } else { - // We always return the to_id, state_change_order since those are the primary keys. - // This is used for subsequent queries for operation and transactions of a state change. - columns = fmt.Sprintf("%s, to_id, state_change_order", columns) + queryBuilder.WriteString(" ORDER BY to_id ASC, state_change_order ASC") } - query := fmt.Sprintf(` - SELECT %s, tx_hash - FROM state_changes - WHERE tx_hash = ANY($1) - ORDER BY to_id DESC, state_change_order DESC - `, columns) - var stateChanges []*types.StateChange + if limit != nil && *limit > 0 { + queryBuilder.WriteString(fmt.Sprintf(" LIMIT $%d", argIndex)) + args = append(args, *limit) + } + + query := queryBuilder.String() + + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS statechanges ORDER BY to_id ASC, state_change_order ASC`, query) + } + + var stateChanges []*types.StateChangeWithCursor + start := time.Now() + err := m.DB.SelectContext(ctx, &stateChanges, query, args...) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) + if err != nil { + return nil, fmt.Errorf("getting paginated state changes by tx hash: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "state_changes") + return stateChanges, nil +} + +// BatchGetByTxHashes gets the state changes that are associated with the given transaction hashes. +func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []string, columns string, limit *int32, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { + columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "state_change_order") + var queryBuilder strings.Builder + // This CTE query implements per-transaction pagination to ensure balanced results. + // Instead of applying a global LIMIT that could return all state changes from just a few + // transactions, we use ROW_NUMBER() with PARTITION BY tx_hash to limit results per transaction. + // This guarantees that each transaction gets at most 'limit' state changes, providing + // more balanced and predictable pagination across multiple transactions. + queryBuilder.WriteString(fmt.Sprintf(` + WITH + inputs (tx_hash) AS ( + SELECT * FROM UNNEST($1::text[]) + ), + + ranked_state_changes_per_tx_hash AS ( + SELECT + sc.*, + ROW_NUMBER() OVER (PARTITION BY sc.tx_hash ORDER BY sc.to_id %s, sc.state_change_order %s) AS rn + FROM + state_changes sc + JOIN + inputs i ON sc.tx_hash = i.tx_hash + ) + SELECT %s, to_id as "cursor.cursor_to_id", state_change_order as "cursor.cursor_state_change_order" FROM ranked_state_changes_per_tx_hash + `, sortOrder, sortOrder, columns)) + if limit != nil { + queryBuilder.WriteString(fmt.Sprintf(" WHERE rn <= %d", *limit)) + } + query := queryBuilder.String() + + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS statechanges ORDER BY to_id ASC, state_change_order ASC`, query) + } + + var stateChanges []*types.StateChangeWithCursor start := time.Now() err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(txHashes)) duration := time.Since(start).Seconds() @@ -315,23 +404,94 @@ func (m *StateChangeModel) BatchGetByTxHashes(ctx context.Context, txHashes []st return stateChanges, nil } -// BatchGetByOperationIDs gets the state changes that are associated with the given operation IDs. -func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string) ([]*types.StateChange, error) { - if columns == "" { - columns = "*" +// BatchGetByOperationID gets state changes for a single operation with pagination support. +func (m *StateChangeModel) BatchGetByOperationID(ctx context.Context, operationID int64, columns string, limit *int32, cursor *types.StateChangeCursor, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { + columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "state_change_order") + var queryBuilder strings.Builder + queryBuilder.WriteString(fmt.Sprintf(` + SELECT %s, to_id as "cursor.cursor_to_id", state_change_order as "cursor.cursor_state_change_order" + FROM state_changes + WHERE operation_id = $1 + `, columns)) + + args := []interface{}{operationID} + argIndex := 2 + + if cursor != nil { + if sortOrder == DESC { + queryBuilder.WriteString(fmt.Sprintf(` + AND (to_id < %d OR (to_id = %d AND state_change_order < %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } else { + queryBuilder.WriteString(fmt.Sprintf(` + AND (to_id > %d OR (to_id = %d AND state_change_order > %d)) + `, cursor.ToID, cursor.ToID, cursor.StateChangeOrder)) + } + } + + if sortOrder == DESC { + queryBuilder.WriteString(" ORDER BY to_id DESC, state_change_order DESC") } else { - columns = fmt.Sprintf("%s, to_id, state_change_order", columns) + queryBuilder.WriteString(" ORDER BY to_id ASC, state_change_order ASC") } - // We always return the to_id, state_change_order since those are the primary keys. - // This is used for subsequent queries for operation and transactions of a state change. - query := fmt.Sprintf(` - SELECT %s, operation_id - FROM state_changes - WHERE operation_id = ANY($1) - ORDER BY to_id DESC, state_change_order DESC - `, columns) - var stateChanges []*types.StateChange + if limit != nil && *limit > 0 { + queryBuilder.WriteString(fmt.Sprintf(" LIMIT $%d", argIndex)) + args = append(args, *limit) + } + + query := queryBuilder.String() + + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS statechanges ORDER BY to_id ASC, state_change_order ASC`, query) + } + + var stateChanges []*types.StateChangeWithCursor + start := time.Now() + err := m.DB.SelectContext(ctx, &stateChanges, query, args...) + duration := time.Since(start).Seconds() + m.MetricsService.ObserveDBQueryDuration("SELECT", "state_changes", duration) + if err != nil { + return nil, fmt.Errorf("getting paginated state changes by operation ID: %w", err) + } + m.MetricsService.IncDBQuery("SELECT", "state_changes") + return stateChanges, nil +} + +// BatchGetByOperationIDs gets the state changes that are associated with the given operation IDs. +func (m *StateChangeModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string, limit *int32, sortOrder SortOrder) ([]*types.StateChangeWithCursor, error) { + columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "state_change_order") + var queryBuilder strings.Builder + // This CTE query implements per-operation pagination to ensure balanced results. + // Instead of applying a global LIMIT that could return all state changes from just a few + // operations, we use ROW_NUMBER() with PARTITION BY operation_id to limit results per operation. + // This guarantees that each operation gets at most 'limit' state changes, providing + // more balanced and predictable pagination across multiple operations. + queryBuilder.WriteString(fmt.Sprintf(` + WITH + inputs (operation_id) AS ( + SELECT * FROM UNNEST($1::bigint[]) + ), + + ranked_state_changes_per_operation_id AS ( + SELECT + sc.*, + ROW_NUMBER() OVER (PARTITION BY sc.operation_id ORDER BY sc.to_id %s, sc.state_change_order %s) AS rn + FROM + state_changes sc + JOIN + inputs i ON sc.operation_id = i.operation_id + ) + SELECT %s, to_id as "cursor.cursor_to_id", state_change_order as "cursor.cursor_state_change_order" FROM ranked_state_changes_per_operation_id + `, sortOrder, sortOrder, columns)) + if limit != nil { + queryBuilder.WriteString(fmt.Sprintf(" WHERE rn <= %d", *limit)) + } + query := queryBuilder.String() + if sortOrder == DESC { + query = fmt.Sprintf(`SELECT * FROM (%s) AS statechanges ORDER BY to_id ASC, state_change_order ASC`, query) + } + var stateChanges []*types.StateChangeWithCursor start := time.Now() err := m.DB.SelectContext(ctx, &stateChanges, query, pq.Array(operationIDs)) duration := time.Since(start).Seconds() diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 82ee3b2a..296ee589 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -310,13 +310,13 @@ func TestStateChangeModel_GetAll(t *testing.T) { require.NoError(t, err) // Test GetAll without limit - stateChanges, err := m.GetAll(ctx, nil, "") + stateChanges, err := m.GetAll(ctx, "", nil, nil, DESC) require.NoError(t, err) assert.Len(t, stateChanges, 3) // Test GetAll with limit limit := int32(2) - stateChanges, err = m.GetAll(ctx, &limit, "") + stateChanges, err = m.GetAll(ctx, "", &limit, nil, DESC) require.NoError(t, err) assert.Len(t, stateChanges, 2) } @@ -328,16 +328,6 @@ func TestStateChangeModel_BatchGetByTxHashes(t *testing.T) { require.NoError(t, err) defer dbConnectionPool.Close() - mockMetricsService := metrics.NewMockMetricsService() - mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() - mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() - defer mockMetricsService.AssertExpectations(t) - - m := &StateChangeModel{ - DB: dbConnectionPool, - MetricsService: mockMetricsService, - } - ctx := context.Background() now := time.Now() @@ -351,32 +341,184 @@ func TestStateChangeModel_BatchGetByTxHashes(t *testing.T) { INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) VALUES ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), - ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1) + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1), + ('tx3', 3, 'env3', 'res3', 'meta3', 3, $1) `, now) require.NoError(t, err) - // Create test state changes + // Create test state changes - multiple state changes per transaction to test ranking _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) VALUES (1, 1, 'credit', $1, 1, $2, 123, 'tx1'), (2, 1, 'debit', $1, 2, $2, 456, 'tx2'), - (3, 1, 'credit', $1, 3, $2, 789, 'tx1') + (3, 1, 'credit', $1, 3, $2, 789, 'tx1'), + (4, 1, 'debit', $1, 4, $2, 101, 'tx1'), + (5, 1, 'credit', $1, 5, $2, 102, 'tx2'), + (6, 1, 'debit', $1, 6, $2, 103, 'tx3'), + (7, 1, 'credit', $1, 7, $2, 104, 'tx2') `, now, address) require.NoError(t, err) - // Test BatchGetByTxHash - stateChanges, err := m.BatchGetByTxHashes(ctx, []string{"tx1", "tx2"}, "") - require.NoError(t, err) - assert.Len(t, stateChanges, 3) + testCases := []struct { + name string + txHashes []string + limit *int32 + sortOrder SortOrder + expectedCount int + expectedTxCounts map[string]int + expectMetricCalls int + }{ + { + name: "🟢 basic functionality with multiple tx hashes", + txHashes: []string{"tx1", "tx2"}, + limit: nil, + sortOrder: ASC, + expectedCount: 6, // 3 state changes for tx1 + 3 for tx2 + expectedTxCounts: map[string]int{"tx1": 3, "tx2": 3}, + expectMetricCalls: 1, + }, + { + name: "🟢 with limit parameter", + txHashes: []string{"tx1", "tx2"}, + limit: func() *int32 { v := int32(2); return &v }(), + sortOrder: ASC, + expectedCount: 4, // 2 state changes per tx hash (limited by ROW_NUMBER) + expectedTxCounts: map[string]int{"tx1": 2, "tx2": 2}, + expectMetricCalls: 1, + }, + { + name: "🟢 DESC sort order", + txHashes: []string{"tx1"}, + limit: nil, + sortOrder: DESC, + expectedCount: 3, + expectedTxCounts: map[string]int{"tx1": 3}, + expectMetricCalls: 1, + }, + { + name: "🟢 single transaction", + txHashes: []string{"tx3"}, + limit: nil, + sortOrder: ASC, + expectedCount: 1, + expectedTxCounts: map[string]int{"tx3": 1}, + expectMetricCalls: 1, + }, + { + name: "🟡 empty tx hashes array", + txHashes: []string{}, + limit: nil, + sortOrder: ASC, + expectedCount: 0, + expectedTxCounts: map[string]int{}, + expectMetricCalls: 1, + }, + { + name: "🟡 non-existent transaction hash", + txHashes: []string{"nonexistent"}, + limit: nil, + sortOrder: ASC, + expectedCount: 0, + expectedTxCounts: map[string]int{}, + expectMetricCalls: 1, + }, + { + name: "🟡 mixed existing and non-existent hashes", + txHashes: []string{"tx1", "nonexistent", "tx2"}, + limit: nil, + sortOrder: ASC, + expectedCount: 6, + expectedTxCounts: map[string]int{"tx1": 3, "tx2": 3}, + expectMetricCalls: 1, + }, + { + name: "🟢 limit smaller than state changes per transaction", + txHashes: []string{"tx1"}, + limit: func() *int32 { v := int32(1); return &v }(), + sortOrder: ASC, + expectedCount: 1, // Only first state change due to ROW_NUMBER ranking + expectedTxCounts: map[string]int{"tx1": 1}, + expectMetricCalls: 1, + }, + { + name: "🟢 all transactions", + txHashes: []string{"tx1", "tx2", "tx3"}, + limit: nil, + sortOrder: ASC, + expectedCount: 7, // All state changes + expectedTxCounts: map[string]int{"tx1": 3, "tx2": 3, "tx3": 1}, + expectMetricCalls: 1, + }, + } - // Verify state changes are for correct tx hashes - txHashesFound := make(map[string]int) - for _, sc := range stateChanges { - txHashesFound[sc.TxHash]++ + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return().Times(tc.expectMetricCalls) + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return().Times(tc.expectMetricCalls) + defer mockMetricsService.AssertExpectations(t) + + m := &StateChangeModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + stateChanges, err := m.BatchGetByTxHashes(ctx, tc.txHashes, "", tc.limit, tc.sortOrder) + require.NoError(t, err) + assert.Len(t, stateChanges, tc.expectedCount) + + // Verify state changes are for correct tx hashes + txHashesFound := make(map[string]int) + for _, sc := range stateChanges { + txHashesFound[sc.TxHash]++ + } + assert.Equal(t, tc.expectedTxCounts, txHashesFound) + + // Verify within-transaction ordering + // The CTE uses ROW_NUMBER() OVER (PARTITION BY sc.tx_hash ORDER BY sc.to_id %s, sc.state_change_order %s) + // This means state changes within each transaction should be ordered by (to_id, state_change_order) + if len(stateChanges) > 0 { + stateChangesByTxHash := make(map[string][]*types.StateChangeWithCursor) + for _, sc := range stateChanges { + stateChangesByTxHash[sc.TxHash] = append(stateChangesByTxHash[sc.TxHash], sc) + } + + // Verify ordering within each transaction + for txHash, txStateChanges := range stateChangesByTxHash { + if len(txStateChanges) > 1 { + for i := 1; i < len(txStateChanges); i++ { + prev := txStateChanges[i-1] + curr := txStateChanges[i] + // After final transformation, state changes should be in ascending (to_id, state_change_order) order within each tx + if prev.Cursor.ToID == curr.Cursor.ToID { + assert.True(t, prev.Cursor.StateChangeOrder <= curr.Cursor.StateChangeOrder, + "state changes within tx %s with same to_id should be ordered by state_change_order: prev=(%d,%d), curr=(%d,%d)", + txHash, prev.Cursor.ToID, prev.Cursor.StateChangeOrder, curr.Cursor.ToID, curr.Cursor.StateChangeOrder) + } else { + assert.True(t, prev.Cursor.ToID <= curr.Cursor.ToID, + "state changes within tx %s should be ordered by to_id: prev=(%d,%d), curr=(%d,%d)", + txHash, prev.Cursor.ToID, prev.Cursor.StateChangeOrder, curr.Cursor.ToID, curr.Cursor.StateChangeOrder) + } + } + } + } + } + + // Verify limit behavior when specified + if tc.limit != nil && len(tc.expectedTxCounts) > 0 { + for txHash, count := range tc.expectedTxCounts { + assert.True(t, count <= int(*tc.limit), "number of state changes for %s should not exceed limit %d", txHash, *tc.limit) + } + } + + // Verify cursor structure for returned state changes + for _, sc := range stateChanges { + assert.NotZero(t, sc.Cursor.ToID, "cursor ToID should be set") + assert.NotZero(t, sc.Cursor.StateChangeOrder, "cursor StateChangeOrder should be set") + } + }) } - assert.Equal(t, 2, txHashesFound["tx1"]) - assert.Equal(t, 1, txHashesFound["tx2"]) } func TestStateChangeModel_BatchGetByOperationIDs(t *testing.T) { @@ -425,7 +567,8 @@ func TestStateChangeModel_BatchGetByOperationIDs(t *testing.T) { require.NoError(t, err) // Test BatchGetByOperationID - stateChanges, err := m.BatchGetByOperationIDs(ctx, []int64{123, 456}, "") + limit := int32(10) + stateChanges, err := m.BatchGetByOperationIDs(ctx, []int64{123, 456}, "", &limit, ASC) require.NoError(t, err) assert.Len(t, stateChanges, 3) @@ -437,3 +580,104 @@ func TestStateChangeModel_BatchGetByOperationIDs(t *testing.T) { assert.Equal(t, 2, operationIDsFound[123]) assert.Equal(t, 1, operationIDsFound[456]) } + +func TestStateChangeModel_BatchGetByTxHash(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockMetricsService := metrics.NewMockMetricsService() + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "state_changes").Return() + defer mockMetricsService.AssertExpectations(t) + + m := &StateChangeModel{ + DB: dbConnectionPool, + MetricsService: mockMetricsService, + } + + ctx := context.Background() + now := time.Now() + + // Create test account + address := keypair.MustRandom().Address() + _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) + require.NoError(t, err) + + // Create test transactions first + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO transactions (hash, to_id, envelope_xdr, result_xdr, meta_xdr, ledger_number, ledger_created_at) + VALUES + ('tx1', 1, 'env1', 'res1', 'meta1', 1, $1), + ('tx2', 2, 'env2', 'res2', 'meta2', 2, $1) + `, now) + require.NoError(t, err) + + // Create test state changes for tx1 + _, err = dbConnectionPool.ExecContext(ctx, ` + INSERT INTO state_changes (to_id, state_change_order, state_change_category, ledger_created_at, ledger_number, account_id, operation_id, tx_hash) + VALUES + (1, 1, 'credit', $1, 1, $2, 123, 'tx1'), + (2, 1, 'debit', $1, 2, $2, 124, 'tx1'), + (3, 1, 'credit', $1, 3, $2, 125, 'tx1'), + (4, 1, 'debit', $1, 4, $2, 456, 'tx2') + `, now, address) + require.NoError(t, err) + + t.Run("get all state changes for single transaction", func(t *testing.T) { + stateChanges, err := m.BatchGetByTxHash(ctx, "tx1", "", nil, nil, ASC) + require.NoError(t, err) + assert.Len(t, stateChanges, 3) + + // Verify all state changes are for tx1 + for _, sc := range stateChanges { + assert.Equal(t, "tx1", sc.TxHash) + } + + // Verify ordering (ASC by to_id, state_change_order) + assert.Equal(t, int64(1), stateChanges[0].ToID) + assert.Equal(t, int64(2), stateChanges[1].ToID) + assert.Equal(t, int64(3), stateChanges[2].ToID) + }) + + t.Run("get state changes with pagination - first", func(t *testing.T) { + limit := int32(2) + stateChanges, err := m.BatchGetByTxHash(ctx, "tx1", "", &limit, nil, ASC) + require.NoError(t, err) + assert.Len(t, stateChanges, 2) + + assert.Equal(t, int64(1), stateChanges[0].ToID) + assert.Equal(t, int64(2), stateChanges[1].ToID) + }) + + t.Run("get state changes with cursor pagination", func(t *testing.T) { + limit := int32(2) + cursor := &types.StateChangeCursor{ToID: 1, StateChangeOrder: 1} + stateChanges, err := m.BatchGetByTxHash(ctx, "tx1", "", &limit, cursor, ASC) + require.NoError(t, err) + assert.Len(t, stateChanges, 2) + + // Should get results after cursor (to_id=1, state_change_order=1) + assert.Equal(t, int64(2), stateChanges[0].ToID) + assert.Equal(t, int64(3), stateChanges[1].ToID) + }) + + t.Run("get state changes with DESC ordering", func(t *testing.T) { + stateChanges, err := m.BatchGetByTxHash(ctx, "tx1", "", nil, nil, DESC) + require.NoError(t, err) + assert.Len(t, stateChanges, 3) + + // Verify ordering (results should be in ASC order after DESC query transformation) + assert.Equal(t, int64(1), stateChanges[0].ToID) + assert.Equal(t, int64(2), stateChanges[1].ToID) + assert.Equal(t, int64(3), stateChanges[2].ToID) + }) + + t.Run("no state changes for non-existent transaction", func(t *testing.T) { + stateChanges, err := m.BatchGetByTxHash(ctx, "nonexistent", "", nil, nil, ASC) + require.NoError(t, err) + assert.Empty(t, stateChanges) + }) +} diff --git a/internal/data/transactions.go b/internal/data/transactions.go index 6f953bf2..01d24b58 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -20,9 +20,7 @@ type TransactionModel struct { } func (m *TransactionModel) GetByHash(ctx context.Context, hash string, columns string) (*types.Transaction, error) { - if columns == "" { - columns = "*" - } + columns = prepareColumnsWithID(columns, types.Transaction{}, "", "to_id") query := fmt.Sprintf(`SELECT %s FROM transactions WHERE hash = $1`, columns) var transaction types.Transaction start := time.Now() @@ -37,7 +35,7 @@ func (m *TransactionModel) GetByHash(ctx context.Context, hash string, columns s } func (m *TransactionModel) GetAll(ctx context.Context, columns string, limit *int32, cursor *int64, sortOrder SortOrder) ([]*types.TransactionWithCursor, error) { - columns = prepareColumnsWithID(columns, "transactions", "to_id") + columns = prepareColumnsWithID(columns, types.Transaction{}, "", "to_id") queryBuilder := strings.Builder{} queryBuilder.WriteString(fmt.Sprintf(`SELECT %s, to_id as cursor FROM transactions`, columns)) @@ -78,8 +76,7 @@ func (m *TransactionModel) GetAll(ctx context.Context, columns string, limit *in // BatchGetByAccountAddress gets the transactions that are associated with a single account address. func (m *TransactionModel) BatchGetByAccountAddress(ctx context.Context, accountAddress string, columns string, limit *int32, cursor *int64, orderBy SortOrder) ([]*types.TransactionWithCursor, error) { - // Prepare columns, ensuring transactions.to_id is always included - columns = prepareColumnsWithID(columns, "transactions", "to_id") + columns = prepareColumnsWithID(columns, types.Transaction{}, "transactions", "to_id") // Build paginated query using shared utility query, args := buildGetByAccountAddressQuery(paginatedQueryConfig{ @@ -108,9 +105,7 @@ func (m *TransactionModel) BatchGetByAccountAddress(ctx context.Context, account // BatchGetByOperationIDs gets the transactions that are associated with the given operation IDs. func (m *TransactionModel) BatchGetByOperationIDs(ctx context.Context, operationIDs []int64, columns string) ([]*types.TransactionWithOperationID, error) { - if columns == "" { - columns = "transactions.*" - } + columns = prepareColumnsWithID(columns, types.Transaction{}, "transactions", "to_id") query := fmt.Sprintf(` SELECT %s, o.id as operation_id FROM operations o @@ -131,9 +126,7 @@ func (m *TransactionModel) BatchGetByOperationIDs(ctx context.Context, operation // BatchGetByStateChangeIDs gets the transactions that are associated with the given state changes func (m *TransactionModel) BatchGetByStateChangeIDs(ctx context.Context, scToIDs []int64, scOrders []int64, columns string) ([]*types.TransactionWithStateChangeID, error) { - if columns == "" { - columns = "transactions.*" - } + columns = prepareColumnsWithID(columns, types.Transaction{}, "transactions", "to_id") // Build tuples for the IN clause. Since (to_id, state_change_order) is the primary key of state_changes, // it will be faster to search on this tuple. @@ -144,8 +137,8 @@ func (m *TransactionModel) BatchGetByStateChangeIDs(ctx context.Context, scToIDs query := fmt.Sprintf(` SELECT %s, CONCAT(sc.to_id, '-', sc.state_change_order) as state_change_id - FROM state_changes sc - INNER JOIN transactions ON transactions.hash = sc.tx_hash + FROM transactions + INNER JOIN state_changes sc ON transactions.hash = sc.tx_hash WHERE (sc.to_id, sc.state_change_order) IN (%s) `, columns, strings.Join(tuples, ", ")) diff --git a/internal/serve/graphql/dataloaders/loaders.go b/internal/serve/graphql/dataloaders/loaders.go index d345892f..4fe8e250 100644 --- a/internal/serve/graphql/dataloaders/loaders.go +++ b/internal/serve/graphql/dataloaders/loaders.go @@ -19,7 +19,7 @@ import ( type Dataloaders struct { // OperationsByTxHashLoader batches requests for operations by transaction hash // Used by Transaction.operations field resolver to prevent N+1 queries - OperationsByTxHashLoader *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] + OperationsByTxHashLoader *dataloadgen.Loader[OperationColumnsKey, []*types.OperationWithCursor] // AccountsByTxHashLoader batches requests for accounts by transaction hash // Used by Transaction.accounts field resolver to prevent N+1 queries @@ -27,7 +27,7 @@ type Dataloaders struct { // StateChangesByTxHashLoader batches requests for state changes by transaction hash // Used by Transaction.stateChanges field resolver to prevent N+1 queries - StateChangesByTxHashLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] + StateChangesByTxHashLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] // TransactionsByOperationIDLoader batches requests for transactions by operation ID // Used by Operation.transaction field resolver to prevent N+1 queries @@ -39,7 +39,7 @@ type Dataloaders struct { // StateChangesByOperationIDLoader batches requests for state changes by operation ID // Used by Operation.stateChanges field resolver to prevent N+1 queries - StateChangesByOperationIDLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] + StateChangesByOperationIDLoader *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] // OperationByStateChangeIDLoader batches requests for operations by state change ID // Used by StateChange.operation field resolver to prevent N+1 queries diff --git a/internal/serve/graphql/dataloaders/operation_loaders.go b/internal/serve/graphql/dataloaders/operation_loaders.go index c0dfdeae..9011f76f 100644 --- a/internal/serve/graphql/dataloaders/operation_loaders.go +++ b/internal/serve/graphql/dataloaders/operation_loaders.go @@ -10,33 +10,56 @@ import ( "github.com/stellar/wallet-backend/internal/indexer/types" ) +const ( + // TODO: this should be configurable via config + MaxOperationsPerBatch = 10 +) + type OperationColumnsKey struct { TxHash string AccountID string StateChangeID string Columns string + Limit *int32 + Cursor *int64 + SortOrder data.SortOrder } // opByTxHashLoader creates a dataloader for fetching operations by transaction hash // This prevents N+1 queries when multiple transactions request their operations // The loader batches multiple transaction hashes into a single database query -func operationsByTxHashLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.Operation] { +func operationsByTxHashLoader(models *data.Models) *dataloadgen.Loader[OperationColumnsKey, []*types.OperationWithCursor] { return newOneToManyLoader( - func(ctx context.Context, keys []OperationColumnsKey) ([]*types.Operation, error) { - txHashes := make([]string, len(keys)) + func(ctx context.Context, keys []OperationColumnsKey) ([]*types.OperationWithCursor, error) { + // Add the tx_hash column since that will be used as the primary key to group the operations + // in the final result. columns := keys[0].Columns + if columns != "" { + columns = fmt.Sprintf("%s, tx_hash", columns) + } + sortOrder := keys[0].SortOrder + limit := keys[0].Limit + + // If there is only one key, we can use a simpler query without resorting to the CTE expressions. + // Also, when a single key is requested, we can allow using normal cursor based pagination. + if len(keys) == 1 { + return models.Operations.BatchGetByTxHash(ctx, keys[0].TxHash, columns, limit, keys[0].Cursor, sortOrder) + } + + txHashes := make([]string, len(keys)) + maxLimit := min(*limit, MaxOperationsPerBatch) for i, key := range keys { txHashes[i] = key.TxHash } - return models.Operations.BatchGetByTxHashes(ctx, txHashes, columns) + return models.Operations.BatchGetByTxHashes(ctx, txHashes, columns, &maxLimit, sortOrder) }, - func(item *types.Operation) string { + func(item *types.OperationWithCursor) string { return item.TxHash }, func(key OperationColumnsKey) string { return key.TxHash }, - func(item *types.Operation) types.Operation { + func(item *types.OperationWithCursor) types.OperationWithCursor { return *item }, ) diff --git a/internal/serve/graphql/dataloaders/statechange_loaders.go b/internal/serve/graphql/dataloaders/statechange_loaders.go index 8d5bc634..507caaae 100644 --- a/internal/serve/graphql/dataloaders/statechange_loaders.go +++ b/internal/serve/graphql/dataloaders/statechange_loaders.go @@ -2,6 +2,7 @@ package dataloaders import ( "context" + "fmt" "github.com/vikstrous/dataloadgen" @@ -9,33 +10,56 @@ import ( "github.com/stellar/wallet-backend/internal/indexer/types" ) +const ( + // TODO: this should be configurable via config + MaxStateChangesPerBatch = 10 +) + type StateChangeColumnsKey struct { + TxHash string AccountID string OperationID int64 - TxHash string Columns string + Limit *int32 + Cursor *types.StateChangeCursor + SortOrder data.SortOrder } // stateChangesByTxHashLoader creates a dataloader for fetching state changes by transaction hash // This prevents N+1 queries when multiple transactions request their state changes // The loader batches multiple transaction hashes into a single database query -func stateChangesByTxHashLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { +func stateChangesByTxHashLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] { return newOneToManyLoader( - func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChange, error) { - txHashes := make([]string, len(keys)) + func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChangeWithCursor, error) { + // Add the tx_hash column since that will be used as the primary key to group the state changes + // in the final result. columns := keys[0].Columns + if columns != "" { + columns = fmt.Sprintf("%s, tx_hash", columns) + } + sortOrder := keys[0].SortOrder + limit := keys[0].Limit + + // If there is only one key, we can use a simpler query without resorting to the CTE expressions. + // Also, when a single key is requested, we can allow using normal cursor based pagination. + if len(keys) == 1 { + return models.StateChanges.BatchGetByTxHash(ctx, keys[0].TxHash, columns, limit, keys[0].Cursor, sortOrder) + } + + txHashes := make([]string, len(keys)) + maxLimit := min(*limit, MaxStateChangesPerBatch) for i, key := range keys { txHashes[i] = key.TxHash } - return models.StateChanges.BatchGetByTxHashes(ctx, txHashes, columns) + return models.StateChanges.BatchGetByTxHashes(ctx, txHashes, columns, &maxLimit, sortOrder) }, - func(item *types.StateChange) string { + func(item *types.StateChangeWithCursor) string { return item.TxHash }, func(key StateChangeColumnsKey) string { return key.TxHash }, - func(item *types.StateChange) types.StateChange { + func(item *types.StateChangeWithCursor) types.StateChangeWithCursor { return *item }, ) @@ -44,23 +68,37 @@ func stateChangesByTxHashLoader(models *data.Models) *dataloadgen.Loader[StateCh // stateChangesByOperationIDLoader creates a dataloader for fetching state changes by operation ID // This prevents N+1 queries when multiple operations request their state changes // The loader batches multiple operation IDs into a single database query -func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChange] { +func stateChangesByOperationIDLoader(models *data.Models) *dataloadgen.Loader[StateChangeColumnsKey, []*types.StateChangeWithCursor] { return newOneToManyLoader( - func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChange, error) { - operationIDs := make([]int64, len(keys)) + func(ctx context.Context, keys []StateChangeColumnsKey) ([]*types.StateChangeWithCursor, error) { + // Add the operation_id column since that will be used as the primary key to group the state changes + // in the final result. columns := keys[0].Columns + if columns != "" { + columns = fmt.Sprintf("%s, operation_id", columns) + } + sortOrder := keys[0].SortOrder + limit := keys[0].Limit + + // If there is only one key, we can use a simpler query without resorting to the CTE expressions. + // Also, when a single key is requested, we can allow using normal cursor based pagination. + if len(keys) == 1 { + return models.StateChanges.BatchGetByOperationID(ctx, keys[0].OperationID, columns, limit, keys[0].Cursor, sortOrder) + } + + operationIDs := make([]int64, len(keys)) for i, key := range keys { operationIDs[i] = key.OperationID } - return models.StateChanges.BatchGetByOperationIDs(ctx, operationIDs, columns) + return models.StateChanges.BatchGetByOperationIDs(ctx, operationIDs, columns, limit, sortOrder) }, - func(item *types.StateChange) int64 { + func(item *types.StateChangeWithCursor) int64 { return item.OperationID }, func(key StateChangeColumnsKey) int64 { return key.OperationID }, - func(item *types.StateChange) types.StateChange { + func(item *types.StateChangeWithCursor) types.StateChangeWithCursor { return *item }, ) diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 74f06d30..dc16122e 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -84,7 +84,7 @@ type ComplexityRoot struct { LedgerNumber func(childComplexity int) int OperationType func(childComplexity int) int OperationXDR func(childComplexity int) int - StateChanges func(childComplexity int) int + StateChanges func(childComplexity int, first *int32, after *string, last *int32, before *string) int Transaction func(childComplexity int) int } @@ -106,9 +106,10 @@ type ComplexityRoot struct { } Query struct { - Account func(childComplexity int, address string) int + AccountByAddress func(childComplexity int, address string) int + OperationByID func(childComplexity int, id int64) int Operations func(childComplexity int, first *int32, after *string, last *int32, before *string) int - StateChanges func(childComplexity int, limit *int32) int + StateChanges func(childComplexity int, first *int32, after *string, last *int32, before *string) int TransactionByHash func(childComplexity int, hash string) int Transactions func(childComplexity int, first *int32, after *string, last *int32, before *string) int } @@ -160,9 +161,9 @@ type ComplexityRoot struct { LedgerCreatedAt func(childComplexity int) int LedgerNumber func(childComplexity int) int MetaXDR func(childComplexity int) int - Operations func(childComplexity int) int + Operations func(childComplexity int, first *int32, after *string, last *int32, before *string) int ResultXDR func(childComplexity int) int - StateChanges func(childComplexity int) int + StateChanges func(childComplexity int, first *int32, after *string, last *int32, before *string) int } TransactionConnection struct { @@ -190,14 +191,15 @@ type MutationResolver interface { type OperationResolver interface { Transaction(ctx context.Context, obj *types.Operation) (*types.Transaction, error) Accounts(ctx context.Context, obj *types.Operation) ([]*types.Account, error) - StateChanges(ctx context.Context, obj *types.Operation) ([]*types.StateChange, error) + StateChanges(ctx context.Context, obj *types.Operation, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) } type QueryResolver interface { TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) Transactions(ctx context.Context, first *int32, after *string, last *int32, before *string) (*TransactionConnection, error) - Account(ctx context.Context, address string) (*types.Account, error) + AccountByAddress(ctx context.Context, address string) (*types.Account, error) Operations(ctx context.Context, first *int32, after *string, last *int32, before *string) (*OperationConnection, error) - StateChanges(ctx context.Context, limit *int32) ([]*types.StateChange, error) + OperationByID(ctx context.Context, id int64) (*types.Operation, error) + StateChanges(ctx context.Context, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) } type StateChangeResolver interface { TokenID(ctx context.Context, obj *types.StateChange) (*string, error) @@ -217,9 +219,9 @@ type StateChangeResolver interface { Transaction(ctx context.Context, obj *types.StateChange) (*types.Transaction, error) } type TransactionResolver interface { - Operations(ctx context.Context, obj *types.Transaction) ([]*types.Operation, error) + Operations(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*OperationConnection, error) Accounts(ctx context.Context, obj *types.Transaction) ([]*types.Account, error) - StateChanges(ctx context.Context, obj *types.Transaction) ([]*types.StateChange, error) + StateChanges(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) } type executableSchema struct { @@ -402,7 +404,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Operation.StateChanges(childComplexity), true + args, err := ec.field_Operation_stateChanges_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Operation.StateChanges(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "Operation.transaction": if e.complexity.Operation.Transaction == nil { @@ -467,17 +474,29 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.PageInfo.StartCursor(childComplexity), true - case "Query.account": - if e.complexity.Query.Account == nil { + case "Query.accountByAddress": + if e.complexity.Query.AccountByAddress == nil { break } - args, err := ec.field_Query_account_args(ctx, rawArgs) + args, err := ec.field_Query_accountByAddress_args(ctx, rawArgs) if err != nil { return 0, false } - return e.complexity.Query.Account(childComplexity, args["address"].(string)), true + return e.complexity.Query.AccountByAddress(childComplexity, args["address"].(string)), true + + case "Query.operationById": + if e.complexity.Query.OperationByID == nil { + break + } + + args, err := ec.field_Query_operationById_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.OperationByID(childComplexity, args["id"].(int64)), true case "Query.operations": if e.complexity.Query.Operations == nil { @@ -501,7 +520,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.complexity.Query.StateChanges(childComplexity, args["limit"].(*int32)), true + return e.complexity.Query.StateChanges(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "Query.transactionByHash": if e.complexity.Query.TransactionByHash == nil { @@ -770,7 +789,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Transaction.Operations(childComplexity), true + args, err := ec.field_Transaction_operations_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Transaction.Operations(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "Transaction.resultXdr": if e.complexity.Transaction.ResultXDR == nil { @@ -784,7 +808,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Transaction.StateChanges(childComplexity), true + args, err := ec.field_Transaction_stateChanges_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Transaction.StateChanges(childComplexity, args["first"].(*int32), args["after"].(*string), args["last"].(*int32), args["before"].(*string)), true case "TransactionConnection.edges": if e.complexity.TransactionConnection.Edges == nil { @@ -1119,7 +1148,7 @@ type Operation{ accounts: [Account!]! @goField(forceResolver: true) # Related state changes - uses resolver to fetch associated changes - stateChanges: [StateChange!]! @goField(forceResolver: true) + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } `, BuiltIn: false}, {Name: "../schema/pagination.graphqls", Input: `type TransactionConnection { @@ -1162,11 +1191,12 @@ type PageInfo { {Name: "../schema/queries.graphqls", Input: `# GraphQL Query root type - defines all available queries in the API # In GraphQL, the Query type is the entry point for read operations type Query { - transactionByHash(hash: String!): Transaction + transactionByHash(hash: String!): Transaction transactions(first: Int, after: String, last: Int, before: String): TransactionConnection - account(address: String!): Account + accountByAddress(address: String!): Account operations(first: Int, after: String, last: Int, before: String): OperationConnection - stateChanges(limit: Int): [StateChange!]! + operationById(id: Int64!): Operation + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } `, BuiltIn: false}, {Name: "../schema/scalars.graphqls", Input: `# GraphQL Custom Scalars - extend GraphQL's built-in scalar types @@ -1240,13 +1270,13 @@ type Transaction{ # GraphQL Relationships - these fields require resolvers # @goField(forceResolver: true) tells gqlgen to always generate a resolver # even if the Go struct has a matching field - operations: [Operation!]! @goField(forceResolver: true) + operations(first: Int, after: String, last: Int, before: String): OperationConnection # Related accounts - uses resolver with dataloader for efficiency accounts: [Account!]! @goField(forceResolver: true) # Related state changes - uses resolver to fetch associated changes - stateChanges: [StateChange!]! @goField(forceResolver: true) + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } `, BuiltIn: false}, } @@ -1556,6 +1586,83 @@ func (ec *executionContext) field_Mutation_registerAccount_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Operation_stateChanges_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Operation_stateChanges_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := ec.field_Operation_stateChanges_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Operation_stateChanges_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Operation_stateChanges_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} +func (ec *executionContext) field_Operation_stateChanges_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Operation_stateChanges_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Operation_stateChanges_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Operation_stateChanges_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1579,17 +1686,17 @@ func (ec *executionContext) field_Query___type_argsName( return zeroVal, nil } -func (ec *executionContext) field_Query_account_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { +func (ec *executionContext) field_Query_accountByAddress_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_account_argsAddress(ctx, rawArgs) + arg0, err := ec.field_Query_accountByAddress_argsAddress(ctx, rawArgs) if err != nil { return nil, err } args["address"] = arg0 return args, nil } -func (ec *executionContext) field_Query_account_argsAddress( +func (ec *executionContext) field_Query_accountByAddress_argsAddress( ctx context.Context, rawArgs map[string]any, ) (string, error) { @@ -1602,6 +1709,29 @@ func (ec *executionContext) field_Query_account_argsAddress( return zeroVal, nil } +func (ec *executionContext) field_Query_operationById_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_operationById_argsID(ctx, rawArgs) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_operationById_argsID( + ctx context.Context, + rawArgs map[string]any, +) (int64, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + if tmp, ok := rawArgs["id"]; ok { + return ec.unmarshalNInt642int64(ctx, tmp) + } + + var zeroVal int64 + return zeroVal, nil +} + func (ec *executionContext) field_Query_operations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1682,19 +1812,34 @@ func (ec *executionContext) field_Query_operations_argsBefore( func (ec *executionContext) field_Query_stateChanges_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_stateChanges_argsLimit(ctx, rawArgs) + arg0, err := ec.field_Query_stateChanges_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := ec.field_Query_stateChanges_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Query_stateChanges_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Query_stateChanges_argsBefore(ctx, rawArgs) if err != nil { return nil, err } - args["limit"] = arg0 + args["before"] = arg3 return args, nil } -func (ec *executionContext) field_Query_stateChanges_argsLimit( +func (ec *executionContext) field_Query_stateChanges_argsFirst( ctx context.Context, rawArgs map[string]any, ) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { return ec.unmarshalOInt2ᚖint32(ctx, tmp) } @@ -1702,6 +1847,45 @@ func (ec *executionContext) field_Query_stateChanges_argsLimit( return zeroVal, nil } +func (ec *executionContext) field_Query_stateChanges_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_stateChanges_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Query_stateChanges_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field_Query_transactionByHash_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1802,6 +1986,160 @@ func (ec *executionContext) field_Query_transactions_argsBefore( return zeroVal, nil } +func (ec *executionContext) field_Transaction_operations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Transaction_operations_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := ec.field_Transaction_operations_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Transaction_operations_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Transaction_operations_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} +func (ec *executionContext) field_Transaction_operations_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Transaction_operations_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Transaction_operations_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Transaction_operations_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Transaction_stateChanges_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Transaction_stateChanges_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := ec.field_Transaction_stateChanges_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Transaction_stateChanges_argsLast(ctx, rawArgs) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := ec.field_Transaction_stateChanges_argsBefore(ctx, rawArgs) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} +func (ec *executionContext) field_Transaction_stateChanges_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Transaction_stateChanges_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Transaction_stateChanges_argsLast( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last")) + if tmp, ok := rawArgs["last"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Transaction_stateChanges_argsBefore( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) + if tmp, ok := rawArgs["before"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -2874,24 +3212,21 @@ func (ec *executionContext) _Operation_stateChanges(ctx context.Context, field g }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Operation().StateChanges(rctx, obj) + return ec.resolvers.Operation().StateChanges(rctx, obj, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.StateChange) + res := resTmp.(*StateChangeConnection) fc.Result = res - return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) + return ec.marshalOStateChangeConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Operation_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Operation_stateChanges(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Operation", Field: field, @@ -2899,52 +3234,25 @@ func (ec *executionContext) fieldContext_Operation_stateChanges(_ context.Contex IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "accountId": - return ec.fieldContext_StateChange_accountId(ctx, field) - case "stateChangeCategory": - return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) - case "stateChangeReason": - return ec.fieldContext_StateChange_stateChangeReason(ctx, field) - case "ingestedAt": - return ec.fieldContext_StateChange_ingestedAt(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) - case "ledgerNumber": - return ec.fieldContext_StateChange_ledgerNumber(ctx, field) - case "tokenId": - return ec.fieldContext_StateChange_tokenId(ctx, field) - case "amount": - return ec.fieldContext_StateChange_amount(ctx, field) - case "claimableBalanceId": - return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) - case "liquidityPoolId": - return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) - case "offerId": - return ec.fieldContext_StateChange_offerId(ctx, field) - case "signerAccountId": - return ec.fieldContext_StateChange_signerAccountId(ctx, field) - case "spenderAccountId": - return ec.fieldContext_StateChange_spenderAccountId(ctx, field) - case "sponsoredAccountId": - return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) - case "sponsorAccountId": - return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) - case "signerWeights": - return ec.fieldContext_StateChange_signerWeights(ctx, field) - case "thresholds": - return ec.fieldContext_StateChange_thresholds(ctx, field) - case "flags": - return ec.fieldContext_StateChange_flags(ctx, field) - case "keyValue": - return ec.fieldContext_StateChange_keyValue(ctx, field) - case "operation": - return ec.fieldContext_StateChange_operation(ctx, field) - case "transaction": - return ec.fieldContext_StateChange_transaction(ctx, field) + case "edges": + return ec.fieldContext_StateChangeConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_StateChangeConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + return nil, fmt.Errorf("no field named %q was found under type StateChangeConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Operation_stateChanges_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -3456,8 +3764,8 @@ func (ec *executionContext) fieldContext_Query_transactions(ctx context.Context, return fc, nil } -func (ec *executionContext) _Query_account(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_account(ctx, field) +func (ec *executionContext) _Query_accountByAddress(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_accountByAddress(ctx, field) if err != nil { return graphql.Null } @@ -3470,7 +3778,7 @@ func (ec *executionContext) _Query_account(ctx context.Context, field graphql.Co }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().Account(rctx, fc.Args["address"].(string)) + return ec.resolvers.Query().AccountByAddress(rctx, fc.Args["address"].(string)) }) if err != nil { ec.Error(ctx, err) @@ -3484,7 +3792,7 @@ func (ec *executionContext) _Query_account(ctx context.Context, field graphql.Co return ec.marshalOAccount2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐAccount(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_account(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_accountByAddress(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -3511,7 +3819,7 @@ func (ec *executionContext) fieldContext_Query_account(ctx context.Context, fiel } }() ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Query_account_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + if fc.Args, err = ec.field_Query_accountByAddress_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { ec.Error(ctx, err) return fc, err } @@ -3576,8 +3884,8 @@ func (ec *executionContext) fieldContext_Query_operations(ctx context.Context, f return fc, nil } -func (ec *executionContext) _Query_stateChanges(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_stateChanges(ctx, field) +func (ec *executionContext) _Query_operationById(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_operationById(ctx, field) if err != nil { return graphql.Null } @@ -3590,24 +3898,21 @@ func (ec *executionContext) _Query_stateChanges(ctx context.Context, field graph }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().StateChanges(rctx, fc.Args["limit"].(*int32)) + return ec.resolvers.Query().OperationByID(rctx, fc.Args["id"].(int64)) }) 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.([]*types.StateChange) + res := resTmp.(*types.Operation) fc.Result = res - return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) + return ec.marshalOOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_stateChanges(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_operationById(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -3615,50 +3920,84 @@ func (ec *executionContext) fieldContext_Query_stateChanges(ctx context.Context, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "accountId": - return ec.fieldContext_StateChange_accountId(ctx, field) - case "stateChangeCategory": - return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) - case "stateChangeReason": - return ec.fieldContext_StateChange_stateChangeReason(ctx, field) - case "ingestedAt": - return ec.fieldContext_StateChange_ingestedAt(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) + case "id": + return ec.fieldContext_Operation_id(ctx, field) + case "operationType": + return ec.fieldContext_Operation_operationType(ctx, field) + case "operationXdr": + return ec.fieldContext_Operation_operationXdr(ctx, field) case "ledgerNumber": - return ec.fieldContext_StateChange_ledgerNumber(ctx, field) - case "tokenId": - return ec.fieldContext_StateChange_tokenId(ctx, field) - case "amount": - return ec.fieldContext_StateChange_amount(ctx, field) - case "claimableBalanceId": - return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) - case "liquidityPoolId": - return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) - case "offerId": - return ec.fieldContext_StateChange_offerId(ctx, field) - case "signerAccountId": - return ec.fieldContext_StateChange_signerAccountId(ctx, field) - case "spenderAccountId": - return ec.fieldContext_StateChange_spenderAccountId(ctx, field) - case "sponsoredAccountId": - return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) - case "sponsorAccountId": - return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) - case "signerWeights": - return ec.fieldContext_StateChange_signerWeights(ctx, field) - case "thresholds": - return ec.fieldContext_StateChange_thresholds(ctx, field) - case "flags": - return ec.fieldContext_StateChange_flags(ctx, field) - case "keyValue": - return ec.fieldContext_StateChange_keyValue(ctx, field) - case "operation": - return ec.fieldContext_StateChange_operation(ctx, field) + return ec.fieldContext_Operation_ledgerNumber(ctx, field) + case "ledgerCreatedAt": + return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) + case "ingestedAt": + return ec.fieldContext_Operation_ingestedAt(ctx, field) case "transaction": - return ec.fieldContext_StateChange_transaction(ctx, field) + return ec.fieldContext_Operation_transaction(ctx, field) + case "accounts": + return ec.fieldContext_Operation_accounts(ctx, field) + case "stateChanges": + return ec.fieldContext_Operation_stateChanges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_operationById_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_stateChanges(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_stateChanges(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) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().StateChanges(rctx, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*StateChangeConnection) + fc.Result = res + return ec.marshalOStateChangeConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeConnection(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_stateChanges(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "edges": + return ec.fieldContext_StateChangeConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_StateChangeConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + return nil, fmt.Errorf("no field named %q was found under type StateChangeConnection", field.Name) }, } defer func() { @@ -5374,24 +5713,21 @@ func (ec *executionContext) _Transaction_operations(ctx context.Context, field g }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Transaction().Operations(rctx, obj) + return ec.resolvers.Transaction().Operations(rctx, obj, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.Operation) + res := resTmp.(*OperationConnection) fc.Result = res - return ec.marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx, field.Selections, res) + return ec.marshalOOperationConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Transaction_operations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Transaction_operations(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Transaction", Field: field, @@ -5399,28 +5735,25 @@ func (ec *executionContext) fieldContext_Transaction_operations(_ context.Contex IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_Operation_id(ctx, field) - case "operationType": - return ec.fieldContext_Operation_operationType(ctx, field) - case "operationXdr": - return ec.fieldContext_Operation_operationXdr(ctx, field) - case "ledgerNumber": - return ec.fieldContext_Operation_ledgerNumber(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_Operation_ledgerCreatedAt(ctx, field) - case "ingestedAt": - return ec.fieldContext_Operation_ingestedAt(ctx, field) - case "transaction": - return ec.fieldContext_Operation_transaction(ctx, field) - case "accounts": - return ec.fieldContext_Operation_accounts(ctx, field) - case "stateChanges": - return ec.fieldContext_Operation_stateChanges(ctx, field) + case "edges": + return ec.fieldContext_OperationConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_OperationConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Operation", field.Name) + return nil, fmt.Errorf("no field named %q was found under type OperationConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Transaction_operations_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -5492,24 +5825,21 @@ func (ec *executionContext) _Transaction_stateChanges(ctx context.Context, field }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Transaction().StateChanges(rctx, obj) + return ec.resolvers.Transaction().StateChanges(rctx, obj, fc.Args["first"].(*int32), fc.Args["after"].(*string), fc.Args["last"].(*int32), fc.Args["before"].(*string)) }) 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.([]*types.StateChange) + res := resTmp.(*StateChangeConnection) fc.Result = res - return ec.marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx, field.Selections, res) + return ec.marshalOStateChangeConnection2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐStateChangeConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Transaction_stateChanges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Transaction_stateChanges(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Transaction", Field: field, @@ -5517,52 +5847,25 @@ func (ec *executionContext) fieldContext_Transaction_stateChanges(_ context.Cont IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "accountId": - return ec.fieldContext_StateChange_accountId(ctx, field) - case "stateChangeCategory": - return ec.fieldContext_StateChange_stateChangeCategory(ctx, field) - case "stateChangeReason": - return ec.fieldContext_StateChange_stateChangeReason(ctx, field) - case "ingestedAt": - return ec.fieldContext_StateChange_ingestedAt(ctx, field) - case "ledgerCreatedAt": - return ec.fieldContext_StateChange_ledgerCreatedAt(ctx, field) - case "ledgerNumber": - return ec.fieldContext_StateChange_ledgerNumber(ctx, field) - case "tokenId": - return ec.fieldContext_StateChange_tokenId(ctx, field) - case "amount": - return ec.fieldContext_StateChange_amount(ctx, field) - case "claimableBalanceId": - return ec.fieldContext_StateChange_claimableBalanceId(ctx, field) - case "liquidityPoolId": - return ec.fieldContext_StateChange_liquidityPoolId(ctx, field) - case "offerId": - return ec.fieldContext_StateChange_offerId(ctx, field) - case "signerAccountId": - return ec.fieldContext_StateChange_signerAccountId(ctx, field) - case "spenderAccountId": - return ec.fieldContext_StateChange_spenderAccountId(ctx, field) - case "sponsoredAccountId": - return ec.fieldContext_StateChange_sponsoredAccountId(ctx, field) - case "sponsorAccountId": - return ec.fieldContext_StateChange_sponsorAccountId(ctx, field) - case "signerWeights": - return ec.fieldContext_StateChange_signerWeights(ctx, field) - case "thresholds": - return ec.fieldContext_StateChange_thresholds(ctx, field) - case "flags": - return ec.fieldContext_StateChange_flags(ctx, field) - case "keyValue": - return ec.fieldContext_StateChange_keyValue(ctx, field) - case "operation": - return ec.fieldContext_StateChange_operation(ctx, field) - case "transaction": - return ec.fieldContext_StateChange_transaction(ctx, field) + case "edges": + return ec.fieldContext_StateChangeConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_StateChangeConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type StateChange", field.Name) + return nil, fmt.Errorf("no field named %q was found under type StateChangeConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Transaction_stateChanges_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -8350,16 +8653,13 @@ func (ec *executionContext) _Operation(ctx context.Context, sel ast.SelectionSet case "stateChanges": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._Operation_stateChanges(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -8593,7 +8893,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "account": + case "accountByAddress": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -8602,7 +8902,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._Query_account(ctx, field) + res = ec._Query_accountByAddress(ctx, field) return res } @@ -8630,20 +8930,36 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "operationById": + 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._Query_operationById(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "stateChanges": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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._Query_stateChanges(ctx, field) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -9415,16 +9731,13 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS case "operations": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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_operations(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -9487,16 +9800,13 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS case "stateChanges": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + 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_stateChanges(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -10100,60 +10410,6 @@ func (ec *executionContext) marshalNInt642int64(ctx context.Context, sel ast.Sel return res } -func (ec *executionContext) marshalNOperation2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperationᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.Operation) graphql.Marshaler { - ret := make(graphql.Array, len(v)) - var wg sync.WaitGroup - isLen1 := len(v) == 1 - if !isLen1 { - wg.Add(len(v)) - } - for i := range v { - i := i - fc := &graphql.FieldContext{ - Index: &i, - Result: &v[i], - } - ctx := graphql.WithFieldContext(ctx, fc) - f := func(i int) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = nil - } - }() - if !isLen1 { - defer wg.Done() - } - ret[i] = ec.marshalNOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx, sel, v[i]) - } - if isLen1 { - f(i) - } else { - go f(i) - } - - } - wg.Wait() - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - -func (ec *executionContext) marshalNOperation2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐOperation(ctx context.Context, sel ast.SelectionSet, v *types.Operation) graphql.Marshaler { - if v == nil { - if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { - ec.Errorf(ctx, "the requested element is null which the schema does not allow") - } - return graphql.Null - } - return ec._Operation(ctx, sel, v) -} - func (ec *executionContext) marshalNOperationEdge2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐOperationEdge(ctx context.Context, sel ast.SelectionSet, v *OperationEdge) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -10210,60 +10466,6 @@ func (ec *executionContext) marshalNRegisterAccountPayload2ᚖgithubᚗcomᚋste return ec._RegisterAccountPayload(ctx, sel, v) } -func (ec *executionContext) marshalNStateChange2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.StateChange) graphql.Marshaler { - ret := make(graphql.Array, len(v)) - var wg sync.WaitGroup - isLen1 := len(v) == 1 - if !isLen1 { - wg.Add(len(v)) - } - for i := range v { - i := i - fc := &graphql.FieldContext{ - Index: &i, - Result: &v[i], - } - ctx := graphql.WithFieldContext(ctx, fc) - f := func(i int) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = nil - } - }() - if !isLen1 { - defer wg.Done() - } - ret[i] = ec.marshalNStateChange2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChange(ctx, sel, v[i]) - } - if isLen1 { - f(i) - } else { - go f(i) - } - - } - wg.Wait() - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - -func (ec *executionContext) marshalNStateChange2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChange(ctx context.Context, sel ast.SelectionSet, v *types.StateChange) graphql.Marshaler { - if v == nil { - if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { - ec.Errorf(ctx, "the requested element is null which the schema does not allow") - } - return graphql.Null - } - return ec._StateChange(ctx, sel, v) -} - func (ec *executionContext) unmarshalNStateChangeCategory2githubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋindexerᚋtypesᚐStateChangeCategory(ctx context.Context, v any) (types.StateChangeCategory, error) { tmp, err := graphql.UnmarshalString(v) res := types.StateChangeCategory(tmp) diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go index 44858553..b9c214e4 100644 --- a/internal/serve/graphql/resolvers/account.resolvers.go +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -29,7 +29,7 @@ func (r *accountResolver) Transactions(ctx context.Context, obj *types.Account, } queryLimit := *params.Limit + 1 // +1 to check if there is a next page - dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}) transactions, err := r.models.Transactions.BatchGetByAccountAddress(ctx, obj.StellarAddress, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) if err != nil { return nil, fmt.Errorf("getting transactions from db for account %s: %w", obj.StellarAddress, err) @@ -62,7 +62,7 @@ func (r *accountResolver) Operations(ctx context.Context, obj *types.Account, fi } queryLimit := *params.Limit + 1 // +1 to check if there is a next page - dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "operations") + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) operations, err := r.models.Operations.BatchGetByAccountAddress(ctx, obj.StellarAddress, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) if err != nil { return nil, fmt.Errorf("getting operations from db for account %s: %w", obj.StellarAddress, err) @@ -94,7 +94,7 @@ func (r *accountResolver) StateChanges(ctx context.Context, obj *types.Account, } queryLimit := *params.Limit + 1 // +1 to check if there is a next page - dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "state_changes") + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}) stateChanges, err := r.models.StateChanges.BatchGetByAccountAddress(ctx, obj.StellarAddress, strings.Join(dbColumns, ", "), &queryLimit, params.StateChangeCursor, params.SortOrder) if err != nil { return nil, fmt.Errorf("getting state changes from db for account %s: %w", obj.StellarAddress, err) diff --git a/internal/serve/graphql/resolvers/operation.resolvers.go b/internal/serve/graphql/resolvers/operation.resolvers.go index 9fcbd9d8..e7772b05 100644 --- a/internal/serve/graphql/resolvers/operation.resolvers.go +++ b/internal/serve/graphql/resolvers/operation.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "fmt" "strings" "github.com/stellar/wallet-backend/internal/indexer/types" @@ -22,7 +23,7 @@ func (r *operationResolver) Transaction(ctx context.Context, obj *types.Operatio // Extract dataloaders from GraphQL context // Dataloaders are injected by middleware to batch database queries loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}) loaderKey := dataloaders.TransactionColumnsKey{ OperationID: obj.ID, @@ -44,7 +45,7 @@ func (r *operationResolver) Transaction(ctx context.Context, obj *types.Operatio // Field resolvers receive the parent object (Operation) and return the field value func (r *operationResolver) Accounts(ctx context.Context, obj *types.Operation) ([]*types.Account, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Account{}, "accounts") + dbColumns := GetDBColumnsForFields(ctx, types.Account{}) loaderKey := dataloaders.AccountColumnsKey{ OperationID: obj.ID, @@ -64,22 +65,44 @@ func (r *operationResolver) Accounts(ctx context.Context, obj *types.Operation) // This is a field resolver - it resolves the "stateChanges" field on an Operation object // gqlgen calls this when a GraphQL query requests the stateChanges field on an Operation // Field resolvers receive the parent object (Operation) and return the field value -func (r *operationResolver) StateChanges(ctx context.Context, obj *types.Operation) ([]*types.StateChange, error) { - loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "") +func (r *operationResolver) StateChanges(ctx context.Context, obj *types.Operation, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}) + params, err := parsePaginationParams(first, after, last, before, true) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) + } + queryLimit := *params.Limit + 1 // +1 to check if there is a next page + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) loaderKey := dataloaders.StateChangeColumnsKey{ OperationID: obj.ID, Columns: strings.Join(dbColumns, ", "), + Limit: &queryLimit, + Cursor: params.StateChangeCursor, + SortOrder: params.SortOrder, } - // Use dataloader to efficiently batch-load state changes for this operation - // This prevents N+1 queries when multiple operations request their state changes stateChanges, err := loaders.StateChangesByOperationIDLoader.Load(ctx, loaderKey) if err != nil { return nil, err } - return stateChanges, nil + + conn := NewConnectionWithRelayPagination(stateChanges, params, func(sc *types.StateChangeWithCursor) string { + return fmt.Sprintf("%d:%d", sc.Cursor.ToID, sc.Cursor.StateChangeOrder) + }) + + edges := make([]*graphql1.StateChangeEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.StateChangeEdge{ + Node: &edge.Node.StateChange, + Cursor: edge.Cursor, + } + } + + return &graphql1.StateChangeConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // Operation returns graphql1.OperationResolver implementation. diff --git a/internal/serve/graphql/resolvers/operation_resolvers_test.go b/internal/serve/graphql/resolvers/operation_resolvers_test.go index dfc6c2e5..bbb7af95 100644 --- a/internal/serve/graphql/resolvers/operation_resolvers_test.go +++ b/internal/serve/graphql/resolvers/operation_resolvers_test.go @@ -132,36 +132,127 @@ func TestOperationResolver_StateChanges(t *testing.T) { }, }} parentOperation := &types.Operation{ID: toid.New(1000, 1, 1).ToInt64()} + nonExistentOperation := &types.Operation{ID: 9999} - t.Run("success", func(t *testing.T) { + t.Run("success without pagination", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) - ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(getTestCtx("state_changes", []string{}), middleware.LoadersKey, loaders) - stateChanges, err := resolver.StateChanges(ctx, parentOperation) + stateChanges, err := resolver.StateChanges(ctx, parentOperation, nil, nil, nil, nil) require.NoError(t, err) - require.Len(t, stateChanges, 2) - assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) - assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) + require.Len(t, stateChanges.Edges, 2) + // operation 1000,1,1 has 2 state changes + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) + }) + + t.Run("get state changes with first/after pagination", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) + first := int32(1) + stateChanges, err := resolver.StateChanges(ctx, parentOperation, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 1) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) + + // Get the next page using cursor + nextCursor := stateChanges.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + stateChanges, err = resolver.StateChanges(ctx, parentOperation, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 1) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + }) + + t.Run("get state changes with last/before pagination", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) + last := int32(1) + stateChanges, err := resolver.StateChanges(ctx, parentOperation, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 1) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + + // Get the previous page using cursor + last = int32(10) + prevCursor := stateChanges.PageInfo.StartCursor + assert.NotNil(t, prevCursor) + stateChanges, err = resolver.StateChanges(ctx, parentOperation, nil, nil, &last, prevCursor) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 1) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) + }) + + t.Run("invalid pagination params", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) + first := int32(0) + last := int32(1) + after := encodeCursor(int64(1)) + before := encodeCursor(int64(2)) + + _, err := resolver.StateChanges(ctx, parentOperation, &first, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + + first = int32(1) + _, err = resolver.StateChanges(ctx, parentOperation, &first, nil, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + + _, err = resolver.StateChanges(ctx, parentOperation, nil, &after, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + + _, err = resolver.StateChanges(ctx, parentOperation, &first, nil, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + + _, err = resolver.StateChanges(ctx, parentOperation, nil, &after, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") + }) + + t.Run("pagination with larger limit than available data", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) + first := int32(100) + stateChanges, err := resolver.StateChanges(ctx, parentOperation, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 2) // operation has 2 state changes + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) t.Run("nil operation panics", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) - ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) assert.Panics(t, func() { - _, _ = resolver.StateChanges(ctx, nil) //nolint:errcheck + _, _ = resolver.StateChanges(ctx, nil, nil, nil, nil, nil) //nolint:errcheck }) }) t.Run("operation with no state changes", func(t *testing.T) { - nonExistentOperation := &types.Operation{ID: 9999} loaders := dataloaders.NewDataloaders(resolver.models) - ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(getTestCtx("state_changes", []string{""}), middleware.LoadersKey, loaders) - stateChanges, err := resolver.StateChanges(ctx, nonExistentOperation) + stateChanges, err := resolver.StateChanges(ctx, nonExistentOperation, nil, nil, nil, nil) require.NoError(t, err) - assert.Empty(t, stateChanges) + assert.Empty(t, stateChanges.Edges) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) } diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go index 356a50f7..0e336b25 100644 --- a/internal/serve/graphql/resolvers/queries.resolvers.go +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -17,7 +17,7 @@ import ( // This is a root query resolver - it handles the "transactionByHash" query. // gqlgen calls this function when a GraphQL query requests "transactionByHash" func (r *queryResolver) TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) { - dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "") + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}) return r.models.Transactions.GetByHash(ctx, hash, strings.Join(dbColumns, ", ")) } @@ -31,7 +31,7 @@ func (r *queryResolver) Transactions(ctx context.Context, first *int32, after *s } queryLimit := *params.Limit + 1 // +1 to check if there is a next page - dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "") + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}) transactions, err := r.models.Transactions.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) if err != nil { return nil, fmt.Errorf("getting transactions from db: %w", err) @@ -55,10 +55,8 @@ func (r *queryResolver) Transactions(ctx context.Context, first *int32, after *s }, nil } -// Account is the resolver for the account field. -// This resolver handles the "account" query. -// It shows the standard pattern: receive args, query data, return result or error -func (r *queryResolver) Account(ctx context.Context, address string) (*types.Account, error) { +// AccountByAddress is the resolver for the accountByAddress field. +func (r *queryResolver) AccountByAddress(ctx context.Context, address string) (*types.Account, error) { return r.models.Account.Get(ctx, address) } @@ -71,7 +69,7 @@ func (r *queryResolver) Operations(ctx context.Context, first *int32, after *str } queryLimit := *params.Limit + 1 // +1 to check if there is a next page - dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "") + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) operations, err := r.models.Operations.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.Cursor, params.SortOrder) if err != nil { return nil, fmt.Errorf("getting operations from db: %w", err) @@ -95,16 +93,42 @@ func (r *queryResolver) Operations(ctx context.Context, first *int32, after *str }, nil } +// OperationByID is the resolver for the operationById field. +func (r *queryResolver) OperationByID(ctx context.Context, id int64) (*types.Operation, error) { + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) + return r.models.Operations.GetByID(ctx, id, strings.Join(dbColumns, ", ")) +} + // StateChanges is the resolver for the stateChanges field. -func (r *queryResolver) StateChanges(ctx context.Context, limit *int32) ([]*types.StateChange, error) { - if limit != nil && *limit < 0 { - return nil, fmt.Errorf("limit must be non-negative, got %d", *limit) +func (r *queryResolver) StateChanges(ctx context.Context, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { + params, err := parsePaginationParams(first, after, last, before, true) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) } - if limit != nil && *limit == 0 { - return []*types.StateChange{}, nil + queryLimit := *params.Limit + 1 // +1 to check if there is a next page + + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}) + stateChanges, err := r.models.StateChanges.GetAll(ctx, strings.Join(dbColumns, ", "), &queryLimit, params.StateChangeCursor, params.SortOrder) + if err != nil { + return nil, fmt.Errorf("getting state changes from db: %w", err) + } + + conn := NewConnectionWithRelayPagination(stateChanges, params, func(sc *types.StateChangeWithCursor) string { + return fmt.Sprintf("%d:%d", sc.Cursor.ToID, sc.Cursor.StateChangeOrder) + }) + + edges := make([]*graphql1.StateChangeEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.StateChangeEdge{ + Node: &edge.Node.StateChange, + Cursor: edge.Cursor, + } } - dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "") - return r.models.StateChanges.GetAll(ctx, limit, strings.Join(dbColumns, ", ")) + + return &graphql1.StateChangeConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // Query returns graphql1.QueryResolver implementation. diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index d35155a7..d398cc61 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -231,19 +231,19 @@ func TestQueryResolver_Account(t *testing.T) { } t.Run("success", func(t *testing.T) { - acc, err := resolver.Account(testCtx, "test-account") + acc, err := resolver.AccountByAddress(testCtx, "test-account") require.NoError(t, err) assert.Equal(t, "test-account", acc.StellarAddress) }) t.Run("non-existent account", func(t *testing.T) { - acc, err := resolver.Account(testCtx, "non-existent-account") + acc, err := resolver.AccountByAddress(testCtx, "non-existent-account") require.Error(t, err) assert.Nil(t, acc) }) t.Run("empty address", func(t *testing.T) { - acc, err := resolver.Account(testCtx, "") + acc, err := resolver.AccountByAddress(testCtx, "") require.Error(t, err) assert.Nil(t, acc) }) @@ -267,26 +267,26 @@ func TestQueryResolver_Operations(t *testing.T) { } t.Run("get all operations", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) operations, err := resolver.Operations(ctx, nil, nil, nil, nil) require.NoError(t, err) require.Len(t, operations.Edges, 8) // Operations are ordered by ID ascending - assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), operations.Edges[0].Node.ID) - assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), operations.Edges[1].Node.ID) - assert.Equal(t, toid.New(1000, 2, 1).ToInt64(), operations.Edges[2].Node.ID) - assert.Equal(t, toid.New(1000, 2, 2).ToInt64(), operations.Edges[3].Node.ID) + assert.Equal(t, "opxdr1", operations.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr2", operations.Edges[1].Node.OperationXDR) + assert.Equal(t, "opxdr3", operations.Edges[2].Node.OperationXDR) + assert.Equal(t, "opxdr4", operations.Edges[3].Node.OperationXDR) }) t.Run("get operations with first/after limit and cursor", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) first := int32(2) ops, err := resolver.Operations(ctx, &first, nil, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), ops.Edges[0].Node.ID) - assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), ops.Edges[1].Node.ID) + assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) @@ -297,7 +297,7 @@ func TestQueryResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, toid.New(1000, 2, 1).ToInt64(), ops.Edges[0].Node.ID) + assert.Equal(t, "opxdr3", ops.Edges[0].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -308,24 +308,24 @@ func TestQueryResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 5) - assert.Equal(t, toid.New(1000, 2, 2).ToInt64(), ops.Edges[0].Node.ID) - assert.Equal(t, toid.New(1000, 3, 1).ToInt64(), ops.Edges[1].Node.ID) - assert.Equal(t, toid.New(1000, 3, 2).ToInt64(), ops.Edges[2].Node.ID) - assert.Equal(t, toid.New(1000, 4, 1).ToInt64(), ops.Edges[3].Node.ID) - assert.Equal(t, toid.New(1000, 4, 2).ToInt64(), ops.Edges[4].Node.ID) + assert.Equal(t, "opxdr4", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr5", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, "opxdr6", ops.Edges[2].Node.OperationXDR) + assert.Equal(t, "opxdr7", ops.Edges[3].Node.OperationXDR) + assert.Equal(t, "opxdr8", ops.Edges[4].Node.OperationXDR) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) }) t.Run("get operations with last/before limit and cursor", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) last := int32(2) ops, err := resolver.Operations(ctx, nil, nil, &last, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) // With backward pagination, we get the last 2 items - assert.Equal(t, toid.New(1000, 4, 1).ToInt64(), ops.Edges[0].Node.ID) - assert.Equal(t, toid.New(1000, 4, 2).ToInt64(), ops.Edges[1].Node.ID) + assert.Equal(t, "opxdr7", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr8", ops.Edges[1].Node.OperationXDR) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -336,7 +336,7 @@ func TestQueryResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, nil, nil, &last, prevCursor) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, toid.New(1000, 3, 2).ToInt64(), ops.Edges[0].Node.ID) + assert.Equal(t, "opxdr6", ops.Edges[0].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -347,17 +347,17 @@ func TestQueryResolver_Operations(t *testing.T) { require.NoError(t, err) // There are 5 operations before (2,1): (2,2), (3,1), (3,2), (4,1), (4,2) assert.Len(t, ops.Edges, 5) - assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), ops.Edges[0].Node.ID) - assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), ops.Edges[1].Node.ID) - assert.Equal(t, toid.New(1000, 2, 1).ToInt64(), ops.Edges[2].Node.ID) - assert.Equal(t, toid.New(1000, 2, 2).ToInt64(), ops.Edges[3].Node.ID) - assert.Equal(t, toid.New(1000, 3, 1).ToInt64(), ops.Edges[4].Node.ID) + assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, "opxdr3", ops.Edges[2].Node.OperationXDR) + assert.Equal(t, "opxdr4", ops.Edges[3].Node.OperationXDR) + assert.Equal(t, "opxdr5", ops.Edges[4].Node.OperationXDR) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) }) t.Run("returns error when first is negative", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) first := int32(-1) ops, err := resolver.Operations(ctx, &first, nil, nil, nil) require.Error(t, err) @@ -366,7 +366,7 @@ func TestQueryResolver_Operations(t *testing.T) { }) t.Run("returns error when last is negative", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) last := int32(-1) ops, err := resolver.Operations(ctx, nil, nil, &last, nil) require.Error(t, err) @@ -375,7 +375,7 @@ func TestQueryResolver_Operations(t *testing.T) { }) t.Run("returns error when first is zero", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) first := int32(0) ops, err := resolver.Operations(ctx, &first, nil, nil, nil) require.Error(t, err) @@ -384,7 +384,7 @@ func TestQueryResolver_Operations(t *testing.T) { }) t.Run("returns error when last is zero", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) last := int32(0) ops, err := resolver.Operations(ctx, nil, nil, &last, nil) require.Error(t, err) @@ -393,7 +393,7 @@ func TestQueryResolver_Operations(t *testing.T) { }) t.Run("first parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) first := int32(100) ops, err := resolver.Operations(ctx, &first, nil, nil, nil) require.NoError(t, err) @@ -403,7 +403,7 @@ func TestQueryResolver_Operations(t *testing.T) { }) t.Run("last parameter's value larger than available data", func(t *testing.T) { - ctx := getTestCtx("operations", []string{"id"}) + ctx := getTestCtx("operations", []string{"operation_xdr"}) last := int32(100) ops, err := resolver.Operations(ctx, nil, nil, &last, nil) require.NoError(t, err) @@ -413,6 +413,59 @@ func TestQueryResolver_Operations(t *testing.T) { }) } +func TestQueryResolver_OperationByID(t *testing.T) { + mockMetricsService := &metrics.MockMetricsService{} + mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "operations", mock.Anything).Return() + mockMetricsService.On("IncDBQuery", "SELECT", "operations").Return() + defer mockMetricsService.AssertExpectations(t) + + resolver := &queryResolver{ + &Resolver{ + models: &data.Models{ + Operations: &data.OperationModel{ + DB: testDBConnectionPool, + MetricsService: mockMetricsService, + }, + }, + }, + } + + t.Run("success", func(t *testing.T) { + ctx := getTestCtx("operations", []string{""}) + op, err := resolver.OperationByID(ctx, toid.New(1000, 1, 1).ToInt64()) + + require.NoError(t, err) + assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), op.ID) + assert.Equal(t, "opxdr1", op.OperationXDR) + assert.Equal(t, "tx1", op.TxHash) + assert.Equal(t, uint32(1), op.LedgerNumber) + }) + + t.Run("non-existent ID", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + op, err := resolver.OperationByID(ctx, 999) + + require.Error(t, err) + assert.Nil(t, op) + }) + + t.Run("zero ID", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + op, err := resolver.OperationByID(ctx, 0) + + require.Error(t, err) + assert.Nil(t, op) + }) + + t.Run("negative ID", func(t *testing.T) { + ctx := getTestCtx("operations", []string{"id"}) + op, err := resolver.OperationByID(ctx, -1) + + require.Error(t, err) + assert.Nil(t, op) + }) +} + func TestQueryResolver_StateChanges(t *testing.T) { mockMetricsService := &metrics.MockMetricsService{} mockMetricsService.On("ObserveDBQueryDuration", "SELECT", "state_changes", mock.Anything).Return() @@ -431,47 +484,138 @@ func TestQueryResolver_StateChanges(t *testing.T) { } t.Run("get all", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{"stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) - scs, err := resolver.StateChanges(ctx, nil) + ctx := getTestCtx("state_changes", []string{}) + stateChanges, err := resolver.StateChanges(ctx, nil, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 20) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + }) + + t.Run("get state changes with first/after limit and cursor", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + first := int32(2) + scs, err := resolver.StateChanges(ctx, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, scs.Edges, 2) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", scs.Edges[0].Node.ToID, scs.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", scs.Edges[1].Node.ToID, scs.Edges[1].Node.StateChangeOrder)) + assert.True(t, scs.PageInfo.HasNextPage) + assert.False(t, scs.PageInfo.HasPreviousPage) + + // Get the next cursor + nextCursor := scs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + scs, err = resolver.StateChanges(ctx, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, scs.Edges, 2) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", scs.Edges[0].Node.ToID, scs.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", scs.Edges[1].Node.ToID, scs.Edges[1].Node.StateChangeOrder)) + assert.True(t, scs.PageInfo.HasNextPage) + assert.True(t, scs.PageInfo.HasPreviousPage) + + // Get the next page with larger limit + first = int32(20) + nextCursor = scs.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + scs, err = resolver.StateChanges(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) - assert.Len(t, scs, 20) - // Verify the state changes have the expected account ID - assert.Equal(t, "test-account", scs[0].AccountID) + assert.Len(t, scs.Edges, 16) // Should return next 10 items + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", scs.Edges[0].Node.ToID, scs.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 2, 0).ToInt64()), fmt.Sprintf("%d:%d", scs.Edges[1].Node.ToID, scs.Edges[1].Node.StateChangeOrder)) + assert.False(t, scs.PageInfo.HasNextPage) + assert.True(t, scs.PageInfo.HasPreviousPage) }) - t.Run("get with limit", func(t *testing.T) { - limit := int32(3) - ctx := getTestCtx("state_changes", []string{"stateChangeCategory", "txHash", "operationId", "accountId", "ledgerCreatedAt", "ledgerNumber"}) - stateChanges, err := resolver.StateChanges(ctx, &limit) + t.Run("get state changes with last/before limit and cursor", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + last := int32(2) + stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 2) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + + // Get the previous page + prevCursor := stateChanges.PageInfo.EndCursor + assert.NotNil(t, prevCursor) + stateChanges, err = resolver.StateChanges(ctx, nil, nil, &last, prevCursor) require.NoError(t, err) - assert.Len(t, stateChanges, 3) - assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) - assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 4, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) - assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[2].ToID, stateChanges[2].StateChangeOrder)) + assert.Len(t, stateChanges.Edges, 2) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 4, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 4, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + + // Get more previous items + prevCursor = stateChanges.PageInfo.EndCursor + assert.NotNil(t, prevCursor) + last = int32(20) + stateChanges, err = resolver.StateChanges(ctx, nil, nil, &last, prevCursor) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 16) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) // We're at the beginning + }) + + t.Run("returns error when first is negative", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + first := int32(-1) + stateChanges, err := resolver.StateChanges(ctx, &first, nil, nil, nil) + require.Error(t, err) + assert.Nil(t, stateChanges) + assert.Contains(t, err.Error(), "first must be greater than 0") + }) + + t.Run("returns error when last is negative", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + last := int32(-1) + stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) + require.Error(t, err) + assert.Nil(t, stateChanges) + assert.Contains(t, err.Error(), "last must be greater than 0") + }) + + t.Run("returns error when first is zero", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + first := int32(0) + stateChanges, err := resolver.StateChanges(ctx, &first, nil, nil, nil) + require.Error(t, err) + assert.Nil(t, stateChanges) + assert.Contains(t, err.Error(), "first must be greater than 0") }) - t.Run("negative limit error", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{"accountId"}) - limit := int32(-10) - scs, err := resolver.StateChanges(ctx, &limit) + t.Run("returns error when last is zero", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + last := int32(0) + stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) require.Error(t, err) - assert.Nil(t, scs) - assert.Contains(t, err.Error(), "limit must be non-negative") + assert.Nil(t, stateChanges) + assert.Contains(t, err.Error(), "last must be greater than 0") }) - t.Run("zero limit", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{"accountId"}) - limit := int32(0) - scs, err := resolver.StateChanges(ctx, &limit) + t.Run("first parameter's value larger than available data", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + first := int32(100) + stateChanges, err := resolver.StateChanges(ctx, &first, nil, nil, nil) require.NoError(t, err) - assert.Len(t, scs, 0) + assert.Len(t, stateChanges.Edges, 20) // Total available state changes + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) - t.Run("limit larger than available data", func(t *testing.T) { - ctx := getTestCtx("state_changes", []string{"accountId"}) - limit := int32(50) - scs, err := resolver.StateChanges(ctx, &limit) + t.Run("last parameter's value larger than available data", func(t *testing.T) { + ctx := getTestCtx("state_changes", []string{}) + last := int32(100) + stateChanges, err := resolver.StateChanges(ctx, nil, nil, &last, nil) require.NoError(t, err) - assert.Len(t, scs, 20) + assert.Len(t, stateChanges.Edges, 20) // Total available state changes + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) } diff --git a/internal/serve/graphql/resolvers/statechange.resolvers.go b/internal/serve/graphql/resolvers/statechange.resolvers.go index 796e1c01..899ea1ec 100644 --- a/internal/serve/graphql/resolvers/statechange.resolvers.go +++ b/internal/serve/graphql/resolvers/statechange.resolvers.go @@ -150,7 +150,7 @@ func (r *stateChangeResolver) KeyValue(ctx context.Context, obj *types.StateChan // Operation is the resolver for the operation field. func (r *stateChangeResolver) Operation(ctx context.Context, obj *types.StateChange) (*types.Operation, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "operations") + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) stateChangeID := fmt.Sprintf("%d-%d", obj.ToID, obj.StateChangeOrder) loaderKey := dataloaders.OperationColumnsKey{ @@ -167,7 +167,7 @@ func (r *stateChangeResolver) Operation(ctx context.Context, obj *types.StateCha // Transaction is the resolver for the transaction field. func (r *stateChangeResolver) Transaction(ctx context.Context, obj *types.StateChange) (*types.Transaction, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}, "transactions") + dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}) stateChangeID := fmt.Sprintf("%d-%d", obj.ToID, obj.StateChangeOrder) loaderKey := dataloaders.TransactionColumnsKey{ diff --git a/internal/serve/graphql/resolvers/transaction.resolvers.go b/internal/serve/graphql/resolvers/transaction.resolvers.go index eb33e66c..d42c90e0 100644 --- a/internal/serve/graphql/resolvers/transaction.resolvers.go +++ b/internal/serve/graphql/resolvers/transaction.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" + "fmt" "strings" "github.com/stellar/wallet-backend/internal/indexer/types" @@ -17,21 +18,44 @@ import ( // Operations is the resolver for the operations field. // This is a field resolver for the "operations" field on a Transaction object // It's called when a GraphQL query requests the operations within a transaction -func (r *transactionResolver) Operations(ctx context.Context, obj *types.Transaction) ([]*types.Operation, error) { - loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Operation{}, "") +func (r *transactionResolver) Operations(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*graphql1.OperationConnection, error) { + dbColumns := GetDBColumnsForFields(ctx, types.Operation{}) + params, err := parsePaginationParams(first, after, last, before, false) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) + } + queryLimit := *params.Limit + 1 // +1 to check if there is a next page + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) loaderKey := dataloaders.OperationColumnsKey{ - TxHash: obj.Hash, - Columns: strings.Join(dbColumns, ", "), + TxHash: obj.Hash, + Columns: strings.Join(dbColumns, ", "), + Limit: &queryLimit, + Cursor: params.Cursor, + SortOrder: params.SortOrder, } - // Use dataloader to batch-load operations for this transaction operations, err := loaders.OperationsByTxHashLoader.Load(ctx, loaderKey) if err != nil { return nil, err } - return operations, nil + + conn := NewConnectionWithRelayPagination(operations, params, func(o *types.OperationWithCursor) int64 { + return o.Cursor + }) + + edges := make([]*graphql1.OperationEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.OperationEdge{ + Node: &edge.Node.Operation, + Cursor: edge.Cursor, + } + } + + return &graphql1.OperationConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // Accounts is the resolver for the accounts field. @@ -39,7 +63,7 @@ func (r *transactionResolver) Operations(ctx context.Context, obj *types.Transac // It's called when a GraphQL query requests the accounts within a transaction func (r *transactionResolver) Accounts(ctx context.Context, obj *types.Transaction) ([]*types.Account, error) { loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.Account{}, "accounts") + dbColumns := GetDBColumnsForFields(ctx, types.Account{}) // Use dataloader to batch-load accounts for this transaction // This prevents N+1 queries when multiple transactions request their operations @@ -58,21 +82,44 @@ func (r *transactionResolver) Accounts(ctx context.Context, obj *types.Transacti // StateChanges is the resolver for the stateChanges field. // This is a field resolver for the "stateChanges" field on a Transaction object // It's called when a GraphQL query requests the state changes within a transaction -func (r *transactionResolver) StateChanges(ctx context.Context, obj *types.Transaction) ([]*types.StateChange, error) { - loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) - dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}, "") +func (r *transactionResolver) StateChanges(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*graphql1.StateChangeConnection, error) { + dbColumns := GetDBColumnsForFields(ctx, types.StateChange{}) + params, err := parsePaginationParams(first, after, last, before, true) + if err != nil { + return nil, fmt.Errorf("parsing pagination params: %w", err) + } + queryLimit := *params.Limit + 1 // +1 to check if there is a next page + loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders) loaderKey := dataloaders.StateChangeColumnsKey{ - TxHash: obj.Hash, - Columns: strings.Join(dbColumns, ", "), + TxHash: obj.Hash, + Columns: strings.Join(dbColumns, ", "), + Limit: &queryLimit, + Cursor: params.StateChangeCursor, + SortOrder: params.SortOrder, } - // Use dataloader to batch-load state changes for this transaction stateChanges, err := loaders.StateChangesByTxHashLoader.Load(ctx, loaderKey) if err != nil { return nil, err } - return stateChanges, nil + + conn := NewConnectionWithRelayPagination(stateChanges, params, func(sc *types.StateChangeWithCursor) string { + return fmt.Sprintf("%d:%d", sc.Cursor.ToID, sc.Cursor.StateChangeOrder) + }) + + edges := make([]*graphql1.StateChangeEdge, len(conn.Edges)) + for i, edge := range conn.Edges { + edges[i] = &graphql1.StateChangeEdge{ + Node: &edge.Node.StateChange, + Cursor: edge.Cursor, + } + } + + return &graphql1.StateChangeConnection{ + Edges: edges, + PageInfo: conn.PageInfo, + }, nil } // Transaction returns graphql1.TransactionResolver implementation. diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index eb5baa54..65a50f2c 100644 --- a/internal/serve/graphql/resolvers/transaction_resolvers_test.go +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -36,14 +36,14 @@ func TestTransactionResolver_Operations(t *testing.T) { t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) - ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(getTestCtx("operations", []string{"operation_xdr"}), middleware.LoadersKey, loaders) - operations, err := resolver.Operations(ctx, parentTx) + operations, err := resolver.Operations(ctx, parentTx, nil, nil, nil, nil) require.NoError(t, err) - require.Len(t, operations, 2) - assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), operations[0].ID) - assert.Equal(t, toid.New(1000, 1, 2).ToInt64(), operations[1].ID) + require.Len(t, operations.Edges, 2) + assert.Equal(t, "opxdr1", operations.Edges[0].Node.OperationXDR) + assert.Equal(t, "opxdr2", operations.Edges[1].Node.OperationXDR) }) t.Run("nil transaction panics", func(t *testing.T) { @@ -51,7 +51,7 @@ func TestTransactionResolver_Operations(t *testing.T) { ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { - _, _ = resolver.Operations(ctx, nil) //nolint:errcheck + _, _ = resolver.Operations(ctx, nil, nil, nil, nil, nil) //nolint:errcheck }) }) @@ -60,10 +60,95 @@ func TestTransactionResolver_Operations(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) - operations, err := resolver.Operations(ctx, nonExistentTx) + operations, err := resolver.Operations(ctx, nonExistentTx, nil, nil, nil, nil) + + require.NoError(t, err) + assert.Empty(t, operations.Edges) + }) + + t.Run("get operations with first/after pagination", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"operation_xdr"}), middleware.LoadersKey, loaders) + first := int32(1) + ops, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 1) + assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.True(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) + + // Get the next page using cursor + nextCursor := ops.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + ops, err = resolver.Operations(ctx, parentTx, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 1) + assert.Equal(t, "opxdr2", ops.Edges[0].Node.OperationXDR) + assert.False(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + }) + + t.Run("get operations with last/before pagination", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"operation_xdr"}), middleware.LoadersKey, loaders) + last := int32(1) + ops, err := resolver.Operations(ctx, parentTx, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, ops.Edges, 1) + assert.Equal(t, "opxdr2", ops.Edges[0].Node.OperationXDR) + assert.False(t, ops.PageInfo.HasNextPage) + assert.True(t, ops.PageInfo.HasPreviousPage) + + // Get the previous page using cursor + prevCursor := ops.PageInfo.EndCursor + assert.NotNil(t, prevCursor) + ops, err = resolver.Operations(ctx, parentTx, nil, nil, &last, prevCursor) + require.NoError(t, err) + assert.Len(t, ops.Edges, 1) + assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.True(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) + }) + + t.Run("invalid pagination params", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + first := int32(0) + last := int32(1) + after := encodeCursor(int64(1)) + before := encodeCursor(int64(2)) + + _, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + + first = int32(1) + _, err = resolver.Operations(ctx, parentTx, &first, nil, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + + _, err = resolver.Operations(ctx, parentTx, nil, &after, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + + _, err = resolver.Operations(ctx, parentTx, &first, nil, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + + _, err = resolver.Operations(ctx, parentTx, nil, &after, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") + }) + t.Run("pagination with larger limit than available data", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) + first := int32(100) + ops, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) require.NoError(t, err) - assert.Empty(t, operations) + assert.Len(t, ops.Edges, 2) // tx1 has 2 operations + assert.False(t, ops.PageInfo.HasNextPage) + assert.False(t, ops.PageInfo.HasPreviousPage) }) } @@ -134,21 +219,115 @@ func TestTransactionResolver_StateChanges(t *testing.T) { }, }} parentTx := &types.Transaction{Hash: "tx1"} + nonExistentTx := &types.Transaction{Hash: "non-existent-tx"} - t.Run("success", func(t *testing.T) { + t.Run("success without pagination", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) - ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + ctx := context.WithValue(getTestCtx("state_changes", []string{}), middleware.LoadersKey, loaders) - stateChanges, err := resolver.StateChanges(ctx, parentTx) + stateChanges, err := resolver.StateChanges(ctx, parentTx, nil, nil, nil, nil) require.NoError(t, err) - require.Len(t, stateChanges, 5) + require.Len(t, stateChanges.Edges, 5) // For tx1: operations 1 and 2, each with 2 state changes and 1 fee change - assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[0].ToID, stateChanges[0].StateChangeOrder)) - assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[1].ToID, stateChanges[1].StateChangeOrder)) - assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[2].ToID, stateChanges[2].StateChangeOrder)) - assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[3].ToID, stateChanges[3].StateChangeOrder)) - assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges[4].ToID, stateChanges[4].StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[3].Node.ToID, stateChanges.Edges[3].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[4].Node.ToID, stateChanges.Edges[4].Node.StateChangeOrder)) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) + }) + + t.Run("get state changes with first/after pagination", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + first := int32(2) + stateChanges, err := resolver.StateChanges(ctx, parentTx, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 2) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) + + // Get the next page using cursor + nextCursor := stateChanges.PageInfo.EndCursor + assert.NotNil(t, nextCursor) + stateChanges, err = resolver.StateChanges(ctx, parentTx, &first, nextCursor, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 2) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + }) + + t.Run("get state changes with last/before pagination", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + last := int32(2) + stateChanges, err := resolver.StateChanges(ctx, parentTx, nil, nil, &last, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 2) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 2).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.True(t, stateChanges.PageInfo.HasPreviousPage) + + // Get the previous page using cursor + last = int32(10) + prevCursor := stateChanges.PageInfo.StartCursor + assert.NotNil(t, prevCursor) + stateChanges, err = resolver.StateChanges(ctx, parentTx, nil, nil, &last, prevCursor) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 3) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 0).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[0].Node.ToID, stateChanges.Edges[0].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:1", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[1].Node.ToID, stateChanges.Edges[1].Node.StateChangeOrder)) + assert.Equal(t, fmt.Sprintf("%d:2", toid.New(1000, 1, 1).ToInt64()), fmt.Sprintf("%d:%d", stateChanges.Edges[2].Node.ToID, stateChanges.Edges[2].Node.StateChangeOrder)) + assert.True(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) + }) + + t.Run("invalid pagination params", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + first := int32(0) + last := int32(1) + after := encodeCursor(int64(1)) + before := encodeCursor(int64(2)) + + _, err := resolver.StateChanges(ctx, parentTx, &first, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first must be greater than 0") + + first = int32(1) + _, err = resolver.StateChanges(ctx, parentTx, &first, nil, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and last cannot be used together") + + _, err = resolver.StateChanges(ctx, parentTx, nil, &after, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: after and before cannot be used together") + + _, err = resolver.StateChanges(ctx, parentTx, &first, nil, nil, &before) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: first and before cannot be used together") + + _, err = resolver.StateChanges(ctx, parentTx, nil, &after, &last, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating pagination params: last and after cannot be used together") + }) + + t.Run("pagination with larger limit than available data", func(t *testing.T) { + loaders := dataloaders.NewDataloaders(resolver.models) + ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) + first := int32(100) + stateChanges, err := resolver.StateChanges(ctx, parentTx, &first, nil, nil, nil) + require.NoError(t, err) + assert.Len(t, stateChanges.Edges, 5) // tx1 has 5 state changes + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) t.Run("nil transaction panics", func(t *testing.T) { @@ -156,18 +335,19 @@ func TestTransactionResolver_StateChanges(t *testing.T) { ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) assert.Panics(t, func() { - _, _ = resolver.StateChanges(ctx, nil) //nolint:errcheck + _, _ = resolver.StateChanges(ctx, nil, nil, nil, nil, nil) //nolint:errcheck }) }) t.Run("transaction with no state changes", func(t *testing.T) { - nonExistentTx := &types.Transaction{Hash: "non-existent-tx"} loaders := dataloaders.NewDataloaders(resolver.models) ctx := context.WithValue(getTestCtx("state_changes", []string{"accountId", "stateChangeCategory"}), middleware.LoadersKey, loaders) - stateChanges, err := resolver.StateChanges(ctx, nonExistentTx) + stateChanges, err := resolver.StateChanges(ctx, nonExistentTx, nil, nil, nil, nil) require.NoError(t, err) - assert.Empty(t, stateChanges) + assert.Empty(t, stateChanges.Edges) + assert.False(t, stateChanges.PageInfo.HasNextPage) + assert.False(t, stateChanges.PageInfo.HasPreviousPage) }) } diff --git a/internal/serve/graphql/resolvers/utils.go b/internal/serve/graphql/resolvers/utils.go index 50f46f56..dca5b982 100644 --- a/internal/serve/graphql/resolvers/utils.go +++ b/internal/serve/graphql/resolvers/utils.go @@ -90,7 +90,7 @@ func NewConnectionWithRelayPagination[T any, C int64 | string](nodes []T, params } } -func GetDBColumnsForFields(ctx context.Context, model any, prefix string) []string { +func GetDBColumnsForFields(ctx context.Context, model any) []string { opCtx := graphql.GetOperationContext(ctx) fields := graphql.CollectFieldsCtx(ctx, nil) @@ -100,13 +100,13 @@ func GetDBColumnsForFields(ctx context.Context, model any, prefix string) []stri for _, edgeField := range edgeFields { if edgeField.Name == "node" { nodeFields := graphql.CollectFields(opCtx, edgeField.Selections, nil) - return prefixDBColumns(prefix, getDBColumns(model, nodeFields)) + return getDBColumns(model, nodeFields) } } } } - return prefixDBColumns(prefix, getDBColumns(model, fields)) + return getDBColumns(model, fields) } func encodeCursor[T int64 | string](i T) string { @@ -163,17 +163,6 @@ func getDBColumns(model any, fields []graphql.CollectedField) []string { return dbColumns } -func prefixDBColumns(prefix string, cols []string) []string { - if prefix == "" { - return cols - } - prefixedCols := make([]string, len(cols)) - for i, col := range cols { - prefixedCols[i] = prefix + "." + col - } - return prefixedCols -} - func getColumnMap(model any) map[string]string { modelType := reflect.TypeOf(model) fieldToColumnMap := make(map[string]string) @@ -182,6 +171,8 @@ func getColumnMap(model any) map[string]string { jsonTag := field.Tag.Get("json") dbTag := field.Tag.Get("db") + // Not all fields have a db tag for e.g. the relationship fields in the indexer model structs + // dont have a db tag. So we need to check for both jsonTag and dbTag. if jsonTag != "" && dbTag != "" && dbTag != "-" { jsonFieldName := strings.Split(jsonTag, ",")[0] fieldToColumnMap[jsonFieldName] = dbTag diff --git a/internal/serve/graphql/schema/operation.graphqls b/internal/serve/graphql/schema/operation.graphqls index 99dad66d..2d0ceeea 100644 --- a/internal/serve/graphql/schema/operation.graphqls +++ b/internal/serve/graphql/schema/operation.graphqls @@ -16,5 +16,5 @@ type Operation{ accounts: [Account!]! @goField(forceResolver: true) # Related state changes - uses resolver to fetch associated changes - stateChanges: [StateChange!]! @goField(forceResolver: true) + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } diff --git a/internal/serve/graphql/schema/queries.graphqls b/internal/serve/graphql/schema/queries.graphqls index 26443241..dd58bcef 100644 --- a/internal/serve/graphql/schema/queries.graphqls +++ b/internal/serve/graphql/schema/queries.graphqls @@ -1,9 +1,10 @@ # GraphQL Query root type - defines all available queries in the API # In GraphQL, the Query type is the entry point for read operations type Query { - transactionByHash(hash: String!): Transaction + transactionByHash(hash: String!): Transaction transactions(first: Int, after: String, last: Int, before: String): TransactionConnection - account(address: String!): Account + accountByAddress(address: String!): Account operations(first: Int, after: String, last: Int, before: String): OperationConnection - stateChanges(limit: Int): [StateChange!]! + operationById(id: Int64!): Operation + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } diff --git a/internal/serve/graphql/schema/transaction.graphqls b/internal/serve/graphql/schema/transaction.graphqls index 61b48f1b..602a633b 100644 --- a/internal/serve/graphql/schema/transaction.graphqls +++ b/internal/serve/graphql/schema/transaction.graphqls @@ -12,11 +12,11 @@ type Transaction{ # GraphQL Relationships - these fields require resolvers # @goField(forceResolver: true) tells gqlgen to always generate a resolver # even if the Go struct has a matching field - operations: [Operation!]! @goField(forceResolver: true) + operations(first: Int, after: String, last: Int, before: String): OperationConnection # Related accounts - uses resolver with dataloader for efficiency accounts: [Account!]! @goField(forceResolver: true) # Related state changes - uses resolver to fetch associated changes - stateChanges: [StateChange!]! @goField(forceResolver: true) + stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection } From 7b0bf52a8a86d880e3d6b6636dff24532d49dde6 Mon Sep 17 00:00:00 2001 From: Aditya Vyas Date: Fri, 19 Sep 2025 10:16:11 -0400 Subject: [PATCH 16/16] Delete helpers.go --- internal/indexer/processors/helpers.go | 57 -------------------------- 1 file changed, 57 deletions(-) delete mode 100644 internal/indexer/processors/helpers.go diff --git a/internal/indexer/processors/helpers.go b/internal/indexer/processors/helpers.go deleted file mode 100644 index ed5792a5..00000000 --- a/internal/indexer/processors/helpers.go +++ /dev/null @@ -1,57 +0,0 @@ -package processors - -import ( - "fmt" - - "github.com/stellar/go/ingest" - "github.com/stellar/go/toid" - "github.com/stellar/go/xdr" - - "github.com/stellar/wallet-backend/internal/indexer/types" -) - -func ConvertTransaction(transaction *ingest.LedgerTransaction) (*types.Transaction, error) { - envelopeXDR, err := xdr.MarshalBase64(transaction.Envelope) - if err != nil { - return nil, fmt.Errorf("marshalling transaction envelope: %w", err) - } - - resultXDR, err := xdr.MarshalBase64(transaction.Result) - if err != nil { - return nil, fmt.Errorf("marshalling transaction result: %w", err) - } - - metaXDR, err := xdr.MarshalBase64(transaction.UnsafeMeta) - if err != nil { - return nil, fmt.Errorf("marshalling transaction meta: %w", err) - } - - ledgerSequence := transaction.Ledger.LedgerSequence() - transactionID := toid.New(int32(ledgerSequence), int32(transaction.Index), 0).ToInt64() - - return &types.Transaction{ - ToID: transactionID, - Hash: transaction.Hash.HexString(), - LedgerCreatedAt: transaction.Ledger.ClosedAt(), - EnvelopeXDR: envelopeXDR, - ResultXDR: resultXDR, - MetaXDR: metaXDR, - LedgerNumber: ledgerSequence, - }, nil -} - -func ConvertOperation(transaction *ingest.LedgerTransaction, op *xdr.Operation, opID int64) (*types.Operation, error) { - xdrOpStr, err := xdr.MarshalBase64(op) - if err != nil { - return nil, fmt.Errorf("marshalling operation %d: %w", opID, err) - } - - return &types.Operation{ - ID: opID, - OperationType: types.OperationTypeFromXDR(op.Body.Type), - OperationXDR: xdrOpStr, - LedgerNumber: transaction.Ledger.LedgerSequence(), - LedgerCreatedAt: transaction.Ledger.ClosedAt(), - TxHash: transaction.Hash.HexString(), - }, nil -}