From c7d39fbaec1999fec71cadb092bc4d0fa52be0bb Mon Sep 17 00:00:00 2001 From: Zeph Grunschlag Date: Wed, 16 Feb 2022 11:59:06 -0600 Subject: [PATCH 01/29] Use fetch/reset instead of pull for go-algorand in nightly test. (#885) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ddd15a9df..92585f6e9 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ test-generate: test/test_generate.py nightly-setup: - cd third_party/go-algorand && git pull origin master + cd third_party/go-algorand && git fetch && git reset --hard origin/master nightly-teardown: git submodule update From 3cafaad3c2c61e3e9d6e8686738d462f40b10418 Mon Sep 17 00:00:00 2001 From: AlgoStephenAkiki <85183435+AlgoStephenAkiki@users.noreply.github.com> Date: Wed, 16 Feb 2022 14:39:33 -0500 Subject: [PATCH 02/29] Configurable query parameters runtime data structures. (#873) --- api/disabled_parameters.go | 149 +++++++++++++++++++++++++++++ api/disabled_parameters_test.go | 163 ++++++++++++++++++++++++++++++++ api/handlers.go | 55 +++++++++++ api/server.go | 7 ++ 4 files changed, 374 insertions(+) create mode 100644 api/disabled_parameters.go create mode 100644 api/disabled_parameters_test.go diff --git a/api/disabled_parameters.go b/api/disabled_parameters.go new file mode 100644 index 000000000..3a2d0c4c0 --- /dev/null +++ b/api/disabled_parameters.go @@ -0,0 +1,149 @@ +package api + +import ( + "fmt" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/labstack/echo/v4" +) + +// EndpointConfig is a data structure that contains whether the +// endpoint is disabled (with a boolean) as well as a set that +// contains disabled optional parameters. The disabled optional parameter +// set is keyed by the name of the variable +type EndpointConfig struct { + EndpointDisabled bool + DisabledOptionalParameters map[string]bool +} + +// NewEndpointConfig creates a new empty endpoint config +func NewEndpointConfig() *EndpointConfig { + rval := &EndpointConfig{ + EndpointDisabled: false, + DisabledOptionalParameters: make(map[string]bool), + } + + return rval +} + +// DisabledMap a type that holds a map of disabled types +// The key for a disabled map is the handler function name +type DisabledMap struct { + Data map[string]*EndpointConfig +} + +// NewDisabledMap creates a new empty disabled map +func NewDisabledMap() *DisabledMap { + return &DisabledMap{ + Data: make(map[string]*EndpointConfig), + } +} + +// NewDisabledMapFromOA3 Creates a new disabled map from an openapi3 definition +func NewDisabledMapFromOA3(swag *openapi3.Swagger) *DisabledMap { + rval := NewDisabledMap() + for _, item := range swag.Paths { + for _, opItem := range item.Operations() { + + endpointConfig := NewEndpointConfig() + + for _, pref := range opItem.Parameters { + + // TODO how to enable it to be disabled + parameterIsDisabled := false + if !parameterIsDisabled { + // If the parameter is not disabled, then we don't need + // to do anything + continue + } + + if pref.Value.Required { + // If an endpoint config required parameter is disabled, then the whole endpoint is disabled + endpointConfig.EndpointDisabled = true + } else { + // If the optional parameter is disabled, add it to the map + endpointConfig.DisabledOptionalParameters[pref.Value.Name] = true + } + } + + rval.Data[opItem.OperationID] = endpointConfig + + } + + } + + return rval +} + +// ErrVerifyFailedEndpoint an error that signifies that the entire endpoint is disabled +var ErrVerifyFailedEndpoint error = fmt.Errorf("endpoint is disabled") + +// ErrVerifyFailedParameter an error that signifies that a parameter was provided when it was disabled +type ErrVerifyFailedParameter struct { + ParameterName string +} + +func (evfp ErrVerifyFailedParameter) Error() string { + return fmt.Sprintf("provided disabled parameter: %s", evfp.ParameterName) +} + +// DisabledParameterErrorReporter defines an error reporting interface +// for the Verify functions +type DisabledParameterErrorReporter interface { + Errorf(format string, args ...interface{}) +} + +// Verify returns nil if the function can continue (i.e. the parameters are valid and disabled +// parameters are not supplied), otherwise VerifyFailedEndpoint if the endpoint failed and +// VerifyFailedParameter if a disabled parameter was provided. +func Verify(dm *DisabledMap, nameOfHandlerFunc string, ctx echo.Context, log DisabledParameterErrorReporter) error { + + if dm == nil || dm.Data == nil { + return nil + } + + if val, ok := dm.Data[nameOfHandlerFunc]; ok { + return val.verify(ctx, log) + } + + // If the function name wasn't in the map something got messed up.... + log.Errorf("verify function could not find name of handler function in map: %s", nameOfHandlerFunc) + // We want to fail-safe to not stop the indexer + return nil +} + +func (ec *EndpointConfig) verify(ctx echo.Context, log DisabledParameterErrorReporter) error { + + if ec.EndpointDisabled { + return ErrVerifyFailedEndpoint + } + + queryParams := ctx.QueryParams() + formParams, formErr := ctx.FormParams() + + if formErr != nil { + log.Errorf("retrieving form parameters for verification resulted in an error: %v", formErr) + } + + for paramName := range ec.DisabledOptionalParameters { + + // The optional param is disabled, check that it wasn't supplied... + queryValue := queryParams.Get(paramName) + if queryValue != "" { + // If the query value is non-zero, and it was disabled, we should return false + return ErrVerifyFailedParameter{paramName} + } + + if formErr != nil { + continue + } + + formValue := formParams.Get(paramName) + if formValue != "" { + // If the query value is non-zero, and it was disabled, we should return false + return ErrVerifyFailedParameter{paramName} + } + } + + return nil +} diff --git a/api/disabled_parameters_test.go b/api/disabled_parameters_test.go new file mode 100644 index 000000000..2e76391e8 --- /dev/null +++ b/api/disabled_parameters_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/labstack/echo/v4" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/require" +) + +// TestFailingParam tests that disabled parameters provided via +// the FormParams() and QueryParams() functions of the context are appropriately handled +func TestFailingParam(t *testing.T) { + type testingStruct struct { + name string + setFormValues func(*url.Values) + expectedError error + expectedErrorCount int + mimeType string + } + tests := []testingStruct{ + { + "non-disabled param provided", + func(f *url.Values) { + f.Set("3", "Provided") + }, nil, 0, echo.MIMEApplicationForm, + }, + { + "disabled param provided but empty", + func(f *url.Values) { + f.Set("1", "") + }, nil, 0, echo.MIMEApplicationForm, + }, + { + "disabled param provided", + func(f *url.Values) { + f.Set("1", "Provided") + }, ErrVerifyFailedParameter{"1"}, 0, echo.MIMEApplicationForm, + }, + } + + testsPostOnly := []testingStruct{ + { + "Error encountered for Form Params", + func(f *url.Values) { + f.Set("1", "Provided") + }, nil, 1, echo.MIMEMultipartForm, + }, + } + + ctxFactoryGet := func(e *echo.Echo, f *url.Values, t *testingStruct) *echo.Context { + req := httptest.NewRequest(http.MethodGet, "/?"+f.Encode(), nil) + ctx := e.NewContext(req, nil) + return &ctx + } + + ctxFactoryPost := func(e *echo.Echo, f *url.Values, t *testingStruct) *echo.Context { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(f.Encode())) + req.Header.Add(echo.HeaderContentType, t.mimeType) + ctx := e.NewContext(req, nil) + return &ctx + } + + runner := func(t *testing.T, tstruct *testingStruct, ctxFactory func(*echo.Echo, *url.Values, *testingStruct) *echo.Context) { + dm := NewDisabledMap() + e1 := NewEndpointConfig() + e1.EndpointDisabled = false + e1.DisabledOptionalParameters["1"] = true + + dm.Data["K1"] = e1 + + e := echo.New() + + f := make(url.Values) + tstruct.setFormValues(&f) + + ctx := ctxFactory(e, &f, tstruct) + + logger, hook := test.NewNullLogger() + + err := Verify(dm, "K1", *ctx, logger) + + require.Equal(t, tstruct.expectedError, err) + require.Len(t, hook.AllEntries(), tstruct.expectedErrorCount) + } + + for _, test := range tests { + t.Run("Post-"+test.name, func(t *testing.T) { + runner(t, &test, ctxFactoryPost) + }) + + t.Run("Get-"+test.name, func(t *testing.T) { + runner(t, &test, ctxFactoryGet) + }) + + } + + for _, test := range testsPostOnly { + t.Run("Post-"+test.name, func(t *testing.T) { + runner(t, &test, ctxFactoryPost) + }) + + } +} + +// TestFailingEndpoint tests that an endpoint which has a disabled required parameter +// returns a failed endpoint error +func TestFailingEndpoint(t *testing.T) { + dm := NewDisabledMap() + + e1 := NewEndpointConfig() + e1.EndpointDisabled = true + e1.DisabledOptionalParameters["1"] = true + + dm.Data["K1"] = e1 + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/?", nil) + ctx := e.NewContext(req, nil) + + logger, hook := test.NewNullLogger() + + err := Verify(dm, "K1", ctx, logger) + + require.Equal(t, ErrVerifyFailedEndpoint, err) + + require.Len(t, hook.AllEntries(), 0) +} + +// TestVerifyNonExistentHandler tests that nonexistent endpoint is logged +// but doesn't stop the indexer from functioning +func TestVerifyNonExistentHandler(t *testing.T) { + dm := NewDisabledMap() + + e1 := NewEndpointConfig() + e1.EndpointDisabled = false + e1.DisabledOptionalParameters["1"] = true + + dm.Data["K1"] = e1 + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/?", nil) + ctx := e.NewContext(req, nil) + + logger, hook := test.NewNullLogger() + + err := Verify(dm, "DoesntExist", ctx, logger) + + require.Equal(t, nil, err) + require.Len(t, hook.AllEntries(), 1) + + hook.Reset() + + err = Verify(dm, "K1", ctx, logger) + + require.Equal(t, nil, err) + + require.Len(t, hook.AllEntries(), 0) +} diff --git a/api/handlers.go b/api/handlers.go index 0bd7795b2..0137e68e0 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -38,6 +38,8 @@ type ServerImplementation struct { timeout time.Duration log *log.Logger + + disabledParams *DisabledMap } ///////////////////// @@ -145,9 +147,17 @@ func (si *ServerImplementation) MakeHealthCheck(ctx echo.Context) error { }) } +func (si *ServerImplementation) verifyHandler(operationID string, ctx echo.Context) error { + return Verify(si.disabledParams, operationID, ctx, si.log) +} + // LookupAccountByID queries indexer for a given account. // (GET /v2/accounts/{account-id}) func (si *ServerImplementation) LookupAccountByID(ctx echo.Context, accountID string, params generated.LookupAccountByIDParams) error { + if err := si.verifyHandler("LookupAccountByID", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + addr, errors := decodeAddress(&accountID, "account-id", make([]string, 0)) if len(errors) != 0 { return badRequest(ctx, errors[0]) @@ -183,6 +193,10 @@ func (si *ServerImplementation) LookupAccountByID(ctx echo.Context, accountID st // SearchForAccounts returns accounts matching the provided parameters // (GET /v2/accounts) func (si *ServerImplementation) SearchForAccounts(ctx echo.Context, params generated.SearchForAccountsParams) error { + if err := si.verifyHandler("SearchForAccounts", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + if !si.EnableAddressSearchRoundRewind && params.Round != nil { return badRequest(ctx, errMultiAcctRewind) } @@ -242,6 +256,9 @@ func (si *ServerImplementation) SearchForAccounts(ctx echo.Context, params gener // LookupAccountTransactions looks up transactions associated with a particular account. // (GET /v2/accounts/{account-id}/transactions) func (si *ServerImplementation) LookupAccountTransactions(ctx echo.Context, accountID string, params generated.LookupAccountTransactionsParams) error { + if err := si.verifyHandler("LookupAccountTransactions", ctx); err != nil { + return badRequest(ctx, err.Error()) + } // Check that a valid account was provided _, errors := decodeAddress(strPtr(accountID), "account-id", make([]string, 0)) if len(errors) != 0 { @@ -277,6 +294,9 @@ func (si *ServerImplementation) LookupAccountTransactions(ctx echo.Context, acco // SearchForApplications returns applications for the provided parameters. // (GET /v2/applications) func (si *ServerImplementation) SearchForApplications(ctx echo.Context, params generated.SearchForApplicationsParams) error { + if err := si.verifyHandler("SearchForApplications", ctx); err != nil { + return badRequest(ctx, err.Error()) + } apps, round, err := si.fetchApplications(ctx.Request().Context(), params) if err != nil { return indexerError(ctx, fmt.Errorf("%s: %w", errFailedSearchingApplication, err)) @@ -298,6 +318,9 @@ func (si *ServerImplementation) SearchForApplications(ctx echo.Context, params g // LookupApplicationByID returns one application for the requested ID. // (GET /v2/applications/{application-id}) func (si *ServerImplementation) LookupApplicationByID(ctx echo.Context, applicationID uint64, params generated.LookupApplicationByIDParams) error { + if err := si.verifyHandler("LookupApplicationByID", ctx); err != nil { + return badRequest(ctx, err.Error()) + } p := generated.SearchForApplicationsParams{ ApplicationId: &applicationID, IncludeAll: params.IncludeAll, @@ -326,6 +349,10 @@ func (si *ServerImplementation) LookupApplicationByID(ctx echo.Context, applicat // LookupApplicationLogsByID returns one application logs // (GET /v2/applications/{application-id}/logs) func (si *ServerImplementation) LookupApplicationLogsByID(ctx echo.Context, applicationID uint64, params generated.LookupApplicationLogsByIDParams) error { + if err := si.verifyHandler("LookupApplicationLogsByID", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + searchParams := generated.SearchForTransactionsParams{ AssetId: nil, ApplicationId: uint64Ptr(applicationID), @@ -385,6 +412,10 @@ func (si *ServerImplementation) LookupApplicationLogsByID(ctx echo.Context, appl // LookupAssetByID looks up a particular asset // (GET /v2/assets/{asset-id}) func (si *ServerImplementation) LookupAssetByID(ctx echo.Context, assetID uint64, params generated.LookupAssetByIDParams) error { + if err := si.verifyHandler("LookupAssetByID", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + search := generated.SearchForAssetsParams{ AssetId: uint64Ptr(assetID), Limit: uint64Ptr(1), @@ -417,6 +448,10 @@ func (si *ServerImplementation) LookupAssetByID(ctx echo.Context, assetID uint64 // LookupAssetBalances looks up balances for a particular asset // (GET /v2/assets/{asset-id}/balances) func (si *ServerImplementation) LookupAssetBalances(ctx echo.Context, assetID uint64, params generated.LookupAssetBalancesParams) error { + if err := si.verifyHandler("LookupAssetBalances", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + query := idb.AssetBalanceQuery{ AssetID: assetID, AmountGT: params.CurrencyGreaterThan, @@ -453,6 +488,10 @@ func (si *ServerImplementation) LookupAssetBalances(ctx echo.Context, assetID ui // LookupAssetTransactions looks up transactions associated with a particular asset // (GET /v2/assets/{asset-id}/transactions) func (si *ServerImplementation) LookupAssetTransactions(ctx echo.Context, assetID uint64, params generated.LookupAssetTransactionsParams) error { + if err := si.verifyHandler("LookupAssetTransactions", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + searchParams := generated.SearchForTransactionsParams{ AssetId: uint64Ptr(assetID), ApplicationId: nil, @@ -481,6 +520,10 @@ func (si *ServerImplementation) LookupAssetTransactions(ctx echo.Context, assetI // SearchForAssets returns assets matching the provided parameters // (GET /v2/assets) func (si *ServerImplementation) SearchForAssets(ctx echo.Context, params generated.SearchForAssetsParams) error { + if err := si.verifyHandler("SearchForAssets", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + options, err := assetParamsToAssetQuery(params) if err != nil { return badRequest(ctx, err.Error()) @@ -506,6 +549,10 @@ func (si *ServerImplementation) SearchForAssets(ctx echo.Context, params generat // LookupBlock returns the block for a given round number // (GET /v2/blocks/{round-number}) func (si *ServerImplementation) LookupBlock(ctx echo.Context, roundNumber uint64) error { + if err := si.verifyHandler("LookupBlock", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + blk, err := si.fetchBlock(ctx.Request().Context(), roundNumber) if errors.Is(err, idb.ErrorBlockNotFound) { return notFound(ctx, fmt.Sprintf("%s '%d': %v", errLookingUpBlockForRound, roundNumber, err)) @@ -519,6 +566,10 @@ func (si *ServerImplementation) LookupBlock(ctx echo.Context, roundNumber uint64 // LookupTransaction searches for the requested transaction ID. func (si *ServerImplementation) LookupTransaction(ctx echo.Context, txid string) error { + if err := si.verifyHandler("LookupTransaction", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + filter, err := transactionParamsToTransactionFilter(generated.SearchForTransactionsParams{ Txid: strPtr(txid), }) @@ -556,6 +607,10 @@ func (si *ServerImplementation) LookupTransaction(ctx echo.Context, txid string) // SearchForTransactions returns transactions matching the provided parameters // (GET /v2/transactions) func (si *ServerImplementation) SearchForTransactions(ctx echo.Context, params generated.SearchForTransactionsParams) error { + if err := si.verifyHandler("SearchForTransactions", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + filter, err := transactionParamsToTransactionFilter(params) if err != nil { return badRequest(ctx, err.Error()) diff --git a/api/server.go b/api/server.go index 286054c85..8fcef2b0a 100644 --- a/api/server.go +++ b/api/server.go @@ -77,12 +77,19 @@ func Serve(ctx context.Context, serveAddr string, db idb.IndexerDb, fetcherError middleware = append(middleware, middlewares.MakeAuth("X-Indexer-API-Token", options.Tokens)) } + swag, err := generated.GetSwagger() + + if err != nil { + log.Fatal(err) + } + api := ServerImplementation{ EnableAddressSearchRoundRewind: options.DeveloperMode, db: db, fetcher: fetcherError, timeout: options.handlerTimeout(), log: log, + disabledParams: NewDisabledMapFromOA3(swag), } generated.RegisterHandlers(e, &api, middleware...) From 860ed098a61f39ab1e9f10a128a812205d93d048 Mon Sep 17 00:00:00 2001 From: Michael Diamant Date: Fri, 18 Feb 2022 12:36:57 -0500 Subject: [PATCH 03/29] Default to including Python go-algorand E2E tests in buildtestdata.sh (#888) Default to including Python go-algorand E2E tests in `buildtestdata.sh.` * Prior to the PR, `buildtestdata.sh` did not include go-algorand E2E tests. The PR closes the test coverage gap. * Additionally, exposes an environment variable to simplify user control of included tests. The exposed configuration improves workflows for local testing on select tests. --- misc/buildtestdata.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/misc/buildtestdata.sh b/misc/buildtestdata.sh index 263b7e4d6..0a5d1cf76 100755 --- a/misc/buildtestdata.sh +++ b/misc/buildtestdata.sh @@ -33,13 +33,18 @@ if [ -z "${E2EDATA}" ]; then E2EDATA="${HOME}/Algorand/e2edata" fi +tests="${INDEXER_BTD_TESTS:-"test/scripts/e2e_subs/{*.py,*.sh}"}" +echo "Configured tests = ${tests}" + # TODO: EXPERIMENTAL # run faster rounds? 1000 down from 2000 export ALGOSMALLLAMBDAMSEC=1000 rm -rf "${E2EDATA}" mkdir -p "${E2EDATA}" -(cd "${GOALGORAND}" && TEMPDIR="${E2EDATA}" python3 test/scripts/e2e_client_runner.py --keep-temps test/scripts/e2e_subs/*.sh) +(cd "${GOALGORAND}" && \ + TEMPDIR="${E2EDATA}" \ + python3 test/scripts/e2e_client_runner.py --keep-temps "$tests") (cd "${E2EDATA}" && tar -j -c -f net_done.tar.bz2 --exclude node.log --exclude agreement.cdv net) @@ -47,4 +52,4 @@ mkdir -p "${E2EDATA}" RSTAMP=$(TZ=UTC python -c 'import time; print("{:08x}".format(0xffffffff - int(time.time() - time.mktime((2020,1,1,0,0,0,-1,-1,-1)))))') echo "COPY AND PASTE THIS TO UPLOAD:" -echo aws s3 cp --acl public-read "${E2EDATA}/net_done.tar.bz2" s3://algorand-testdata/indexer/e2e3/${RSTAMP}/net_done.tar.bz2 +echo aws s3 cp --acl public-read "${E2EDATA}/net_done.tar.bz2" s3://algorand-testdata/indexer/e2e3/"${RSTAMP}"/net_done.tar.bz2 From 6b0ebfdccf74606cb7433d073ee806a07aea90d4 Mon Sep 17 00:00:00 2001 From: Michael Diamant Date: Fri, 18 Feb 2022 15:59:31 -0500 Subject: [PATCH 04/29] Temporarily revert to inline glob search (#889) https://github.com/algorand/indexer/pull/888 introduces a bug due to bash's ordering of brace and parameter expansion. The commit supports parameter expansion at the expense of dropping the environment variable. I'll restore external configuration in a subsequent PR. --- misc/buildtestdata.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/misc/buildtestdata.sh b/misc/buildtestdata.sh index 0a5d1cf76..cb1655d9f 100755 --- a/misc/buildtestdata.sh +++ b/misc/buildtestdata.sh @@ -33,9 +33,6 @@ if [ -z "${E2EDATA}" ]; then E2EDATA="${HOME}/Algorand/e2edata" fi -tests="${INDEXER_BTD_TESTS:-"test/scripts/e2e_subs/{*.py,*.sh}"}" -echo "Configured tests = ${tests}" - # TODO: EXPERIMENTAL # run faster rounds? 1000 down from 2000 export ALGOSMALLLAMBDAMSEC=1000 @@ -44,7 +41,7 @@ rm -rf "${E2EDATA}" mkdir -p "${E2EDATA}" (cd "${GOALGORAND}" && \ TEMPDIR="${E2EDATA}" \ - python3 test/scripts/e2e_client_runner.py --keep-temps "$tests") + python3 test/scripts/e2e_client_runner.py --keep-temps test/scripts/e2e_subs/{*.py,*.sh}) (cd "${E2EDATA}" && tar -j -c -f net_done.tar.bz2 --exclude node.log --exclude agreement.cdv net) From e3ebf75a2a50e90f581ac84840a84fe78e2e4b5f Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 24 Feb 2022 06:45:54 -0500 Subject: [PATCH 05/29] Ensure query is canceled after account rewind. (#893) --- .circleci/config.yml | 8 ++++++-- accounting/rewind.go | 11 ++++++++++- accounting/rewind_test.go | 6 ++++-- api/handlers.go | 6 ++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2f3b07ea8..c4a692dfa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -104,7 +104,9 @@ commands: - run: make lint - run: make check - run: make integration - - run: make test + - run: + command: make test + no_output_timeout: 15m - run: make test-generate - run: make fakepackage - run: make e2e @@ -115,7 +117,9 @@ commands: - run: make lint - run: make check - run: make integration - - run: make test + - run: + command: make test + no_output_timeout: 15m - run: make test-generate - run: make fakepackage - run: make e2e diff --git a/accounting/rewind.go b/accounting/rewind.go index 82dabb110..dfa1290e0 100644 --- a/accounting/rewind.go +++ b/accounting/rewind.go @@ -99,7 +99,16 @@ func AccountAtRound(ctx context.Context, account models.Account, round uint64, d MinRound: round + 1, MaxRound: account.Round, } - txns, r := db.Transactions(ctx, tf) + ctx2, cf := context.WithCancel(ctx) + // In case of a panic before the next defer, call cf() here. + defer cf() + txns, r := db.Transactions(ctx2, tf) + // In case of an error, make sure the context is cancelled, and the channel is cleaned up. + defer func() { + cf() + for range txns { + } + }() if r < account.Round { err = ConsistencyError{fmt.Sprintf("queried round r: %d < account.Round: %d", r, account.Round)} return diff --git a/accounting/rewind_test.go b/accounting/rewind_test.go index 00bd796cf..dbd79ed3b 100644 --- a/accounting/rewind_test.go +++ b/accounting/rewind_test.go @@ -67,10 +67,12 @@ func TestStaleTransactions1(t *testing.T) { Round: 8, } - var outCh <-chan idb.TxnRow + ch := make(chan idb.TxnRow) + var outCh <-chan idb.TxnRow = ch + close(ch) db := &mocks.IndexerDb{} - db.On("GetSpecialAccounts").Return(transactions.SpecialAddresses{}, nil) + db.On("GetSpecialAccounts", mock.Anything).Return(transactions.SpecialAddresses{}, nil) db.On("Transactions", mock.Anything, mock.Anything).Return(outCh, uint64(7)).Once() account, err := AccountAtRound(context.Background(), account, 6, db) diff --git a/api/handlers.go b/api/handlers.go index 0137e68e0..ad2d07903 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -879,6 +879,12 @@ func (si *ServerImplementation) fetchAccounts(ctx context.Context, options idb.A var accountchan <-chan idb.AccountRow accountchan, round = si.db.GetAccounts(ctx, options) + // Make sure accountchan is empty at the end of processing. + defer func() { + for range accountchan { + } + }() + if (atRound != nil) && (*atRound > round) { return fmt.Errorf("%s: the requested round %d > the current round %d", errRewindingAccount, *atRound, round) From 0b6d8142b4726aa78f66cc2611022ef0c531765e Mon Sep 17 00:00:00 2001 From: Jack Smith Date: Thu, 24 Feb 2022 07:54:17 -0500 Subject: [PATCH 06/29] Version bump --- .version | 2 +- third_party/go-algorand | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.version b/.version index 2701a226a..c8e38b614 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.8.4 +2.9.0 diff --git a/third_party/go-algorand b/third_party/go-algorand index f880d8f7a..d2289a52d 160000 --- a/third_party/go-algorand +++ b/third_party/go-algorand @@ -1 +1 @@ -Subproject commit f880d8f7a88fb96e735838423c03665fda7019f8 +Subproject commit d2289a52d517b1e7e0a23b6936305520895d36d5 From ec1ef099cfdb02d0f259884fbbdd546bf1fc55a6 Mon Sep 17 00:00:00 2001 From: Jack Smith Date: Thu, 24 Feb 2022 07:55:55 -0500 Subject: [PATCH 07/29] Revert "Version bump" This reverts commit 0b6d8142b4726aa78f66cc2611022ef0c531765e. --- .version | 2 +- third_party/go-algorand | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.version b/.version index c8e38b614..2701a226a 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.9.0 +2.8.4 diff --git a/third_party/go-algorand b/third_party/go-algorand index d2289a52d..f880d8f7a 160000 --- a/third_party/go-algorand +++ b/third_party/go-algorand @@ -1 +1 @@ -Subproject commit d2289a52d517b1e7e0a23b6936305520895d36d5 +Subproject commit f880d8f7a88fb96e735838423c03665fda7019f8 From a45137176b73aa10660a12faa0ef12a8c43d1b51 Mon Sep 17 00:00:00 2001 From: Jack Smith Date: Thu, 24 Feb 2022 07:56:36 -0500 Subject: [PATCH 08/29] Version bump --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 2701a226a..c8e38b614 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.8.4 +2.9.0 From 48035a6f833e4cb6073395144c93476b201c4ae9 Mon Sep 17 00:00:00 2001 From: Jack Smith Date: Thu, 24 Feb 2022 08:15:11 -0500 Subject: [PATCH 09/29] Version bump --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index c8e38b614..d56b8a8e7 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.9.0 +2.9.0-rc3 From a4687cb61fb6bd3a6846099139ea48cef55450fd Mon Sep 17 00:00:00 2001 From: Jack Smith Date: Thu, 24 Feb 2022 09:12:51 -0500 Subject: [PATCH 10/29] reverting version bump --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index d56b8a8e7..2701a226a 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.9.0-rc3 +2.8.4 From 1216e7957d5fba7c6a858e244a2aaf7e99412e5d Mon Sep 17 00:00:00 2001 From: AlgoStephenAkiki <85183435+AlgoStephenAkiki@users.noreply.github.com> Date: Thu, 24 Feb 2022 10:47:09 -0500 Subject: [PATCH 11/29] Disable Parameters in REST API (#892) * Disable Parameters in REST API Resolves #3582 Puts in the infrastructure and disables certain parameters in the REST API for poor performing queries. * PR comments --- api/disabled_parameters.go | 184 +++++++++++++++++++++++++++++--- api/disabled_parameters_test.go | 24 +++-- api/server.go | 11 +- 3 files changed, 198 insertions(+), 21 deletions(-) diff --git a/api/disabled_parameters.go b/api/disabled_parameters.go index 3a2d0c4c0..33fd3eaed 100644 --- a/api/disabled_parameters.go +++ b/api/disabled_parameters.go @@ -2,6 +2,8 @@ package api import ( "fmt" + "net/http" + "strings" "github.com/getkin/kin-openapi/openapi3" "github.com/labstack/echo/v4" @@ -16,8 +18,8 @@ type EndpointConfig struct { DisabledOptionalParameters map[string]bool } -// NewEndpointConfig creates a new empty endpoint config -func NewEndpointConfig() *EndpointConfig { +// makeEndpointConfig creates a new empty endpoint config +func makeEndpointConfig() *EndpointConfig { rval := &EndpointConfig{ EndpointDisabled: false, DisabledOptionalParameters: make(map[string]bool), @@ -29,28 +31,182 @@ func NewEndpointConfig() *EndpointConfig { // DisabledMap a type that holds a map of disabled types // The key for a disabled map is the handler function name type DisabledMap struct { + // Key -> Function Name/Operation ID Data map[string]*EndpointConfig } -// NewDisabledMap creates a new empty disabled map -func NewDisabledMap() *DisabledMap { +// MakeDisabledMap creates a new empty disabled map +func MakeDisabledMap() *DisabledMap { return &DisabledMap{ Data: make(map[string]*EndpointConfig), } } -// NewDisabledMapFromOA3 Creates a new disabled map from an openapi3 definition -func NewDisabledMapFromOA3(swag *openapi3.Swagger) *DisabledMap { - rval := NewDisabledMap() - for _, item := range swag.Paths { - for _, opItem := range item.Operations() { +// DisabledMapConfig is a type that holds the configuration for setting up +// a DisabledMap +type DisabledMapConfig struct { + // Key -> Path of REST endpoint (i.e. /v2/accounts/{account-id}/transactions) + // Value -> Operation "get, post, etc" -> Sub-value: List of parameters disabled for that endpoint + Data map[string]map[string][]string +} + +// isDisabled Returns true if the parameter is disabled for the given path +func (dmc *DisabledMapConfig) isDisabled(restPath string, operationName string, parameterName string) bool { + parameterList, exists := dmc.Data[restPath][operationName] + if !exists { + return false + } + + for _, parameter := range parameterList { + if parameterName == parameter { + return true + } + } + return false +} + +func (dmc *DisabledMapConfig) addEntry(restPath string, operationName string, parameterNames []string) { + + if dmc.Data == nil { + dmc.Data = make(map[string]map[string][]string) + } + + if dmc.Data[restPath] == nil { + dmc.Data[restPath] = make(map[string][]string) + } + + dmc.Data[restPath][operationName] = parameterNames +} + +// MakeDisabledMapConfig creates a new disabled map configuration +func MakeDisabledMapConfig() *DisabledMapConfig { + return &DisabledMapConfig{ + Data: make(map[string]map[string][]string), + } +} - endpointConfig := NewEndpointConfig() +// GetDefaultDisabledMapConfigForPostgres will generate a configuration that will block certain +// parameters. Should be used only for the postgres implementation +func GetDefaultDisabledMapConfigForPostgres() *DisabledMapConfig { + rval := MakeDisabledMapConfig() + + // Some syntactic sugar + get := func(restPath string, parameterNames []string) { + rval.addEntry(restPath, http.MethodGet, parameterNames) + } + + get("/v2/accounts", []string{"currency-greater-than", "currency-less-than"}) + get("/v2/accounts/{account-id}/transactions", []string{"note-prefix", "tx-type", "sig-type", "asset-id", "before-time", "after-time", "rekey-to"}) + get("/v2/assets", []string{"name", "unit"}) + get("/v2/assets/{asset-id}/balances", []string{"round", "currency-greater-than", "currency-less-than"}) + get("/v2/transactions", []string{"note-prefix", "tx-type", "sig-type", "asset-id", "before-time", "after-time", "currency-greater-than", "currency-less-than", "address-role", "exclude-close-to", "rekey-to", "application-id"}) + get("/v2/assets/{asset-id}/transactions", []string{"note-prefix", "tx-type", "sig-type", "asset-id", "before-time", "after-time", "currency-greater-than", "currency-less-than", "address-role", "exclude-close-to", "rekey-to"}) + + return rval +} + +// ErrDisabledMapConfig contains any mis-spellings that could be present in a configuration +type ErrDisabledMapConfig struct { + // Key -> REST Path that was mis-spelled + // Value -> Operation "get, post, etc" -> Sub-value: Any parameters that were found to be mis-spelled in a valid REST PATH + BadEntries map[string]map[string][]string +} + +func (edmc *ErrDisabledMapConfig) Error() string { + var sb strings.Builder + for k, v := range edmc.BadEntries { + + // If the length of the list is zero then it is a mis-spelled REST path + if len(v) == 0 { + _, _ = sb.WriteString(fmt.Sprintf("Mis-spelled REST Path: %s\n", k)) + continue + } + + for op, param := range v { + _, _ = sb.WriteString(fmt.Sprintf("REST Path %s (Operation: %s) contains mis-spelled parameters: %s\n", k, op, strings.Join(param, ","))) + } + } + return sb.String() +} + +// makeErrDisabledMapConfig returns a new disabled map config error +func makeErrDisabledMapConfig() *ErrDisabledMapConfig { + return &ErrDisabledMapConfig{ + BadEntries: make(map[string]map[string][]string), + } +} + +// validate makes sure that all keys and values in the Disabled Map Configuration +// are actually spelled right. What might happen is that a user might +// accidentally mis-spell an entry, so we want to make sure to mitigate against +// that by checking the openapi definition +func (dmc *DisabledMapConfig) validate(swag *openapi3.Swagger) error { + potentialRval := makeErrDisabledMapConfig() + + for recordedPath, recordedOp := range dmc.Data { + swagPath, exists := swag.Paths[recordedPath] + if !exists { + // This means that the rest endpoint itself is mis-spelled + potentialRval.BadEntries[recordedPath] = map[string][]string{} + continue + } + + for opName, recordedParams := range recordedOp { + // This will panic if it is an illegal name so no need to check for nil + swagOperation := swagPath.GetOperation(opName) + + for _, recordedParam := range recordedParams { + found := false + + for _, swagParam := range swagOperation.Parameters { + if recordedParam == swagParam.Value.Name { + found = true + break + } + } + + if found { + continue + } + + // If we didn't find it then it's time to add it to the entry + if potentialRval.BadEntries[recordedPath] == nil { + potentialRval.BadEntries[recordedPath] = make(map[string][]string) + } + + potentialRval.BadEntries[recordedPath][opName] = append(potentialRval.BadEntries[recordedPath][opName], recordedParam) + } + } + } + + // If we have no entries then don't return an error + if len(potentialRval.BadEntries) != 0 { + return potentialRval + } + + return nil +} + +// MakeDisabledMapFromOA3 Creates a new disabled map from an openapi3 definition +func MakeDisabledMapFromOA3(swag *openapi3.Swagger, config *DisabledMapConfig) (*DisabledMap, error) { + + err := config.validate(swag) + + if err != nil { + return nil, err + } + + rval := MakeDisabledMap() + for restPath, item := range swag.Paths { + for opName, opItem := range item.Operations() { + + endpointConfig := makeEndpointConfig() for _, pref := range opItem.Parameters { - // TODO how to enable it to be disabled - parameterIsDisabled := false + paramName := pref.Value.Name + + parameterIsDisabled := config.isDisabled(restPath, opName, paramName) if !parameterIsDisabled { // If the parameter is not disabled, then we don't need // to do anything @@ -62,7 +218,7 @@ func NewDisabledMapFromOA3(swag *openapi3.Swagger) *DisabledMap { endpointConfig.EndpointDisabled = true } else { // If the optional parameter is disabled, add it to the map - endpointConfig.DisabledOptionalParameters[pref.Value.Name] = true + endpointConfig.DisabledOptionalParameters[paramName] = true } } @@ -72,7 +228,7 @@ func NewDisabledMapFromOA3(swag *openapi3.Swagger) *DisabledMap { } - return rval + return rval, err } // ErrVerifyFailedEndpoint an error that signifies that the entire endpoint is disabled diff --git a/api/disabled_parameters_test.go b/api/disabled_parameters_test.go index 2e76391e8..95508a35f 100644 --- a/api/disabled_parameters_test.go +++ b/api/disabled_parameters_test.go @@ -10,8 +10,20 @@ import ( "github.com/labstack/echo/v4" "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/require" + + "github.com/algorand/indexer/api/generated/v2" ) +func TestValidate(t *testing.T) { + // Validates that the default config is correctly spelled + dmc := GetDefaultDisabledMapConfigForPostgres() + + swag, err := generated.GetSwagger() + require.NoError(t, err) + + require.NoError(t, dmc.validate(swag)) +} + // TestFailingParam tests that disabled parameters provided via // the FormParams() and QueryParams() functions of the context are appropriately handled func TestFailingParam(t *testing.T) { @@ -66,8 +78,8 @@ func TestFailingParam(t *testing.T) { } runner := func(t *testing.T, tstruct *testingStruct, ctxFactory func(*echo.Echo, *url.Values, *testingStruct) *echo.Context) { - dm := NewDisabledMap() - e1 := NewEndpointConfig() + dm := MakeDisabledMap() + e1 := makeEndpointConfig() e1.EndpointDisabled = false e1.DisabledOptionalParameters["1"] = true @@ -110,9 +122,9 @@ func TestFailingParam(t *testing.T) { // TestFailingEndpoint tests that an endpoint which has a disabled required parameter // returns a failed endpoint error func TestFailingEndpoint(t *testing.T) { - dm := NewDisabledMap() + dm := MakeDisabledMap() - e1 := NewEndpointConfig() + e1 := makeEndpointConfig() e1.EndpointDisabled = true e1.DisabledOptionalParameters["1"] = true @@ -134,9 +146,9 @@ func TestFailingEndpoint(t *testing.T) { // TestVerifyNonExistentHandler tests that nonexistent endpoint is logged // but doesn't stop the indexer from functioning func TestVerifyNonExistentHandler(t *testing.T) { - dm := NewDisabledMap() + dm := MakeDisabledMap() - e1 := NewEndpointConfig() + e1 := makeEndpointConfig() e1.EndpointDisabled = false e1.DisabledOptionalParameters["1"] = true diff --git a/api/server.go b/api/server.go index 8fcef2b0a..b808cfdfd 100644 --- a/api/server.go +++ b/api/server.go @@ -83,13 +83,22 @@ func Serve(ctx context.Context, serveAddr string, db idb.IndexerDb, fetcherError log.Fatal(err) } + // TODO enable this when command line options allows for disabling/enabling overrides + //disabledMapConfig := GetDefaultDisabledMapConfigForPostgres() + disabledMapConfig := MakeDisabledMapConfig() + + disabledMap, err := MakeDisabledMapFromOA3(swag, disabledMapConfig) + if err != nil { + log.Fatal(err) + } + api := ServerImplementation{ EnableAddressSearchRoundRewind: options.DeveloperMode, db: db, fetcher: fetcherError, timeout: options.handlerTimeout(), log: log, - disabledParams: NewDisabledMapFromOA3(swag), + disabledParams: disabledMap, } generated.RegisterHandlers(e, &api, middleware...) From df2779cd1536d0a69fe66843e0d2eb05d428d0cf Mon Sep 17 00:00:00 2001 From: shiqizng <80276844+shiqizng@users.noreply.github.com> Date: Thu, 24 Feb 2022 13:56:03 -0500 Subject: [PATCH 12/29] monitoring dashboard (#876) *add monitoring dashboard --- cmd/algorand-indexer/daemon.go | 8 +- fetcher/fetcher.go | 13 + monitoring/Dockerfile-indexer | 33 + monitoring/README.md | 60 + monitoring/dashboard.json | 1198 ++++++++++++++++++ monitoring/docker-compose.yml | 61 + monitoring/examples/widgets.png | Bin 0 -> 366340 bytes monitoring/grafana.ini | 7 + monitoring/grafana_prometheus_datasource.yml | 28 + monitoring/prometheus.yml | 30 + util/metrics/metrics.go | 18 +- 11 files changed, 1452 insertions(+), 4 deletions(-) create mode 100644 monitoring/Dockerfile-indexer create mode 100644 monitoring/README.md create mode 100644 monitoring/dashboard.json create mode 100644 monitoring/docker-compose.yml create mode 100644 monitoring/examples/widgets.png create mode 100644 monitoring/grafana.ini create mode 100644 monitoring/grafana_prometheus_datasource.yml create mode 100644 monitoring/prometheus.yml diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go index f0f268c43..c587199f0 100644 --- a/cmd/algorand-indexer/daemon.go +++ b/cmd/algorand-indexer/daemon.go @@ -213,8 +213,14 @@ func handleBlock(block *rpcs.EncodedBlockCert, imp importer.Importer) error { // Ignore round 0 (which is empty). if block.Block.Round() > 0 { metrics.BlockImportTimeSeconds.Observe(dt.Seconds()) - metrics.ImportedTxnsPerBlock.Observe(float64(len(block.Block.Payset))) metrics.ImportedRoundGauge.Set(float64(block.Block.Round())) + txnCountByType := make(map[string]int) + for _, txn := range block.Block.Payset { + txnCountByType[string(txn.Txn.Type)]++ + } + for k, v := range txnCountByType { + metrics.ImportedTxnsPerBlock.WithLabelValues(k).Set(float64(v)) + } } logger.Infof("round r=%d (%d txn) imported in %s", block.Block.Round(), len(block.Block.Payset), dt.String()) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 38eedd4bf..b7da57690 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -13,6 +13,7 @@ import ( "github.com/algorand/go-algorand-sdk/client/v2/algod" "github.com/algorand/go-algorand/protocol" "github.com/algorand/go-algorand/rpcs" + "github.com/algorand/indexer/util/metrics" log "github.com/sirupsen/logrus" ) @@ -111,7 +112,13 @@ func (bot *fetcherImpl) catchupLoop(ctx context.Context) error { var blockbytes []byte aclient := bot.Algod() for { + start := time.Now() + blockbytes, err = aclient.BlockRaw(bot.nextRound).Do(ctx) + + dt := time.Since(start) + metrics.GetAlgodRawBlockTimeSeconds.Observe(dt.Seconds()) + if err != nil { // If context has expired. if ctx.Err() != nil { @@ -150,7 +157,13 @@ func (bot *fetcherImpl) followLoop(ctx context.Context) error { "r=%d error getting status %d", retries, bot.nextRound) continue } + start := time.Now() + blockbytes, err = aclient.BlockRaw(bot.nextRound).Do(ctx) + + dt := time.Since(start) + metrics.GetAlgodRawBlockTimeSeconds.Observe(dt.Seconds()) + if err == nil { break } else if ctx.Err() != nil { // if context has expired diff --git a/monitoring/Dockerfile-indexer b/monitoring/Dockerfile-indexer new file mode 100644 index 000000000..453880375 --- /dev/null +++ b/monitoring/Dockerfile-indexer @@ -0,0 +1,33 @@ +ARG GO_VERSION=1.17.5 +FROM golang:$GO_VERSION + +ENV DEBIAN_FRONTEND noninteractive +RUN apt-get update && apt-get install -y apt-utils curl git git-core bsdmainutils python3 + + +# Build go-algorand +RUN mkdir /work +WORKDIR /work +ADD . ./ +WORKDIR /work/third_party/go-algorand +RUN ./scripts/configure_dev.sh +RUN make + +# Build indexer +WORKDIR /work +RUN make +WORKDIR /work/cmd/algorand-indexer +ENV CGO_ENABLED="1" +RUN go build + +# The sleep is to wait until postgres starts +CMD ["/bin/sh", "-c", "\ + echo $ALGOD_NET && \ + echo $CONNECTION_STRING &&\ + sleep 5 && \ + ./algorand-indexer daemon \ + --server \":${PORT}\" \ + -P \"${CONNECTION_STRING}\" \ + --metrics-mode VERBOSE \ + --algod-net \"${ALGOD_NET}\" \ + --algod-token ${ALGOD_TOKEN}"] \ No newline at end of file diff --git a/monitoring/README.md b/monitoring/README.md new file mode 100644 index 000000000..196f1c336 --- /dev/null +++ b/monitoring/README.md @@ -0,0 +1,60 @@ +## Indexer Monitoring Dashboard +A monitoring dashboard displaying indexer performance metrics. + +### To start a monitoring dashboard +### Prerequisite + - Algorand indexer is running with metrics-mode set to ON or VERBOSE + +Data sources for Grafana are set in `grafana_prometheus_datasource.yml`. Check that configurations for +Prometheus source and PostgresSQL source are correct or Grafana will not have the metrics. + +Also, Prometheus target should be updated to listen to a correct indexer address (host:port). The default target assumes +the indexer is running on prometheus container's host at port 8888. Check `/targets` to see whether Prometheus +connects to the target successfully. + +```json +static_configs: +- targets: ['host.docker.internal:8888'] +``` + +Run, + +`docker-compose up -d prometheus grafana` + +- Check metrics are written to /metrics +- Grafana is running on http://localhost:3000; default login (admin/admin) + + + +### View metrics on grafana + +- Go to Import and upload dashboard.json +- Run `create extension pg_stat_statements;` sql on db to enable query stats from Postgres. + - If getting error such as `elation "pg_stat_statements" does not exist`, check `pg_stat_statements` is + loaded to shared_preload_libraries in postgresql.conf or added in db start up command `-c shared_preload_libraries=pg_stat_statements`. + +![](examples/widgets.png) + + +**Default widgets**: + +Importing +- Block import time +- Transactions per block by type +- TPS +- Round # + +Query + +- Request duration +- Request duraton by url +- Postgres Eval Time + +System + +- average CPU usage +- average Heap Alloc +- Postgres table info +- Postgres queries info + + diff --git a/monitoring/dashboard.json b/monitoring/dashboard.json new file mode 100644 index 000000000..066c9668f --- /dev/null +++ b/monitoring/dashboard.json @@ -0,0 +1,1198 @@ +{ + "__inputs": [ + { + "name": "DS_INDEXERPROMETHEUS", + "label": "IndexerPrometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + }, + { + "name": "DS_POSTGRESQL", + "label": "PostgreSQL", + "description": "", + "type": "datasource", + "pluginId": "postgres", + "pluginName": "PostgreSQL" + } + ], + "__elements": [], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "8.3.6" + }, + { + "type": "panel", + "id": "histogram", + "name": "Histogram", + "version": "" + }, + { + "type": "datasource", + "id": "postgres", + "name": "PostgreSQL", + "version": "1.0.0" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 22, + "panels": [], + "title": "Query", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 12, + "options": { + "bucketOffset": 0, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "histogram_quantile(0.95, sum(rate(indexer_request_duration_seconds_bucket[5m])) by (le))", + "interval": "", + "legendFormat": "request duration (sec)", + "refId": "A" + } + ], + "title": "Request Duration", + "type": "histogram" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 28, + "options": { + "bucketOffset": 0, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "histogram_quantile(0.95, sum(rate(indexer_request_duration_seconds_bucket[5m])) by (le, url))", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Request Duration by URL", + "type": "histogram" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 24, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "rate(indexer_daemon_postgres_eval_time_sec_sum[1m])", + "interval": "", + "legendFormat": "eval_time(sec)", + "refId": "A" + } + ], + "title": "Postgres Eval Time", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 20, + "panels": [], + "title": "System", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 20 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "rate(process_cpu_seconds_total[1m])", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "CPU", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 18, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "rate(go_memstats_heap_alloc_bytes[$__interval])", + "interval": "", + "legendFormat": "alloc_bytes", + "refId": "A" + } + ], + "title": "Heap Alloc", + "type": "timeseries" + }, + { + "datasource": { + "type": "postgres", + "uid": "${DS_POSTGRESQL}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 28 + }, + "id": 26, + "options": { + "footer": { + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "8.3.6", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "${DS_POSTGRESQL}" + }, + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT relname, seq_tup_read,idx_scan,idx_tup_fetch, n_tup_ins, n_tup_upd, n_tup_del, n_tup_hot_upd, n_live_tup,\nn_dead_tup,last_vacuum,last_autovacuum\n\nFROM pg_stat_user_tables", + "refId": "A", + "select": [ + [ + { + "params": [ + "*" + ], + "type": "column" + } + ] + ], + "table": "pg_stat_database", + "timeColumn": "time", + "where": [] + } + ], + "title": "postgres db", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "${DS_POSTGRESQL}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "query" + }, + "properties": [ + { + "id": "custom.width", + "value": 167 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 27, + "options": { + "footer": { + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "mean_exec_time" + } + ] + }, + "pluginVersion": "8.3.6", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "${DS_POSTGRESQL}" + }, + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT query, calls,rows,mean_exec_time, \nmin_exec_time,max_exec_time\nFROM pg_stat_statements\nORDER BY mean_exec_time DESC", + "refId": "A", + "select": [ + [ + { + "params": [ + "*" + ], + "type": "column" + } + ] + ], + "table": "pg_stat_database", + "timeColumn": "time", + "where": [] + } + ], + "title": "Queries", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 39 + }, + "id": 14, + "panels": [], + "title": "Importing", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "sum(avg_over_time(indexer_daemon_imported_tx_per_block[10m])/4.3)", + "interval": "", + "legendFormat": "TPS", + "refId": "A" + } + ], + "title": "TPS", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "txns per block" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + }, + { + "id": "custom.drawStyle", + "value": "line" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Total import time (sec)" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 2 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 40 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "rate(indexer_daemon_get_algod_raw_block_time_sec_sum{}[1m])", + "format": "time_series", + "instant": false, + "interval": "", + "legendFormat": "Get Algod Raw Block Response time(sec)", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "sum(avg_over_time(indexer_daemon_imported_tx_per_block[1m]))", + "hide": false, + "interval": "", + "legendFormat": "txns per block", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "rate(indexer_daemon_import_time_sec_sum[1m])", + "hide": false, + "interval": "", + "legendFormat": "block import time (sec)", + "refId": "C" + } + ], + "title": "Block import", + "transformations": [ + { + "id": "calculateField", + "options": { + "alias": "Total import time (sec)", + "binary": { + "left": "Get Algod Raw Block Response time(sec)", + "operator": "+", + "reducer": "sum", + "right": "block import time (sec)" + }, + "mode": "binary", + "reduce": { + "reducer": "sum" + }, + "replaceFields": false + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "rounds imported" + }, + "properties": [ + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "rate" + }, + "properties": [ + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 49 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "indexer_daemon_imported_round{}", + "interval": "", + "legendFormat": "total rounds", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "delta(indexer_daemon_imported_round[$__interval])", + "hide": false, + "interval": "", + "legendFormat": "rounds imported", + "refId": "B" + } + ], + "title": "Round", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 49 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.4.0-beta1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_INDEXERPROMETHEUS}" + }, + "exemplar": true, + "expr": "indexer_daemon_imported_tx_per_block", + "interval": "", + "legendFormat": "{{txn_type}}", + "refId": "A" + } + ], + "title": "Txns per block by Type", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 34, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Monitoring", + "uid": "ArMgIZ-7z", + "version": 3, + "weekStart": "" +} \ No newline at end of file diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 000000000..afc8431fb --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,61 @@ +version: '3' +services: + indexer: + container_name: "indexer" + build: + context: ./../ + dockerfile: "./monitoring/Dockerfile-indexer" + environment: + PORT: 8888 + CONNECTION_STRING: ${DB_CONNECTION:-"host=indexer-db port=5432 user=algorand password=algorand dbname=indexer_db sslmode=disable"} + ALGOD_NET: ${ALGOD_NET} + ALGOD_TOKEN: ${ALGOD_TOKEN} + ports: + - 8888:8888 + depends_on: + - indexer-db + + indexer-db: + image: "postgres:13-alpine" + container_name: "indexer-postgres" + ports: + - 5555:5432 + environment: + POSTGRES_USER: algorand + POSTGRES_PASSWORD: algorand + POSTGRES_DB: indexer_db + command: + - "postgres" + - "-c" + - "shared_preload_libraries=pg_stat_statements" + + prometheus: + image: "prom/prometheus" + container_name: "prometheus" + ports: + - 9090:9090 + volumes: + # Install prometheus config + - ./prometheus.yml:/etc/prometheus/prometheus.yml + extra_hosts: + - "host.docker.internal:host-gateway" + + grafana: + image: "grafana/grafana:8.4.2" + container_name: "grafana" + ports: + - 3000:3000 + environment: + GF_PATHS_CONFIG: /etc/grafana/ours.ini + volumes: + - ./grafana.ini:/etc/grafana/ours.ini + - ./grafana_prometheus_datasource.yml:/etc/grafana/provisioning/datasources/prometheus.yml + # Modify the dashboard with the web interface. Once finished export the + # JSON from the share menu (from the dashboard page), convert the JSON to + # YAML, and update this YAML file. + # + # This didn't work, maybe because the metrics don't exist when initialized? + # The dashboard can be imported manually. + #- ./grafana_indexer_dashboard.yml:/etc/grafana/provisioning/dashboards/indexer.yml + + diff --git a/monitoring/examples/widgets.png b/monitoring/examples/widgets.png new file mode 100644 index 0000000000000000000000000000000000000000..8a50ea3b96161f9987387b18453d9d17ffb448f0 GIT binary patch literal 366340 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%0AHV7)3hX$iauNTY5#pD|3Ld+&|2H`%`<4M*_% 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

0jVj3gnnUUnf0^dT~3?-ilA2Sd` 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 gqIPM%M=W0Ve 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 literal 0 HcmV?d00001 diff --git a/monitoring/grafana.ini b/monitoring/grafana.ini new file mode 100644 index 000000000..11c1fc1b3 --- /dev/null +++ b/monitoring/grafana.ini @@ -0,0 +1,7 @@ +[auth.anonymous] +# enable anonymous access +enabled = true + +# specify organization name that should be used for unauthenticated users +org_name = Algorand + diff --git a/monitoring/grafana_prometheus_datasource.yml b/monitoring/grafana_prometheus_datasource.yml new file mode 100644 index 000000000..ccb869ec1 --- /dev/null +++ b/monitoring/grafana_prometheus_datasource.yml @@ -0,0 +1,28 @@ +# config file version +apiVersion: 1 + +# list of datasources to insert/update depending +# what's available in the database +datasources: + - name: IndexerPrometheus + type: prometheus + # Access mode - proxy (server in the UI) or direct (browser in the UI). + access: proxy + httpMethod: POST + # The default connects to a local instance running in Docker. May be updated + # to connect to any live prometheus instance + url: http://prometheus:9090 + version: 1 + editable: false + + - name: PostgresSQL + type: postgres + # The default connects to a local instance running in Docker. May be updated + # to connect to any live PostgresDB instance + url: indexer-db:5432 + database: indexer_db + user: algorand + secureJsonData: + password: 'algorand' + jsonData: + sslmode: "disable" \ No newline at end of file diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 000000000..947d70806 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,30 @@ +# my global config +global: + scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute. + evaluation_interval: 5s # Evaluate rules every 15 seconds. The default is every 1 minute. + # scrape_timeout is set to the global default (10s). + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: + # - alertmanager:9093 + +# Load rules once and periodically evaluate them according to the global 'evaluation_interval'. +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +# A scrape configuration containing exactly one endpoint to scrape: +# Here it's Prometheus itself. +scrape_configs: + # The job name is added as a label `job=` to any timeseries scraped from this config. + - job_name: 'scrape_indexer' + + # metrics_path defaults to '/metrics' + # scheme defaults to 'http'. + + static_configs: + - targets: ['host.docker.internal:8888'] + diff --git a/util/metrics/metrics.go b/util/metrics/metrics.go index a918a13ab..bffdd452a 100644 --- a/util/metrics/metrics.go +++ b/util/metrics/metrics.go @@ -10,6 +10,7 @@ func RegisterPrometheusMetrics() { prometheus.Register(ImportedRoundGauge) prometheus.Register(BlockUploadTimeSeconds) prometheus.Register(PostgresEvalTimeSeconds) + prometheus.Register(GetAlgodRawBlockTimeSeconds) } // Prometheus metric names broken out for reuse. @@ -19,6 +20,7 @@ const ( ImportedTxnsPerBlockName = "imported_tx_per_block" ImportedRoundGaugeName = "imported_round" PostgresEvalName = "postgres_eval_time_sec" + GetAlgodRawBlockTimeName = "get_algod_raw_block_time_sec" ) // AllMetricNames is a reference for all the custom metric names. @@ -28,6 +30,7 @@ var AllMetricNames = []string{ ImportedTxnsPerBlockName, ImportedRoundGaugeName, PostgresEvalName, + GetAlgodRawBlockTimeName, } // Initialize the prometheus objects. @@ -46,12 +49,14 @@ var ( Help: "Block upload time in seconds.", }) - ImportedTxnsPerBlock = prometheus.NewSummary( - prometheus.SummaryOpts{ + ImportedTxnsPerBlock = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ Subsystem: "indexer_daemon", Name: ImportedTxnsPerBlockName, Help: "Transactions per block.", - }) + }, + []string{"txn_type"}, + ) ImportedRoundGauge = prometheus.NewGauge( prometheus.GaugeOpts{ @@ -66,4 +71,11 @@ var ( Name: PostgresEvalName, Help: "Time spent calling Eval function in seconds.", }) + + GetAlgodRawBlockTimeSeconds = prometheus.NewSummary( + prometheus.SummaryOpts{ + Subsystem: "indexer_daemon", + Name: GetAlgodRawBlockTimeName, + Help: "Total response time from Algod's raw block endpoint in seconds.", + }) ) From 2aad7dbf5a4f368e3804b6995ae8dc41dd939a6a Mon Sep 17 00:00:00 2001 From: shiqizng <80276844+shiqizng@users.noreply.github.com> Date: Fri, 25 Feb 2022 17:00:37 -0500 Subject: [PATCH 13/29] Fix e2e test to support inner transactions. (#899) --- cmd/e2equeries/main.go | 9 +++++++-- misc/buildtestdata.sh | 2 +- misc/e2elive.py | 2 +- util/test/testutil.go | 10 ++++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/cmd/e2equeries/main.go b/cmd/e2equeries/main.go index a786f97ca..35419e5ef 100644 --- a/cmd/e2equeries/main.go +++ b/cmd/e2equeries/main.go @@ -44,7 +44,12 @@ func main() { rowchan, _ := db.Transactions(context.Background(), rekeyTxnQuery) for txnrow := range rowchan { maybeFail(txnrow.Error, "err rekey txn %v\n", txnrow.Error) - rekeyedAuthAddrs = append(rekeyedAuthAddrs, txnrow.Txn.Txn.RekeyTo) + + t := txnrow.Txn + if txnrow.RootTxn != nil { + t = txnrow.RootTxn + } + rekeyedAuthAddrs = append(rekeyedAuthAddrs, t.Txn.RekeyTo) } // some rekeys get rekeyed back; some rekeyed accounts get deleted, just want to find at least one @@ -63,7 +68,7 @@ func main() { } if !foundRekey { // this will report the error in a handy way - printAccountQuery(db, idb.AccountQueryOptions{EqualToAuthAddr: rekeyedAuthAddrs[0][:], Limit: 1}) + printAccountQuery(db, idb.AccountQueryOptions{EqualToAuthAddr: rekeyedAuthAddrs[0][:], Limit: 0}) } // find an asset with > 1 account diff --git a/misc/buildtestdata.sh b/misc/buildtestdata.sh index cb1655d9f..54f8b4604 100755 --- a/misc/buildtestdata.sh +++ b/misc/buildtestdata.sh @@ -49,4 +49,4 @@ mkdir -p "${E2EDATA}" RSTAMP=$(TZ=UTC python -c 'import time; print("{:08x}".format(0xffffffff - int(time.time() - time.mktime((2020,1,1,0,0,0,-1,-1,-1)))))') echo "COPY AND PASTE THIS TO UPLOAD:" -echo aws s3 cp --acl public-read "${E2EDATA}/net_done.tar.bz2" s3://algorand-testdata/indexer/e2e3/"${RSTAMP}"/net_done.tar.bz2 +echo aws s3 cp --acl public-read "${E2EDATA}/net_done.tar.bz2" s3://algorand-testdata/indexer/e2e4/"${RSTAMP}"/net_done.tar.bz2 diff --git a/misc/e2elive.py b/misc/e2elive.py index 71b11c9ec..327ad1dc5 100644 --- a/misc/e2elive.py +++ b/misc/e2elive.py @@ -59,7 +59,7 @@ def main(): s3 = boto3.client('s3', config=Config(signature_version=UNSIGNED)) tarname = 'net_done.tar.bz2' tarpath = os.path.join(tempdir, tarname) - firstFromS3Prefix(s3, bucket, 'indexer/e2e3', tarname, outpath=tarpath) + firstFromS3Prefix(s3, bucket, 'indexer/e2e4', tarname, outpath=tarpath) source_is_tar = True sourcenet = tarpath tempnet = os.path.join(tempdir, 'net') diff --git a/util/test/testutil.go b/util/test/testutil.go index 759be3e52..d97f731ed 100644 --- a/util/test/testutil.go +++ b/util/test/testutil.go @@ -101,12 +101,14 @@ func PrintTxnQuery(db idb.IndexerDb, q idb.TransactionFilter) { for txnrow := range rowchan { util.MaybeFail(txnrow.Error, "err %v\n", txnrow.Error) stxn := txnrow.Txn - tjs := util.JSONOneLine(stxn.Txn) - info("%d:%d %s sr=%d rr=%d ca=%d cr=%d t=%s\n", txnrow.Round, txnrow.Intra, tjs, stxn.SenderRewards, stxn.ReceiverRewards, stxn.ClosingAmount, stxn.CloseRewards, txnrow.RoundTime.String()) - count++ + if stxn != nil { + tjs := util.JSONOneLine(stxn.Txn) + info("%d:%d %s sr=%d rr=%d ca=%d cr=%d t=%s\n", txnrow.Round, txnrow.Intra, tjs, stxn.SenderRewards, stxn.ReceiverRewards, stxn.ClosingAmount, stxn.CloseRewards, txnrow.RoundTime.String()) + count++ + } } info("%d txns\n", count) - if q.Limit != 0 && q.Limit != count { + if q.Limit != 0 && count < 2 || count > 100 { fmt.Fprintf(os.Stderr, "txn q CAME UP SHORT, limit=%d actual=%d, q=%#v\n", q.Limit, count, q) myStackTrace() exitValue = 1 From 8d85bf4689d3278bfff1b4d92b79367753f73592 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Mon, 28 Feb 2022 10:57:41 -0500 Subject: [PATCH 14/29] Include block-generator and validator as algorand-indexer subcommands. (#891) --- cmd/algorand-indexer/main.go | 38 ++-- cmd/block-generator/core/commands.go | 20 ++ cmd/block-generator/{ => generator}/daemon.go | 19 +- cmd/block-generator/main.go | 9 +- cmd/block-generator/runner.go | 44 ----- cmd/block-generator/runner/runner.go | 44 +++++ cmd/import-validator/core/command.go | 2 +- cmd/validator/core/command.go | 187 ++++++++++++++++++ cmd/validator/main.go | 170 +--------------- 9 files changed, 291 insertions(+), 242 deletions(-) create mode 100644 cmd/block-generator/core/commands.go rename cmd/block-generator/{ => generator}/daemon.go (52%) delete mode 100644 cmd/block-generator/runner.go create mode 100644 cmd/block-generator/runner/runner.go create mode 100644 cmd/validator/core/command.go diff --git a/cmd/algorand-indexer/main.go b/cmd/algorand-indexer/main.go index 5b0c6486c..9243ced5a 100644 --- a/cmd/algorand-indexer/main.go +++ b/cmd/algorand-indexer/main.go @@ -12,7 +12,9 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/viper" - "github.com/algorand/indexer/cmd/import-validator/core" + bg "github.com/algorand/indexer/cmd/block-generator/core" + iv "github.com/algorand/indexer/cmd/import-validator/core" + v "github.com/algorand/indexer/cmd/validator/core" "github.com/algorand/indexer/config" "github.com/algorand/indexer/idb" "github.com/algorand/indexer/idb/dummy" @@ -114,9 +116,16 @@ func indexerDbFromFlags(opts idb.IndexerDbOptions) (idb.IndexerDb, chan struct{} } func init() { - // Add hidden subcommands - core.ImportValidatorCmd.Hidden = true - rootCmd.AddCommand(core.ImportValidatorCmd) + // Utilities subcommand for more convenient access to useful testing utilities. + utilsCmd := &cobra.Command{ + Use: "util", + Short: "Utilities for testing Indexer operation and correctness.", + Long: "Utilities used for Indexer development. These are low level tools that may require low level knowledge of Indexer deployment and operation. They are included as part of this binary for ease of deployment and automation, and to publicize their existance to people who may find them useful. More detailed documention may be found on github in README files located the different 'cmd' directories.", + } + utilsCmd.AddCommand(iv.ImportValidatorCmd) + utilsCmd.AddCommand(v.ValidatorCmd) + utilsCmd.AddCommand(bg.BlockGenerator) + rootCmd.AddCommand(utilsCmd) logger = log.New() logger.SetFormatter(&log.JSONFormatter{ @@ -129,14 +138,19 @@ func init() { importCmd.Hidden = true rootCmd.AddCommand(daemonCmd) - rootCmd.PersistentFlags().StringVarP(&logLevel, "loglevel", "l", "info", "verbosity of logs: [error, warn, info, debug, trace]") - rootCmd.PersistentFlags().StringVarP(&logFile, "logfile", "f", "", "file to write logs to, if unset logs are written to standard out") - rootCmd.PersistentFlags().StringVarP(&postgresAddr, "postgres", "P", "", "connection string for postgres database") - rootCmd.PersistentFlags().BoolVarP(&dummyIndexerDb, "dummydb", "n", false, "use dummy indexer db") - rootCmd.PersistentFlags().StringVarP(&cpuProfile, "cpuprofile", "", "", "file to record cpu profile to") - rootCmd.PersistentFlags().StringVarP(&pidFilePath, "pidfile", "", "", "file to write daemon's process id to") - rootCmd.PersistentFlags().StringVarP(&configFile, "configfile", "c", "", "file path to configuration file (indexer.yml)") - rootCmd.PersistentFlags().BoolVarP(&doVersion, "version", "v", false, "print version and exit") + // Not applied globally to avoid adding to utility commands. + addFlags := func(cmd *cobra.Command) { + cmd.Flags().StringVarP(&logLevel, "loglevel", "l", "info", "verbosity of logs: [error, warn, info, debug, trace]") + cmd.Flags().StringVarP(&logFile, "logfile", "f", "", "file to write logs to, if unset logs are written to standard out") + cmd.Flags().StringVarP(&postgresAddr, "postgres", "P", "", "connection string for postgres database") + cmd.Flags().BoolVarP(&dummyIndexerDb, "dummydb", "n", false, "use dummy indexer db") + cmd.Flags().StringVarP(&cpuProfile, "cpuprofile", "", "", "file to record cpu profile to") + cmd.Flags().StringVarP(&pidFilePath, "pidfile", "", "", "file to write daemon's process id to") + cmd.Flags().StringVarP(&configFile, "configfile", "c", "", "file path to configuration file (indexer.yml)") + cmd.Flags().BoolVarP(&doVersion, "version", "v", false, "print version and exit") + } + addFlags(daemonCmd) + addFlags(importCmd) viper.RegisterAlias("postgres", "postgres-connection-string") diff --git a/cmd/block-generator/core/commands.go b/cmd/block-generator/core/commands.go new file mode 100644 index 000000000..a28674fea --- /dev/null +++ b/cmd/block-generator/core/commands.go @@ -0,0 +1,20 @@ +package core + +import ( + "github.com/spf13/cobra" + + "github.com/algorand/indexer/cmd/block-generator/generator" + "github.com/algorand/indexer/cmd/block-generator/runner" +) + +// BlockGenerator related cobra commands, ready to be executed or included as subcommands. +var BlockGenerator *cobra.Command + +func init() { + BlockGenerator = &cobra.Command{ + Use: `block-generator`, + Short: `Block generator testing tools.`, + } + BlockGenerator.AddCommand(runner.RunnerCmd) + BlockGenerator.AddCommand(generator.DaemonCmd) +} diff --git a/cmd/block-generator/daemon.go b/cmd/block-generator/generator/daemon.go similarity index 52% rename from cmd/block-generator/daemon.go rename to cmd/block-generator/generator/daemon.go index 780c726c7..ac60a40ed 100644 --- a/cmd/block-generator/daemon.go +++ b/cmd/block-generator/generator/daemon.go @@ -1,34 +1,33 @@ -package main +package generator import ( "fmt" "math/rand" "github.com/spf13/cobra" - - "github.com/algorand/indexer/cmd/block-generator/generator" ) +// DaemonCmd starts a block generator daemon. +var DaemonCmd *cobra.Command + func init() { rand.Seed(12345) var configFile string var port uint64 - var daemonCmd = &cobra.Command{ + DaemonCmd = &cobra.Command{ Use: "daemon", Short: "Start the generator daemon in standalone mode.", Run: func(cmd *cobra.Command, args []string) { addr := fmt.Sprintf(":%d", port) - srv, _ := generator.MakeServer(configFile, addr) + srv, _ := MakeServer(configFile, addr) srv.ListenAndServe() }, } - daemonCmd.Flags().StringVarP(&configFile, "config", "c", "", "Specify the block configuration yaml file.") - daemonCmd.Flags().Uint64VarP(&port, "port", "p", 4010, "Port to start the server at.") - - daemonCmd.MarkFlagRequired("config") + DaemonCmd.Flags().StringVarP(&configFile, "config", "c", "", "Specify the block configuration yaml file.") + DaemonCmd.Flags().Uint64VarP(&port, "port", "p", 4010, "Port to start the server at.") - rootCmd.AddCommand(daemonCmd) + DaemonCmd.MarkFlagRequired("config") } diff --git a/cmd/block-generator/main.go b/cmd/block-generator/main.go index 26622fa9b..810321f17 100644 --- a/cmd/block-generator/main.go +++ b/cmd/block-generator/main.go @@ -1,12 +1,7 @@ package main -import "github.com/spf13/cobra" - -var rootCmd = &cobra.Command{ - Use: `block-generator`, - Short: `Block generator testing tools.`, -} +import "github.com/algorand/indexer/cmd/block-generator/core" func main() { - rootCmd.Execute() + core.BlockGenerator.Execute() } diff --git a/cmd/block-generator/runner.go b/cmd/block-generator/runner.go deleted file mode 100644 index 0a231ea69..000000000 --- a/cmd/block-generator/runner.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "fmt" - "math/rand" - "time" - - "github.com/spf13/cobra" - - "github.com/algorand/indexer/cmd/block-generator/runner" -) - -func init() { - rand.Seed(12345) - var runnerArgs runner.Args - - var runnerCmd = &cobra.Command{ - Use: "runner", - Short: "Run test suite and collect results.", - Run: func(cmd *cobra.Command, args []string) { - if err := runner.Run(runnerArgs); err != nil { - fmt.Println(err) - } - }, - } - - runnerCmd.Flags().StringVarP(&runnerArgs.Path, "scenario", "s", "", "Directory containing scenarios, or specific scenario file.") - runnerCmd.Flags().StringVarP(&runnerArgs.IndexerBinary, "indexer-binary", "i", "", "Path to indexer binary.") - runnerCmd.Flags().Uint64VarP(&runnerArgs.IndexerPort, "indexer-port", "p", 4010, "Port to start the server at. This is useful if you have a prometheus server for collecting additional data.") - runnerCmd.Flags().StringVarP(&runnerArgs.PostgresConnectionString, "postgres-connection-string", "c", "", "Postgres connection string.") - runnerCmd.Flags().DurationVarP(&runnerArgs.RunDuration, "test-duration", "d", 5*time.Minute, "Duration to use for each scenario.") - runnerCmd.Flags().StringVarP(&runnerArgs.ReportDirectory, "report-directory", "r", "", "Location to place test reports.") - runnerCmd.Flags().StringVarP(&runnerArgs.LogLevel, "log-level", "l", "error", "LogLevel to use when starting Indexer. [error, warn, info, debug, trace]") - runnerCmd.Flags().StringVarP(&runnerArgs.CPUProfilePath, "cpuprofile", "", "", "Path where Indexer writes its CPU profile.") - runnerCmd.Flags().BoolVarP(&runnerArgs.ResetReportDir, "reset", "", false, "If set any existing report directory will be deleted before running tests.") - runnerCmd.Flags().BoolVarP(&runnerArgs.RunValidation, "validate", "", false, "If set the validator will run after test-duration has elapsed to verify data is correct. An extra line in each report indicates validator success or failure.") - - runnerCmd.MarkFlagRequired("scenario") - runnerCmd.MarkFlagRequired("indexer-binary") - runnerCmd.MarkFlagRequired("postgres-connection-string") - runnerCmd.MarkFlagRequired("report-directory") - - rootCmd.AddCommand(runnerCmd) -} diff --git a/cmd/block-generator/runner/runner.go b/cmd/block-generator/runner/runner.go new file mode 100644 index 000000000..5d42a879a --- /dev/null +++ b/cmd/block-generator/runner/runner.go @@ -0,0 +1,44 @@ +package runner + +import ( + "fmt" + "math/rand" + "time" + + "github.com/spf13/cobra" +) + +// RunnerCmd launches the block-generator test suite runner. +var RunnerCmd *cobra.Command + +func init() { + rand.Seed(12345) + var runnerArgs Args + + RunnerCmd = &cobra.Command{ + Use: "runner", + Short: "Run test suite and collect results.", + Long: "Run an automated test suite using the block-generator daemon and a provided algorand-indexer binary. Results are captured to a specified output directory.", + Run: func(cmd *cobra.Command, args []string) { + if err := Run(runnerArgs); err != nil { + fmt.Println(err) + } + }, + } + + RunnerCmd.Flags().StringVarP(&runnerArgs.Path, "scenario", "s", "", "Directory containing scenarios, or specific scenario file.") + RunnerCmd.Flags().StringVarP(&runnerArgs.IndexerBinary, "indexer-binary", "i", "", "Path to indexer binary.") + RunnerCmd.Flags().Uint64VarP(&runnerArgs.IndexerPort, "indexer-port", "p", 4010, "Port to start the server at. This is useful if you have a prometheus server for collecting additional data.") + RunnerCmd.Flags().StringVarP(&runnerArgs.PostgresConnectionString, "postgres-connection-string", "c", "", "Postgres connection string.") + RunnerCmd.Flags().DurationVarP(&runnerArgs.RunDuration, "test-duration", "d", 5*time.Minute, "Duration to use for each scenario.") + RunnerCmd.Flags().StringVarP(&runnerArgs.ReportDirectory, "report-directory", "r", "", "Location to place test reports.") + RunnerCmd.Flags().StringVarP(&runnerArgs.LogLevel, "log-level", "l", "error", "LogLevel to use when starting Indexer. [error, warn, info, debug, trace]") + RunnerCmd.Flags().StringVarP(&runnerArgs.CPUProfilePath, "cpuprofile", "", "", "Path where Indexer writes its CPU profile.") + RunnerCmd.Flags().BoolVarP(&runnerArgs.ResetReportDir, "reset", "", false, "If set any existing report directory will be deleted before running tests.") + RunnerCmd.Flags().BoolVarP(&runnerArgs.RunValidation, "validate", "", false, "If set the validator will run after test-duration has elapsed to verify data is correct. An extra line in each report indicates validator success or failure.") + + RunnerCmd.MarkFlagRequired("scenario") + RunnerCmd.MarkFlagRequired("indexer-binary") + RunnerCmd.MarkFlagRequired("postgres-connection-string") + RunnerCmd.MarkFlagRequired("report-directory") +} diff --git a/cmd/import-validator/core/command.go b/cmd/import-validator/core/command.go index b33ccffbc..8c07a8f5b 100644 --- a/cmd/import-validator/core/command.go +++ b/cmd/import-validator/core/command.go @@ -2,7 +2,7 @@ package core import "github.com/spf13/cobra" -// ImportValidatorCmd is a configured cobra command to be executed, or included as a subcommand. +// ImportValidatorCmd is the real-time import validator command. var ImportValidatorCmd *cobra.Command func init() { diff --git a/cmd/validator/core/command.go b/cmd/validator/core/command.go new file mode 100644 index 000000000..a5180f674 --- /dev/null +++ b/cmd/validator/core/command.go @@ -0,0 +1,187 @@ +package core + +import ( + "bufio" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" +) + +// ValidatorCmd is the account validator utility. +var ValidatorCmd *cobra.Command + +func init() { + var ( + config Params + addr string + threads int + processorNum int + printCurl bool + ) + + ValidatorCmd = &cobra.Command{ + Use: "validator", + Short: "validator", + Long: "Compare algod and indexer to each other and report any discrepencies.", + Run: func(cmd *cobra.Command, _ []string) { + run(config, addr, threads, processorNum, printCurl) + }, + } + + ValidatorCmd.Flags().StringVar(&config.AlgodURL, "algod-url", "", "Algod url.") + ValidatorCmd.MarkFlagRequired("algod-url") + ValidatorCmd.Flags().StringVar(&config.AlgodToken, "algod-token", "", "Algod token.") + ValidatorCmd.Flags().StringVar(&config.IndexerURL, "indexer-url", "", "Indexer url.") + ValidatorCmd.MarkFlagRequired("indexer-url") + ValidatorCmd.Flags().StringVar(&config.IndexerToken, "indexer-token", "", "Indexer token.") + ValidatorCmd.Flags().IntVarP(&config.Retries, "retries", "", 5, "Number of retry attempts when a difference is detected.") + ValidatorCmd.Flags().IntVarP(&config.RetryDelayMS, "retry-delay", "", 1000, "Time in milliseconds to sleep between retries.") + ValidatorCmd.Flags().StringVar(&addr, "addr", "", "If provided validate a single address instead of reading Stdin.") + ValidatorCmd.Flags().IntVar(&threads, "threads", 4, "Number of worker threads to initialize.") + ValidatorCmd.Flags().IntVar(&threads, "processor", 0, "Choose compare algorithm [0 = Struct, 1 = Reflection]") + ValidatorCmd.Flags().BoolVar(&printCurl, "print-commands", false, "Print curl commands, including tokens, to query algod and indexer.") +} + +func run(config Params, addr string, threads int, processorNum int, printCurl bool) { + if len(config.AlgodURL) == 0 { + ErrorLog.Fatalf("algod-url parameter is required.") + } + if len(config.AlgodToken) == 0 { + ErrorLog.Fatalf("algod-token parameter is required.") + } + if len(config.IndexerURL) == 0 { + ErrorLog.Fatalf("indexer-url parameter is required.") + } + + results := make(chan Result, 10) + + go func() { + if len(addr) != 0 { + processor, err := MakeProcessor(ProcessorID(processorNum)) + if err != nil { + ErrorLog.Fatalf("%s.\n", err) + } + + // Process a single address + CallProcessor(processor, addr, config, results) + close(results) + } else { + // Process from stdin + start(ProcessorID(processorNum), threads, config, results) + } + }() + + // This will keep going until the results channel is closed. + numErrors := resultsPrinter(config, printCurl, results) + if numErrors > 0 { + os.Exit(1) + } +} + +// start kicks off a bunch of go routines to compare addresses, it also creates a work channel to feed the workers and +// fills the work channel by reading from os.Stdin. Results are returned to the results channel. +func start(processorID ProcessorID, threads int, config Params, results chan<- Result) { + work := make(chan string, 100*threads) + + // Read addresses from stdin and pass along to workers + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + work <- scanner.Text() + } + close(work) + }() + + Start(work, processorID, threads, config, results) +} + +// resultChar picks the appropriate status character for the output. +func resultChar(success bool, retries int) string { + if success && retries == 0 { + return "." + } + if success && retries > 9 { + return fmt.Sprintf("(%d)", retries) + } + if success { + return fmt.Sprintf("%d", retries) + } + return "X" +} + +// resultsPrinter reads the results channel and prints it to the error log. Returns the number of errors. +func resultsPrinter(config Params, printCurl bool, results <-chan Result) int { + numResults := 0 + numErrors := 0 + numRetries := 0 + startTime := time.Now() + + stats := func() { + endTime := time.Now() + duration := endTime.Sub(startTime) + fmt.Printf("\n\nNumber of errors: [%d / %d]\n", numErrors, numResults) + fmt.Printf("Retry count: %d\n", numRetries) + fmt.Printf("Checks per second: %f\n", float64(numResults+numRetries)/duration.Seconds()) + fmt.Printf("Test duration: %s\n", time.Time{}.Add(duration).Format("15:04:05")) + } + + // Print stats at the end when things terminate naturally. + defer stats() + + // Also print stats as the program exits after being interrupted. + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-quit + stats() + os.Exit(1) + }() + + // Process results. Print progress to stdout and log errors to errorLog. + for r := range results { + if numResults%100 == 0 { + fmt.Printf("\n%-8d : ", numResults) + } + fmt.Printf("%s", resultChar(r.Equal, r.Retries)) + + numResults++ + numRetries += r.Retries + if r.Error != nil || !r.Equal { + numErrors++ + ErrorLog.Printf("===================================================================") + ErrorLog.Printf("%s", time.Now().Format("2006-01-02 3:4:5 PM")) + ErrorLog.Printf("Account: %s", r.Details.Address) + ErrorLog.Printf("Error #: %d", numErrors) + ErrorLog.Printf("Retries: %d", r.Retries) + ErrorLog.Printf("Rounds Match: %t", r.SameRound) + + // Print error message if there is one. + if r.Error != nil { + ErrorLog.Printf("Processor error: %v\n", r.Error) + } else { + // Print error details if there are any. + if r.Details != nil { + ErrorLog.Printf("Algod Details:\n%s", r.Details.Algod) + ErrorLog.Printf("Indexer Details:\n%s", r.Details.Indexer) + ErrorLog.Printf("Differences:") + for _, diff := range r.Details.Diff { + ErrorLog.Printf(" - %s", diff) + } + } + // Optionally print curl command. + if printCurl { + ErrorLog.Printf("echo 'Algod:'") + ErrorLog.Printf("curl -q -s -H 'Authorization: Bearer %s' '%s/v2/accounts/%s?pretty'", config.AlgodToken, config.AlgodURL, r.Details.Address) + ErrorLog.Printf("echo 'Indexer:'") + ErrorLog.Printf("curl -q -s -H 'Authorization: Bearer %s' '%s/v2/accounts/%s?pretty'", config.IndexerToken, config.IndexerURL, r.Details.Address) + } + } + } + } + + return numErrors +} diff --git a/cmd/validator/main.go b/cmd/validator/main.go index c59403af4..4411bd793 100644 --- a/cmd/validator/main.go +++ b/cmd/validator/main.go @@ -1,173 +1,7 @@ package main -import ( - "bufio" - "flag" - "fmt" - "os" - "os/signal" - "syscall" - "time" - - "github.com/algorand/indexer/cmd/validator/core" -) +import "github.com/algorand/indexer/cmd/validator/core" func main() { - var ( - config core.Params - addr string - threads int - processorNum int - printCurl bool - ) - - flag.StringVar(&config.AlgodURL, "algod-url", "", "Algod url.") - flag.StringVar(&config.AlgodToken, "algod-token", "", "Algod token.") - flag.StringVar(&config.IndexerURL, "indexer-url", "", "Indexer url.") - flag.StringVar(&config.IndexerToken, "indexer-token", "", "Indexer toke.n") - flag.StringVar(&addr, "addr", "", "If provided validate a single address instead of reading Stdin.") - flag.IntVar(&config.Retries, "retries", 5, "Number of retry attempts when a difference is detected.") - flag.IntVar(&config.RetryDelayMS, "retry-delay", 1000, "Time in milliseconds to sleep between retries.") - flag.IntVar(&threads, "threads", 4, "Number of worker threads to initialize.") - flag.IntVar(&processorNum, "processor", 0, "Choose compare algorithm [0 = Struct, 1 = Reflection]") - flag.BoolVar(&printCurl, "print-commands", false, "Print curl commands, including tokens, to query algod and indexer.") - flag.Parse() - - if len(config.AlgodURL) == 0 { - core.ErrorLog.Fatalf("algod-url parameter is required.") - } - if len(config.AlgodToken) == 0 { - core.ErrorLog.Fatalf("algod-token parameter is required.") - } - if len(config.IndexerURL) == 0 { - core.ErrorLog.Fatalf("indexer-url parameter is required.") - } - - results := make(chan core.Result, 10) - - go func() { - if len(addr) != 0 { - processor, err := core.MakeProcessor(core.ProcessorID(processorNum)) - if err != nil { - core.ErrorLog.Fatalf("%s.\n", err) - } - - // Process a single address - core.CallProcessor(processor, addr, config, results) - close(results) - } else { - // Process from stdin - start(core.ProcessorID(processorNum), threads, config, results) - } - }() - - // This will keep going until the results channel is closed. - numErrors := resultsPrinter(config, printCurl, results) - if numErrors > 0 { - os.Exit(1) - } -} - -// start kicks off a bunch of go routines to compare addresses, it also creates a work channel to feed the workers and -// fills the work channel by reading from os.Stdin. Results are returned to the results channel. -func start(processorID core.ProcessorID, threads int, config core.Params, results chan<- core.Result) { - work := make(chan string, 100*threads) - - // Read addresses from stdin and pass along to workers - go func() { - scanner := bufio.NewScanner(os.Stdin) - for scanner.Scan() { - work <- scanner.Text() - } - close(work) - }() - - core.Start(work, processorID, threads, config, results) -} - -// resultChar picks the appropriate status character for the output. -func resultChar(success bool, retries int) string { - if success && retries == 0 { - return "." - } - if success && retries > 9 { - return fmt.Sprintf("(%d)", retries) - } - if success { - return fmt.Sprintf("%d", retries) - } - return "X" -} - -// resultsPrinter reads the results channel and prints it to the error log. Returns the number of errors. -func resultsPrinter(config core.Params, printCurl bool, results <-chan core.Result) int { - numResults := 0 - numErrors := 0 - numRetries := 0 - startTime := time.Now() - - stats := func() { - endTime := time.Now() - duration := endTime.Sub(startTime) - fmt.Printf("\n\nNumber of errors: [%d / %d]\n", numErrors, numResults) - fmt.Printf("Retry count: %d\n", numRetries) - fmt.Printf("Checks per second: %f\n", float64(numResults+numRetries)/duration.Seconds()) - fmt.Printf("Test duration: %s\n", time.Time{}.Add(duration).Format("15:04:05")) - } - - // Print stats at the end when things terminate naturally. - defer stats() - - // Also print stats as the program exits after being interrupted. - quit := make(chan os.Signal, 1) - signal.Notify(quit, os.Interrupt, syscall.SIGTERM, syscall.SIGINT) - go func() { - <-quit - stats() - os.Exit(1) - }() - - // Process results. Print progress to stdout and log errors to errorLog. - for r := range results { - if numResults%100 == 0 { - fmt.Printf("\n%-8d : ", numResults) - } - fmt.Printf("%s", resultChar(r.Equal, r.Retries)) - - numResults++ - numRetries += r.Retries - if r.Error != nil || !r.Equal { - numErrors++ - core.ErrorLog.Printf("===================================================================") - core.ErrorLog.Printf("%s", time.Now().Format("2006-01-02 3:4:5 PM")) - core.ErrorLog.Printf("Account: %s", r.Details.Address) - core.ErrorLog.Printf("Error #: %d", numErrors) - core.ErrorLog.Printf("Retries: %d", r.Retries) - core.ErrorLog.Printf("Rounds Match: %t", r.SameRound) - - // Print error message if there is one. - if r.Error != nil { - core.ErrorLog.Printf("Processor error: %v\n", r.Error) - } else { - // Print error details if there are any. - if r.Details != nil { - core.ErrorLog.Printf("Algod Details:\n%s", r.Details.Algod) - core.ErrorLog.Printf("Indexer Details:\n%s", r.Details.Indexer) - core.ErrorLog.Printf("Differences:") - for _, diff := range r.Details.Diff { - core.ErrorLog.Printf(" - %s", diff) - } - } - // Optionally print curl command. - if printCurl { - core.ErrorLog.Printf("echo 'Algod:'") - core.ErrorLog.Printf("curl -q -s -H 'Authorization: Bearer %s' '%s/v2/accounts/%s?pretty'", config.AlgodToken, config.AlgodURL, r.Details.Address) - core.ErrorLog.Printf("echo 'Indexer:'") - core.ErrorLog.Printf("curl -q -s -H 'Authorization: Bearer %s' '%s/v2/accounts/%s?pretty'", config.IndexerToken, config.IndexerURL, r.Details.Address) - } - } - } - } - - return numErrors + core.ValidatorCmd.Execute() } From 8b58e4bf8fa512fab428cabaf94debda6991fb66 Mon Sep 17 00:00:00 2001 From: AlgoStephenAkiki <85183435+AlgoStephenAkiki@users.noreply.github.com> Date: Tue, 1 Mar 2022 16:35:58 -0500 Subject: [PATCH 15/29] Allow Viewing of Disabled Params (#902) * Allow Viewing of Disabled Params Resolves #3583 Add a new subcommand "algorand-indexer api-config" to show current config * Mod file * PR comments * PR comments --- README.md | 44 ++++++++++++++ api/disabled_parameters.go | 95 ++++++++++++++++++++++++++++++ api/server.go | 9 ++- cmd/algorand-indexer/api_config.go | 63 ++++++++++++++++++++ cmd/algorand-indexer/daemon.go | 6 ++ cmd/algorand-indexer/main.go | 1 + go.mod | 1 + 7 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 cmd/algorand-indexer/api_config.go diff --git a/README.md b/README.md index a58e6aafd..3ce5ee47e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,50 @@ When `--token your-token` is provided, an authentication header is required. For ~$ curl localhost:8980/transactions -H "X-Indexer-API-Token: your-token" ``` +## Disabling Parameters + +The Indexer has the ability to selectively enable or disable parameters for endpoints. Disabling a "required" parameter will result in the entire endpoint being disabled while disabling an "optional" parameter will cause an error to be returned only if the parameter is provided. + +### Viewing the Current Configuration + +The Indexer has a default set of disabled parameters. To view the disabled parameters issue: +``` +~$ algorand-indexer api-config +``` + +This will output ONLY the disabled parameters in a YAML configuration. To view all parameters (both enabled and disabled) issue: + +``` +~$ algorand-indexer api-config --all +``` + +### Interpreting The Configuration + +Below is a snippet of the output from `algorand-indexer api-config`: + +``` +/v2/accounts: + optional: + - currency-greater-than: disabled + - currency-less-than: disabled +/v2/assets/{asset-id}/transactions: + optional: + - note-prefix: disabled + - tx-type: disabled + - sig-type: disabled + - before-time: disabled + - after-time: disabled + - currency-greater-than: disabled + - currency-less-than: disabled + - address-role: disabled + - exclude-close-to: disabled + - rekey-to: disabled + required: + - asset-id: disabled +``` + +Seeing this we know that the `/v2/accounts` endpoint will return an error if either `currency-greater-than` or `currency-less-than` is provided. Additionally, because a "required" parameter is provided for `/v2/assets/{asset-id}/transactions` then we know this entire endpoint is disabled. The optional parameters are provided so that you can understand what else is disabled if you enable all "required" parameters. + ## Metrics The `/metrics` endpoint is configured with the `--metrics-mode` option and configures if and how [Prometheus](https://prometheus.io/) formatted metrics are generated. diff --git a/api/disabled_parameters.go b/api/disabled_parameters.go index 33fd3eaed..33fbc8b6c 100644 --- a/api/disabled_parameters.go +++ b/api/disabled_parameters.go @@ -7,8 +7,103 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/labstack/echo/v4" + "gopkg.in/yaml.v3" ) +// DisplayDisabledMap is a struct that contains the necessary information +// to output the current config to the screen +type DisplayDisabledMap struct { + // A complicated map but necessary to output the correct YAML. + // This is supposed to represent a data structure with a similar YAML + // representation: + // /v2/accounts/{account-id}: + // required: + // - account-id : enabled + // /v2/accounts: + // optional: + // - auth-addr : enabled + // - next: disabled + Data map[string]map[string][]map[string]string +} + +func (ddm *DisplayDisabledMap) String() (string, error) { + + if len(ddm.Data) == 0 { + return "", nil + } + + bytes, err := yaml.Marshal(ddm.Data) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +func makeDisplayDisabledMap() *DisplayDisabledMap { + return &DisplayDisabledMap{ + Data: make(map[string]map[string][]map[string]string), + } +} + +func (ddm *DisplayDisabledMap) addEntry(restPath string, requiredOrOptional string, entryName string, status string) { + if ddm.Data == nil { + ddm.Data = make(map[string]map[string][]map[string]string) + } + + if ddm.Data[restPath] == nil { + ddm.Data[restPath] = make(map[string][]map[string]string) + } + + mapEntry := map[string]string{entryName: status} + + ddm.Data[restPath][requiredOrOptional] = append(ddm.Data[restPath][requiredOrOptional], mapEntry) +} + +// MakeDisplayDisabledMapFromConfig will make a DisplayDisabledMap that takes into account the DisabledMapConfig. +// If limited is set to true, then only disabled parameters will be added to the DisplayDisabledMap +func MakeDisplayDisabledMapFromConfig(swag *openapi3.Swagger, mapConfig *DisabledMapConfig, limited bool) *DisplayDisabledMap { + + rval := makeDisplayDisabledMap() + for restPath, item := range swag.Paths { + + for opName, opItem := range item.Operations() { + + for _, pref := range opItem.Parameters { + + paramName := pref.Value.Name + + parameterIsDisabled := mapConfig.isDisabled(restPath, opName, paramName) + // If we are limited, then don't bother with enabled parameters + if !parameterIsDisabled && limited { + // If the parameter is not disabled, then we don't need + // to do anything + continue + } + + var statusStr string + + if parameterIsDisabled { + statusStr = "disabled" + } else { + statusStr = "enabled" + } + + if pref.Value.Required { + rval.addEntry(restPath, "required", paramName, statusStr) + } else { + // If the optional parameter is disabled, add it to the map + rval.addEntry(restPath, "optional", paramName, statusStr) + } + } + + } + + } + + return rval +} + // EndpointConfig is a data structure that contains whether the // endpoint is disabled (with a boolean) as well as a set that // contains disabled optional parameters. The disabled optional parameter diff --git a/api/server.go b/api/server.go index b808cfdfd..74f5616ee 100644 --- a/api/server.go +++ b/api/server.go @@ -37,6 +37,9 @@ type ExtraOptions struct { // ReadTimeout is the maximum duration for reading the entire request, including the body. ReadTimeout time.Duration + + // DisabledMapConfig is the disabled map configuration that is being used by the server + DisabledMapConfig *DisabledMapConfig } func (e ExtraOptions) handlerTimeout() time.Duration { @@ -83,11 +86,7 @@ func Serve(ctx context.Context, serveAddr string, db idb.IndexerDb, fetcherError log.Fatal(err) } - // TODO enable this when command line options allows for disabling/enabling overrides - //disabledMapConfig := GetDefaultDisabledMapConfigForPostgres() - disabledMapConfig := MakeDisabledMapConfig() - - disabledMap, err := MakeDisabledMapFromOA3(swag, disabledMapConfig) + disabledMap, err := MakeDisabledMapFromOA3(swag, options.DisabledMapConfig) if err != nil { log.Fatal(err) } diff --git a/cmd/algorand-indexer/api_config.go b/cmd/algorand-indexer/api_config.go new file mode 100644 index 000000000..acdb6d1f6 --- /dev/null +++ b/cmd/algorand-indexer/api_config.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/algorand/indexer/api" + "github.com/algorand/indexer/api/generated/v2" + "github.com/algorand/indexer/config" +) + +var ( + showAllDisabled bool +) + +var apiConfigCmd = &cobra.Command{ + Use: "api-config", + Short: "dump api configuration", + Long: "dump api configuration", + //Args: + Run: func(cmd *cobra.Command, args []string) { + var err error + config.BindFlags(cmd) + err = configureLogger() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to configure logger: %v", err) + os.Exit(1) + } + swag, err := generated.GetSwagger() + + if err != nil { + fmt.Fprintf(os.Stderr, "failed to get swagger: %v", err) + os.Exit(1) + } + + options := makeOptions() + + var displayDisabledMapConfig *api.DisplayDisabledMap + // Show a limited subset + if !showAllDisabled { + displayDisabledMapConfig = api.MakeDisplayDisabledMapFromConfig(swag, options.DisabledMapConfig, true) + } else { + displayDisabledMapConfig = api.MakeDisplayDisabledMapFromConfig(swag, options.DisabledMapConfig, false) + } + + output, err := displayDisabledMapConfig.String() + + if err != nil { + fmt.Fprintf(os.Stderr, "failed to output yaml: %v", err) + os.Exit(1) + } + + fmt.Fprint(os.Stdout, output) + os.Exit(0) + + }, +} + +func init() { + apiConfigCmd.Flags().BoolVar(&showAllDisabled, "all", false, "show all api config parameters, enabled and disabled") +} diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go index c587199f0..7f19634cc 100644 --- a/cmd/algorand-indexer/daemon.go +++ b/cmd/algorand-indexer/daemon.go @@ -175,6 +175,12 @@ func makeOptions() (options api.ExtraOptions) { options.WriteTimeout = writeTimeout options.ReadTimeout = readTimeout + // TODO enable this when command line options allows for disabling/enabling overrides + //disabledMapConfig := api.GetDefaultDisabledMapConfigForPostgres() + disabledMapConfig := api.MakeDisabledMapConfig() + + options.DisabledMapConfig = disabledMapConfig + return } diff --git a/cmd/algorand-indexer/main.go b/cmd/algorand-indexer/main.go index 9243ced5a..7ca86e459 100644 --- a/cmd/algorand-indexer/main.go +++ b/cmd/algorand-indexer/main.go @@ -137,6 +137,7 @@ func init() { rootCmd.AddCommand(importCmd) importCmd.Hidden = true rootCmd.AddCommand(daemonCmd) + rootCmd.AddCommand(apiConfigCmd) // Not applied globally to avoid adding to utility commands. addFlags := func(cmd *cobra.Command) { diff --git a/go.mod b/go.mod index ff6cc2bd9..fd43de5d5 100644 --- a/go.mod +++ b/go.mod @@ -27,4 +27,5 @@ require ( github.com/vektra/mockery v1.1.2 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect golang.org/x/tools v0.1.9 // indirect + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 ) From 1c0885c5cfe65913a9070af1a531336bcc081452 Mon Sep 17 00:00:00 2001 From: Michael Diamant Date: Wed, 2 Mar 2022 11:11:38 -0500 Subject: [PATCH 16/29] Document instructions for updating Indexer E2E test input (#906) --- misc/README.md | 12 ++++++++++++ test/README.md | 16 +--------------- 2 files changed, 13 insertions(+), 15 deletions(-) create mode 100644 misc/README.md diff --git a/misc/README.md b/misc/README.md new file mode 100644 index 000000000..d1ee2f172 --- /dev/null +++ b/misc/README.md @@ -0,0 +1,12 @@ +# Indexer E2E tests + +`make e2e` runs tests with input generated by [go-algorand `e2e_subs` tests](https://github.com/algorand/go-algorand/blob/master/test/scripts/e2e_client_runner.py). As of writing `make e2e` is the _only_ automated validation of indexer's consumption of algod output. + +The process for updating the `make e2e`'s input is manual. Each time a go-algorand E2E test is modified, the test modifier _must_ generate new artifacts for `make e2e` consumption. Here's the process: + +| Step | Command/Action | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Run go-algorand `e2e_subs` tests to produce new Indexer E2E inputs.
* See script comments for local environment setup.
* Expect tests to run for ~5-10min. | `bash misc/buildtestdata.sh` | +| Locally confirm Indexer E2E tests pass with new inputs.
* _Warning_: Only use these commands locally because they're _not_ secure.
* The commands directly invoke `e2elive.py` to increase local iteration speed. `make e2e` spins up a Docker compose environment. | * `docker run -it --rm --name some-postgres -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_USER=$USER -e POSTGRES_DB=indexer_db_e2e postgres`
* `python misc/e2elive.py --source-net $E2EDATA/net_done.tar.bz2 --connection-string "host=localhost port=5432 user=$USER dbname=indexer_db_e2e sslmode=disable"`
* Look for `e2elive.py` output starting with: `indexer e2etest OK`. | +| Upload test run artifacts to S3.
* Publishing to S3 streamlines running the tests for other developers and CI.
* Request AWS account in #helpdesk and access key creation permission in #devops. | Run the s3 copy command provided by `misc/buildtestdata.sh` output. | +| `make e2e` runs on every PR. Validate results on the next CI run. | * Find the most recent workflow starting _after_ updating the S3 bucket: [https://app.circleci.com/pipelines/github/algorand/indexer?branch=develop&filter=all](https://app.circleci.com/pipelines/github/algorand/indexer?branch=develop&filter=all).
* If successful, confirm the test used the artifact you uploaded. Click a job link, expand _make e2e_ and search for `INFO:util:s3://`.
* If unsuccessful, investigate source of error. If additional help is needed, raise in #indexer. It is _your_ responsibility to ensure the build is fixed. | \ No newline at end of file diff --git a/test/README.md b/test/README.md index 05a61818d..f5944c4df 100644 --- a/test/README.md +++ b/test/README.md @@ -58,21 +58,7 @@ It may be useful to edit one of the entry point scripts to make sure the dataset When you have setup for an integration test, use the provided `rest_test` / `sql_test` functions to write your tests. -This test loads an e2edata block archive + genesis dataset, you create one using the [buildtestdata.sh](../misc/buildtestdata.sh) script. You need to configure it with where to place the resulting archive, and your go-algorand directory. Here is an example script to setup everything: -```bash -#!/usr/bin/env bash - -rm -rf ve3 -export GOALGORAND="${GOPATH}/src/github.com/algorand/go-algorand" -export E2EDATA="${HOME}/algorand/indexer/e2edata" -export BUILD_BLOCK_ARCHIVE="yes please" -rm -rf "$E2EDATA" -mkdir -p "$E2EDATA" -python3 -m venv ve3 -ve3/bin/pip install py-algorand-sdk -. ve3/bin/activate -./misc/buildtestdata.sh -``` +This test loads an e2edata block archive + genesis dataset, you create one using the [buildtestdata.sh](../misc/buildtestdata.sh) script. See top-level comments in [buildtestdata.sh](../misc/buildtestdata.sh) for local environment setup instructions. The archive will be buried in the `E2EDATA` directory somewhere. That file is passed to start_indexer_with_blocks, then you just need to write the tests. From 534f5a417658ce143f707a07fed8d03da670395d Mon Sep 17 00:00:00 2001 From: Michael Diamant Date: Mon, 7 Mar 2022 14:07:50 -0500 Subject: [PATCH 17/29] Update e2e_subs docs to revise artifact upload process (#910) --- misc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/README.md b/misc/README.md index d1ee2f172..f692e104d 100644 --- a/misc/README.md +++ b/misc/README.md @@ -8,5 +8,5 @@ The process for updating the `make e2e`'s input is manual. Each time a go-algor |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Run go-algorand `e2e_subs` tests to produce new Indexer E2E inputs.
* See script comments for local environment setup.
* Expect tests to run for ~5-10min. | `bash misc/buildtestdata.sh` | | Locally confirm Indexer E2E tests pass with new inputs.
* _Warning_: Only use these commands locally because they're _not_ secure.
* The commands directly invoke `e2elive.py` to increase local iteration speed. `make e2e` spins up a Docker compose environment. | * `docker run -it --rm --name some-postgres -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_USER=$USER -e POSTGRES_DB=indexer_db_e2e postgres`
* `python misc/e2elive.py --source-net $E2EDATA/net_done.tar.bz2 --connection-string "host=localhost port=5432 user=$USER dbname=indexer_db_e2e sslmode=disable"`
* Look for `e2elive.py` output starting with: `indexer e2etest OK`. | -| Upload test run artifacts to S3.
* Publishing to S3 streamlines running the tests for other developers and CI.
* Request AWS account in #helpdesk and access key creation permission in #devops. | Run the s3 copy command provided by `misc/buildtestdata.sh` output. | +| Upload test run artifacts to S3.
* Publishing to S3 streamlines running the tests for other developers and CI.
* If you don't have an AWS account, request artifact upload by pinging Will, Michael, or DevOps. | Run the s3 copy command provided by `misc/buildtestdata.sh` output. | | `make e2e` runs on every PR. Validate results on the next CI run. | * Find the most recent workflow starting _after_ updating the S3 bucket: [https://app.circleci.com/pipelines/github/algorand/indexer?branch=develop&filter=all](https://app.circleci.com/pipelines/github/algorand/indexer?branch=develop&filter=all).
* If successful, confirm the test used the artifact you uploaded. Click a job link, expand _make e2e_ and search for `INFO:util:s3://`.
* If unsuccessful, investigate source of error. If additional help is needed, raise in #indexer. It is _your_ responsibility to ensure the build is fixed. | \ No newline at end of file From 4eaadebbfc650547f3dc7f6ad420fbd6b370c0eb Mon Sep 17 00:00:00 2001 From: shiqizng <80276844+shiqizng@users.noreply.github.com> Date: Mon, 7 Mar 2022 14:14:02 -0500 Subject: [PATCH 18/29] Revert imported_tx_per_block change and add new imported_txns gauge. (#913) --- cmd/algorand-indexer/daemon.go | 3 ++- monitoring/dashboard.json | 36 +++++++++++++++++----------------- util/metrics/metrics.go | 14 +++++++++++-- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go index 7f19634cc..aba744437 100644 --- a/cmd/algorand-indexer/daemon.go +++ b/cmd/algorand-indexer/daemon.go @@ -219,13 +219,14 @@ func handleBlock(block *rpcs.EncodedBlockCert, imp importer.Importer) error { // Ignore round 0 (which is empty). if block.Block.Round() > 0 { metrics.BlockImportTimeSeconds.Observe(dt.Seconds()) + metrics.ImportedTxnsPerBlock.Observe(float64(len(block.Block.Payset))) metrics.ImportedRoundGauge.Set(float64(block.Block.Round())) txnCountByType := make(map[string]int) for _, txn := range block.Block.Payset { txnCountByType[string(txn.Txn.Type)]++ } for k, v := range txnCountByType { - metrics.ImportedTxnsPerBlock.WithLabelValues(k).Set(float64(v)) + metrics.ImportedTxns.WithLabelValues(k).Set(float64(v)) } } diff --git a/monitoring/dashboard.json b/monitoring/dashboard.json index 066c9668f..4b1e3b5cb 100644 --- a/monitoring/dashboard.json +++ b/monitoring/dashboard.json @@ -9,8 +9,8 @@ "pluginName": "Prometheus" }, { - "name": "DS_POSTGRESQL", - "label": "PostgreSQL", + "name": "DS_POSTGRESSQL", + "label": "PostgresSQL", "description": "", "type": "datasource", "pluginId": "postgres", @@ -23,7 +23,7 @@ "type": "grafana", "id": "grafana", "name": "Grafana", - "version": "8.3.6" + "version": "8.4.2" }, { "type": "panel", @@ -514,7 +514,7 @@ { "datasource": { "type": "postgres", - "uid": "${DS_POSTGRESQL}" + "uid": "${DS_POSTGRESSQL}" }, "fieldConfig": { "defaults": { @@ -559,12 +559,12 @@ }, "showHeader": true }, - "pluginVersion": "8.3.6", + "pluginVersion": "8.4.2", "targets": [ { "datasource": { "type": "postgres", - "uid": "${DS_POSTGRESQL}" + "uid": "${DS_POSTGRESSQL}" }, "format": "table", "group": [], @@ -593,7 +593,7 @@ { "datasource": { "type": "postgres", - "uid": "${DS_POSTGRESQL}" + "uid": "${DS_POSTGRESSQL}" }, "fieldConfig": { "defaults": { @@ -657,12 +657,12 @@ } ] }, - "pluginVersion": "8.3.6", + "pluginVersion": "8.4.2", "targets": [ { "datasource": { "type": "postgres", - "uid": "${DS_POSTGRESQL}" + "uid": "${DS_POSTGRESSQL}" }, "format": "table", "group": [], @@ -781,7 +781,7 @@ "uid": "${DS_INDEXERPROMETHEUS}" }, "exemplar": true, - "expr": "sum(avg_over_time(indexer_daemon_imported_tx_per_block[10m])/4.3)", + "expr": "rate(indexer_daemon_imported_tx_per_block_sum{}[$__interval])/rate(indexer_daemon_imported_round[$__interval])", "interval": "", "legendFormat": "TPS", "refId": "A" @@ -907,7 +907,7 @@ "uid": "${DS_INDEXERPROMETHEUS}" }, "exemplar": true, - "expr": "rate(indexer_daemon_get_algod_raw_block_time_sec_sum{}[1m])", + "expr": "rate(indexer_daemon_get_algod_raw_block_time_sec_sum{}[$__interval])", "format": "time_series", "instant": false, "interval": "", @@ -920,7 +920,7 @@ "uid": "${DS_INDEXERPROMETHEUS}" }, "exemplar": true, - "expr": "sum(avg_over_time(indexer_daemon_imported_tx_per_block[1m]))", + "expr": "rate(indexer_daemon_imported_tx_per_block_sum{}[$__interval])", "hide": false, "interval": "", "legendFormat": "txns per block", @@ -932,7 +932,7 @@ "uid": "${DS_INDEXERPROMETHEUS}" }, "exemplar": true, - "expr": "rate(indexer_daemon_import_time_sec_sum[1m])", + "expr": "rate(indexer_daemon_import_time_sec_sum[$__interval])", "hide": false, "interval": "", "legendFormat": "block import time (sec)", @@ -1077,7 +1077,7 @@ "uid": "${DS_INDEXERPROMETHEUS}" }, "exemplar": true, - "expr": "delta(indexer_daemon_imported_round[$__interval])", + "expr": "rate(indexer_daemon_imported_round[$__interval])", "hide": false, "interval": "", "legendFormat": "rounds imported", @@ -1168,7 +1168,7 @@ "uid": "${DS_INDEXERPROMETHEUS}" }, "exemplar": true, - "expr": "indexer_daemon_imported_tx_per_block", + "expr": "indexer_daemon_imported_txns", "interval": "", "legendFormat": "{{txn_type}}", "refId": "A" @@ -1179,20 +1179,20 @@ } ], "refresh": "", - "schemaVersion": 34, + "schemaVersion": 35, "style": "dark", "tags": [], "templating": { "list": [] }, "time": { - "from": "now-5m", + "from": "now-15m", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Monitoring", "uid": "ArMgIZ-7z", - "version": 3, + "version": 9, "weekStart": "" } \ No newline at end of file diff --git a/util/metrics/metrics.go b/util/metrics/metrics.go index bffdd452a..e9b9f1a09 100644 --- a/util/metrics/metrics.go +++ b/util/metrics/metrics.go @@ -11,6 +11,7 @@ func RegisterPrometheusMetrics() { prometheus.Register(BlockUploadTimeSeconds) prometheus.Register(PostgresEvalTimeSeconds) prometheus.Register(GetAlgodRawBlockTimeSeconds) + prometheus.Register(ImportedTxns) } // Prometheus metric names broken out for reuse. @@ -21,6 +22,7 @@ const ( ImportedRoundGaugeName = "imported_round" PostgresEvalName = "postgres_eval_time_sec" GetAlgodRawBlockTimeName = "get_algod_raw_block_time_sec" + ImportedTxnsName = "imported_txns" ) // AllMetricNames is a reference for all the custom metric names. @@ -49,12 +51,20 @@ var ( Help: "Block upload time in seconds.", }) - ImportedTxnsPerBlock = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ + ImportedTxnsPerBlock = prometheus.NewSummary( + prometheus.SummaryOpts{ Subsystem: "indexer_daemon", Name: ImportedTxnsPerBlockName, Help: "Transactions per block.", }, + ) + + ImportedTxns = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: "indexer_daemon", + Name: ImportedTxnsName, + Help: "Imported transactions grouped by type", + }, []string{"txn_type"}, ) From 5b88518ed6fefd5eea1ded89351710e924d140a3 Mon Sep 17 00:00:00 2001 From: AlgoStephenAkiki <85183435+AlgoStephenAkiki@users.noreply.github.com> Date: Wed, 9 Mar 2022 09:32:03 -0500 Subject: [PATCH 19/29] Enable Validation and Acceptance of New Parameter Configuration (#912) * Init * Enable Validation and Acceptance of New Parameter Configuration Resolves #3584 - Allows for the supplying of configuration files for the daemon and api-config subcommands. - Properly validates the schema and contents of the supplied config file - Enables default parameters to be disabled, allows integration tests to run with all parameters enabled * Removed reflect deep equal for unit test * Changed expectError to a string from a boolean * Added checking of supplied file with enable all params --- api/disabled_parameters.go | 143 +++++++++++++++- api/disabled_parameters_test.go | 162 ++++++++++++++++++ .../mock_disabled_map_config.yaml | 6 + cmd/algorand-indexer/api_config.go | 17 +- cmd/algorand-indexer/daemon.go | 68 ++++++-- test/common.sh | 1 + 6 files changed, 366 insertions(+), 31 deletions(-) create mode 100644 api/test_resources/mock_disabled_map_config.yaml diff --git a/api/disabled_parameters.go b/api/disabled_parameters.go index 33fbc8b6c..3769e9708 100644 --- a/api/disabled_parameters.go +++ b/api/disabled_parameters.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "io/ioutil" "net/http" "strings" @@ -10,6 +11,13 @@ import ( "gopkg.in/yaml.v3" ) +const ( + disabledStatusStr = "disabled" + enabledStatusStr = "enabled" + requiredParameterStr = "required" + optionalParameterStr = "optional" +) + // DisplayDisabledMap is a struct that contains the necessary information // to output the current config to the screen type DisplayDisabledMap struct { @@ -60,6 +68,125 @@ func (ddm *DisplayDisabledMap) addEntry(restPath string, requiredOrOptional stri ddm.Data[restPath][requiredOrOptional] = append(ddm.Data[restPath][requiredOrOptional], mapEntry) } +// validateSchema takes in a newly loaded DisplayDisabledMap and validates that all the +// "strings" are the correct values. For instance, it says that the sub-config is either +// "required" or "optional" as well as making sure the string values for the parameters +// are either "enabled" or "disabled". +// +// However, it does not validate whether the values actually exist. That comes with the "Validate" +// function on a DisabledMapConfig +func (ddm *DisplayDisabledMap) validateSchema() error { + type innerStruct struct { + // IllegalParamTypes: list of the mis-spelled parameter types (i.e. not required or optional) + IllegalParamTypes []string + // IllegalParamStatus: list of parameter names with mis-spelled parameter status combined as a string + IllegalParamStatus []string + } + + illegalSchema := make(map[string]innerStruct) + + for restPath, entries := range ddm.Data { + tmp := innerStruct{} + for requiredOrOptional, paramList := range entries { + + if requiredOrOptional != requiredParameterStr && requiredOrOptional != optionalParameterStr { + tmp.IllegalParamTypes = append(tmp.IllegalParamTypes, requiredOrOptional) + } + + for _, paramDict := range paramList { + for paramName, paramStatus := range paramDict { + if paramStatus != disabledStatusStr && paramStatus != enabledStatusStr { + errorStr := fmt.Sprintf("%s : %s", paramName, paramStatus) + tmp.IllegalParamStatus = append(tmp.IllegalParamStatus, errorStr) + } + } + } + + if len(tmp.IllegalParamTypes) != 0 || len(tmp.IllegalParamStatus) != 0 { + illegalSchema[restPath] = tmp + } + } + } + + // No error if there are no entries + if len(illegalSchema) == 0 { + return nil + } + + var sb strings.Builder + + for restPath, iStruct := range illegalSchema { + _, _ = sb.WriteString(fmt.Sprintf("REST Path %s contained the following errors:\n", restPath)) + if len(iStruct.IllegalParamTypes) != 0 { + _, _ = sb.WriteString(fmt.Sprintf(" -> Illegal Parameter Types: %v\n", iStruct.IllegalParamTypes)) + } + if len(iStruct.IllegalParamStatus) != 0 { + _, _ = sb.WriteString(fmt.Sprintf(" -> Illegal Parameter Status: %v\n", iStruct.IllegalParamStatus)) + } + } + + return fmt.Errorf(sb.String()) +} + +// toDisabledMapConfig creates a disabled map config from a display disabled map. If the swag pointer +// is nil then no validation is performed on the disabled map config. This is useful for unit tests +func (ddm *DisplayDisabledMap) toDisabledMapConfig(swag *openapi3.Swagger) (*DisabledMapConfig, error) { + // Check that all the "strings" are valid + err := ddm.validateSchema() + if err != nil { + return nil, err + } + + // We now should have a correctly formed DisplayDisabledMap. + // Let's turn that into a config + dmc := MakeDisabledMapConfig() + + for restPath, entries := range ddm.Data { + var disabledParams []string + for _, paramList := range entries { + // We don't care if they are required or optional, only if the are disabled + for _, paramDict := range paramList { + for paramName, paramStatus := range paramDict { + if paramStatus != disabledStatusStr { + continue + } + disabledParams = append(disabledParams, paramName) + } + } + } + + // Default to just get for now + dmc.addEntry(restPath, http.MethodGet, disabledParams) + } + + if swag != nil { + err = dmc.validate(swag) + if err != nil { + return nil, err + } + } + + return dmc, nil +} + +// MakeDisabledMapConfigFromFile loads a file containing a disabled map configuration. +func MakeDisabledMapConfigFromFile(swag *openapi3.Swagger, filePath string) (*DisabledMapConfig, error) { + // First load the file... + f, err := ioutil.ReadFile(filePath) + if err != nil { + return nil, err + } + + ddm := makeDisplayDisabledMap() + + err = yaml.Unmarshal(f, &ddm.Data) + if err != nil { + return nil, err + } + + return ddm.toDisabledMapConfig(swag) +} + // MakeDisplayDisabledMapFromConfig will make a DisplayDisabledMap that takes into account the DisabledMapConfig. // If limited is set to true, then only disabled parameters will be added to the DisplayDisabledMap func MakeDisplayDisabledMapFromConfig(swag *openapi3.Swagger, mapConfig *DisabledMapConfig, limited bool) *DisplayDisabledMap { @@ -84,16 +211,16 @@ func MakeDisplayDisabledMapFromConfig(swag *openapi3.Swagger, mapConfig *Disable var statusStr string if parameterIsDisabled { - statusStr = "disabled" + statusStr = disabledStatusStr } else { - statusStr = "enabled" + statusStr = enabledStatusStr } if pref.Value.Required { - rval.addEntry(restPath, "required", paramName, statusStr) + rval.addEntry(restPath, requiredParameterStr, paramName, statusStr) } else { // If the optional parameter is disabled, add it to the map - rval.addEntry(restPath, "optional", paramName, statusStr) + rval.addEntry(restPath, optionalParameterStr, paramName, statusStr) } } @@ -173,7 +300,7 @@ func (dmc *DisabledMapConfig) addEntry(restPath string, operationName string, pa dmc.Data[restPath][operationName] = parameterNames } -// MakeDisabledMapConfig creates a new disabled map configuration +// MakeDisabledMapConfig creates a new disabled map configuration with everything enabled func MakeDisabledMapConfig() *DisabledMapConfig { return &DisabledMapConfig{ Data: make(map[string]map[string][]string), @@ -211,14 +338,14 @@ func (edmc *ErrDisabledMapConfig) Error() string { var sb strings.Builder for k, v := range edmc.BadEntries { - // If the length of the list is zero then it is a mis-spelled REST path + // If the length of the list is zero then it is an unknown REST path if len(v) == 0 { - _, _ = sb.WriteString(fmt.Sprintf("Mis-spelled REST Path: %s\n", k)) + _, _ = sb.WriteString(fmt.Sprintf("Unknown REST Path: %s\n", k)) continue } for op, param := range v { - _, _ = sb.WriteString(fmt.Sprintf("REST Path %s (Operation: %s) contains mis-spelled parameters: %s\n", k, op, strings.Join(param, ","))) + _, _ = sb.WriteString(fmt.Sprintf("REST Path %s (Operation: %s) contains unknown parameters: %s\n", k, op, strings.Join(param, ","))) } } return sb.String() diff --git a/api/disabled_parameters_test.go b/api/disabled_parameters_test.go index 95508a35f..4444b1b24 100644 --- a/api/disabled_parameters_test.go +++ b/api/disabled_parameters_test.go @@ -4,6 +4,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "path/filepath" + "reflect" "strings" "testing" @@ -14,6 +16,166 @@ import ( "github.com/algorand/indexer/api/generated/v2" ) +func TestToDisabledMapConfigFromFile(t *testing.T) { + expectedValue := DisabledMapConfig{Data: map[string]map[string][]string{ + "/sampleEndpoint": {http.MethodGet: {"p2"}}, + }} + + configFile := filepath.Join("test_resources", "mock_disabled_map_config.yaml") + + // Nil pointer for openapi3.swagger because we don't want any validation + // to be run on the config (they are made up endpoints) + loadedConfig, err := MakeDisabledMapConfigFromFile(nil, configFile) + require.NoError(t, err) + require.NotNil(t, loadedConfig) + require.Equal(t, expectedValue, *loadedConfig) +} + +func TestToDisabledMapConfig(t *testing.T) { + type testingStruct struct { + name string + ddm *DisplayDisabledMap + dmc *DisabledMapConfig + expectError string + } + + tests := []testingStruct{ + {"test 1", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required": {{"p1": "enabled"}, {"p2": "disabled"}}, + "optional": {{"p3": "enabled"}}, + }}}, + &DisabledMapConfig{Data: map[string]map[string][]string{ + "/sampleEndpoint": {http.MethodGet: {"p2"}}, + }}, + + "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + // Nil pointer for openapi3.swagger because we don't want any validation + // to be run on the config + dmc, err := test.ddm.toDisabledMapConfig(nil) + + if test.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectError) + } else { + require.NoError(t, err) + require.True(t, reflect.DeepEqual(*dmc, *test.dmc)) + } + }) + } + +} + +func TestSchemaCheck(t *testing.T) { + type testingStruct struct { + name string + ddm *DisplayDisabledMap + expectError string + } + tests := []testingStruct{ + {"test param types - good", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required": {{"p1": "enabled"}, {"p2": "disabled"}}, + "optional": {{"p3": "enabled"}}, + }}, + }, + "", + }, + + {"test param types - bad required", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required-FAKE": {{"p1": "enabled"}, {"p2": "disabled"}}, + "optional": {{"p3": "enabled"}}, + }}, + }, + "required-FAKE", + }, + + {"test param types - bad optional", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required": {{"p1": "enabled"}, {"p2": "disabled"}}, + "optional-FAKE": {{"p3": "enabled"}}, + }}, + }, + "optional-FAKE", + }, + + {"test param types - bad both", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required-FAKE": {{"p1": "enabled"}, {"p2": "disabled"}}, + "optional-FAKE": {{"p3": "enabled"}}, + }}, + }, + "required-FAKE optional-FAKE", + }, + + {"test param status - good", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required": {{"p1": "enabled"}, {"p2": "disabled"}}, + "optional": {{"p3": "enabled"}}, + }}, + }, + "", + }, + + {"test param status - bad required", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required": {{"p1": "enabled"}, {"p2": "disabled-FAKE"}}, + "optional": {{"p3": "enabled"}}, + }}, + }, + "p2", + }, + + {"test param status - bad optional", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required": {{"p1": "enabled"}, {"p2": "disabled"}}, + "optional": {{"p3": "enabled-FAKE"}}, + }}, + }, + "p3", + }, + + {"test param status - bad both", + &DisplayDisabledMap{Data: map[string]map[string][]map[string]string{ + "/sampleEndpoint": { + "required": {{"p1": "enabled-FAKE"}, {"p2": "disabled"}}, + "optional": {{"p3": "enabled-FAKE"}}, + }}, + }, + "p1", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.ddm.validateSchema() + + if test.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectError) + } else { + require.NoError(t, err) + } + }) + } + +} + func TestValidate(t *testing.T) { // Validates that the default config is correctly spelled dmc := GetDefaultDisabledMapConfigForPostgres() diff --git a/api/test_resources/mock_disabled_map_config.yaml b/api/test_resources/mock_disabled_map_config.yaml new file mode 100644 index 000000000..6d501aadb --- /dev/null +++ b/api/test_resources/mock_disabled_map_config.yaml @@ -0,0 +1,6 @@ +/sampleEndpoint: + required: + - p1 : enabled + - p2 : disabled + optional: + - p3 : enabled \ No newline at end of file diff --git a/cmd/algorand-indexer/api_config.go b/cmd/algorand-indexer/api_config.go index acdb6d1f6..bdb45ddb3 100644 --- a/cmd/algorand-indexer/api_config.go +++ b/cmd/algorand-indexer/api_config.go @@ -12,14 +12,14 @@ import ( ) var ( - showAllDisabled bool + suppliedAPIConfigFile string + showAllDisabled bool ) var apiConfigCmd = &cobra.Command{ Use: "api-config", - Short: "dump api configuration", - Long: "dump api configuration", - //Args: + Short: "api configuration", + Long: "api configuration", Run: func(cmd *cobra.Command, args []string) { var err error config.BindFlags(cmd) @@ -36,6 +36,14 @@ var apiConfigCmd = &cobra.Command{ } options := makeOptions() + if suppliedAPIConfigFile != "" { + potentialDisabledMapConfig, err := api.MakeDisabledMapConfigFromFile(swag, suppliedAPIConfigFile) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to created disabled map config from file: %v", err) + os.Exit(1) + } + options.DisabledMapConfig = potentialDisabledMapConfig + } var displayDisabledMapConfig *api.DisplayDisabledMap // Show a limited subset @@ -60,4 +68,5 @@ var apiConfigCmd = &cobra.Command{ func init() { apiConfigCmd.Flags().BoolVar(&showAllDisabled, "all", false, "show all api config parameters, enabled and disabled") + apiConfigCmd.Flags().StringVar(&suppliedAPIConfigFile, "api-config-file", "", "supply an API config file to enable/disable parameters") } diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go index aba744437..611e2bc3a 100644 --- a/cmd/algorand-indexer/daemon.go +++ b/cmd/algorand-indexer/daemon.go @@ -10,11 +10,12 @@ import ( "syscall" "time" - "github.com/algorand/go-algorand/rpcs" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/algorand/go-algorand/rpcs" "github.com/algorand/indexer/api" + "github.com/algorand/indexer/api/generated/v2" "github.com/algorand/indexer/config" "github.com/algorand/indexer/fetcher" "github.com/algorand/indexer/idb" @@ -23,18 +24,19 @@ import ( ) var ( - algodDataDir string - algodAddr string - algodToken string - daemonServerAddr string - noAlgod bool - developerMode bool - allowMigration bool - metricsMode string - tokenString string - writeTimeout time.Duration - readTimeout time.Duration - maxConn uint32 + algodDataDir string + algodAddr string + algodToken string + daemonServerAddr string + noAlgod bool + developerMode bool + allowMigration bool + metricsMode string + tokenString string + writeTimeout time.Duration + readTimeout time.Duration + maxConn uint32 + enableAllParameters bool ) var daemonCmd = &cobra.Command{ @@ -51,6 +53,13 @@ var daemonCmd = &cobra.Command{ os.Exit(1) } + // If someone supplied a configuration file but also said to enable all parameters, + // that's an error + if suppliedAPIConfigFile != "" && enableAllParameters { + fmt.Fprint(os.Stderr, "not allowed to supply an api config file and enable all parameters") + os.Exit(1) + } + if algodDataDir == "" { algodDataDir = os.Getenv("ALGORAND_DATA") } @@ -128,7 +137,26 @@ var daemonCmd = &cobra.Command{ fmt.Printf("serving on %s\n", daemonServerAddr) logger.Infof("serving on %s", daemonServerAddr) - api.Serve(ctx, daemonServerAddr, db, bot, logger, makeOptions()) + + options := makeOptions() + + swag, err := generated.GetSwagger() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to get swagger: %v", err) + os.Exit(1) + } + + if suppliedAPIConfigFile != "" { + logger.Infof("supplied api configuration file located at: %s", suppliedAPIConfigFile) + potentialDisabledMapConfig, err := api.MakeDisabledMapConfigFromFile(swag, suppliedAPIConfigFile) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to created disabled map config from file: %v", err) + os.Exit(1) + } + options.DisabledMapConfig = potentialDisabledMapConfig + } + + api.Serve(ctx, daemonServerAddr, db, bot, logger, options) wg.Wait() }, } @@ -147,6 +175,8 @@ func init() { daemonCmd.Flags().DurationVarP(&writeTimeout, "write-timeout", "", 30*time.Second, "set the maximum duration to wait before timing out writes to a http response, breaking connection") daemonCmd.Flags().DurationVarP(&readTimeout, "read-timeout", "", 5*time.Second, "set the maximum duration for reading the entire request") daemonCmd.Flags().Uint32VarP(&maxConn, "max-conn", "", 0, "set the maximum connections allowed in the connection pool, if the maximum is reached subsequent connections will wait until a connection becomes available, or timeout according to the read-timeout setting") + daemonCmd.Flags().StringVar(&suppliedAPIConfigFile, "api-config-file", "", "supply an API config file to enable/disable parameters") + daemonCmd.Flags().BoolVar(&enableAllParameters, "enable-all-parameters", false, "override default configuration and enable all parameters. Can't be used with --api-config-file") viper.RegisterAlias("algod", "algod-data-dir") viper.RegisterAlias("algod-net", "algod-address") @@ -175,11 +205,11 @@ func makeOptions() (options api.ExtraOptions) { options.WriteTimeout = writeTimeout options.ReadTimeout = readTimeout - // TODO enable this when command line options allows for disabling/enabling overrides - //disabledMapConfig := api.GetDefaultDisabledMapConfigForPostgres() - disabledMapConfig := api.MakeDisabledMapConfig() - - options.DisabledMapConfig = disabledMapConfig + if enableAllParameters { + options.DisabledMapConfig = api.MakeDisabledMapConfig() + } else { + options.DisabledMapConfig = api.GetDefaultDisabledMapConfigForPostgres() + } return } diff --git a/test/common.sh b/test/common.sh index ecb1554aa..d72f09423 100755 --- a/test/common.sh +++ b/test/common.sh @@ -200,6 +200,7 @@ function start_indexer_with_connection_string() { ALGORAND_DATA= ../cmd/algorand-indexer/algorand-indexer daemon \ -S $NET "$RO" \ -P "$1" \ + --enable-all-parameters \ "$RO" \ --pidfile $PIDFILE 2>&1 > /dev/null & } From 82b9fe3e8e0cd5599e219a5adff79d30119099ea Mon Sep 17 00:00:00 2001 From: algochoi <86622919+algochoi@users.noreply.github.com> Date: Wed, 9 Mar 2022 16:30:16 -0500 Subject: [PATCH 20/29] Return all inner transactions are returned for logs endpoint. (#915) --- api/handlers.go | 4 +- api/handlers_e2e_test.go | 103 +++++++++++++++++++++++++++++++++ util/test/account_testutil.go | 105 +++++++++++++++++++++++++++++----- 3 files changed, 197 insertions(+), 15 deletions(-) diff --git a/api/handlers.go b/api/handlers.go index ad2d07903..481a78ef8 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -949,7 +949,9 @@ func (si *ServerImplementation) fetchTransactions(ctx context.Context, filter id } // The root txn has already been added. - if _, ok := rootTxnDedupeMap[*tx.Id]; ok { + // If we also want to return inner txns, we cannot deduplicate the + // results as inner txns all share the same txn ID as its root txn. + if _, ok := rootTxnDedupeMap[*tx.Id]; ok && !filter.ReturnInnerTxnOnly { continue } diff --git a/api/handlers_e2e_test.go b/api/handlers_e2e_test.go index 96ca6ba8c..753ddb822 100644 --- a/api/handlers_e2e_test.go +++ b/api/handlers_e2e_test.go @@ -649,3 +649,106 @@ func TestLookupInnerLogs(t *testing.T) { }) } } + +// TestLookupInnerLogs runs queries for logs given application ids, +// and checks that logs in inner transactions match properly. +func TestLookupMultiInnerLogs(t *testing.T) { + var appAddr basics.Address + appAddr[1] = 99 + + params := generated.LookupApplicationLogsByIDParams{} + + testcases := []struct { + name string + appID uint64 + numTxnsWithLogs int + logs []string + }{ + { + name: "match on root with appId 123", + appID: 123, + numTxnsWithLogs: 1, + logs: []string{ + "testing outer appl log", + "appId 123 log", + }, + }, + { + name: "match on inner with appId 789", + appID: 789, + numTxnsWithLogs: 1, + logs: []string{ + "testing inner log", + "appId 789 log", + }, + }, + { + name: "match on inner with appId 222", + appID: 222, + numTxnsWithLogs: 3, // There are 6 logs over 3 transactions + logs: []string{ + "testing multiple logs 1", + "appId 222 log 1", + "testing multiple logs 2", + "appId 222 log 2", + "testing multiple logs 3", + "appId 222 log 3", + }, + }, + } + + db, shutdownFunc := setupIdb(t, test.MakeGenesis(), test.MakeGenesisBlock()) + defer shutdownFunc() + + /////////// + // Given // a DB with some inner txns in it. + /////////// + appCall := test.MakeAppCallWithMultiLogs(test.AccountA) + + block, err := test.MakeBlockForTxns(test.MakeGenesisBlock().BlockHeader, &appCall) + require.NoError(t, err) + + err = db.AddBlock(&block) + require.NoError(t, err, "failed to commit") + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ////////// + // When // we run a query that queries logs based on appID + ////////// + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/v2/applications/:appIdx/logs") + c.SetParamNames("appIdx") + c.SetParamValues(fmt.Sprintf("%d", tc.appID)) + + api := &ServerImplementation{db: db, timeout: 30 * time.Second} + err = api.LookupApplicationLogsByID(c, tc.appID, params) + require.NoError(t, err) + + ////////// + // Then // The result is the log from the app + ////////// + var response generated.ApplicationLogsResponse + require.Equal(t, http.StatusOK, rec.Code) + json.Decode(rec.Body.Bytes(), &response) + require.NoError(t, err) + + require.Equal(t, uint64(tc.appID), response.ApplicationId) + require.NotNil(t, response.LogData) + ld := *response.LogData + require.Equal(t, tc.numTxnsWithLogs, len(ld)) + + logCount := 0 + for txnIndex, result := range ld { + for logIndex, log := range result.Logs { + require.Equal(t, []byte(tc.logs[txnIndex*2+logIndex]), log) + logCount++ + } + } + require.Equal(t, logCount, len(tc.logs)) + }) + } +} diff --git a/util/test/account_testutil.go b/util/test/account_testutil.go index 942417af4..1142eb3c0 100644 --- a/util/test/account_testutil.go +++ b/util/test/account_testutil.go @@ -263,6 +263,33 @@ func MakeAppOptOutTxn(appid uint64, sender basics.Address) transactions.SignedTx } } +// MakeAppCallTxn makes an appl transaction with a NoOp upon completion. +func MakeAppCallTxn(appid uint64, sender basics.Address) transactions.SignedTxnWithAD { + return transactions.SignedTxnWithAD{ + SignedTxn: transactions.SignedTxn{ + Txn: transactions.Transaction{ + Type: "appl", + Header: transactions.Header{ + Sender: sender, + GenesisHash: GenesisHash, + }, + ApplicationCallTxnFields: transactions.ApplicationCallTxnFields{ + ApplicationID: basics.AppIndex(appid), + OnCompletion: transactions.NoOpOC, + }, + }, + Sig: Signature, + }, + } +} + +// MakeAppCallTxnWithLogs makes an appl NoOp transaction with initialized logs. +func MakeAppCallTxnWithLogs(appid uint64, sender basics.Address, logs []string) (txn transactions.SignedTxnWithAD) { + txn = MakeAppCallTxn(appid, sender) + txn.ApplyData.EvalDelta.Logs = logs + return +} + // MakeAppCallWithInnerTxn creates an app call with 3 levels of transactions: // application create // |- payment @@ -324,24 +351,74 @@ func MakeAppCallWithInnerTxn(appSender, paymentSender, paymentReceiver, assetSen }, }, // Inner application call - { - SignedTxn: transactions.SignedTxn{ - Txn: transactions.Transaction{ - Type: protocol.ApplicationCallTx, - Header: transactions.Header{ - Sender: assetSender, - }, - ApplicationCallTxnFields: transactions.ApplicationCallTxnFields{ - ApplicationID: 789, - OnCompletion: transactions.NoOpOC, - }, - }, - }, - }, + MakeAppCallTxn(789, assetSender), + }, + }, + }, + }, + } + + return createApp +} + +// MakeAppCallWithMultiLogs creates an app call that creates multiple logs +// at the same level. +// application create +// |- application call +// |- application call +// |- application call +// |- application call +// |- application call +func MakeAppCallWithMultiLogs(appSender basics.Address) transactions.SignedTxnWithAD { + createApp := MakeCreateAppTxn(appSender) + + // Add a log to the outer appl call + createApp.ApplicationID = 123 + createApp.ApplyData.EvalDelta.Logs = []string{ + "testing outer appl log", + "appId 123 log", + } + + createApp.ApplyData.EvalDelta.InnerTxns = []transactions.SignedTxnWithAD{ + { + SignedTxn: transactions.SignedTxn{ + Txn: transactions.Transaction{ + Type: protocol.ApplicationCallTx, + Header: transactions.Header{ + Sender: appSender, + }, + ApplicationCallTxnFields: transactions.ApplicationCallTxnFields{ + ApplicationID: 789, + OnCompletion: transactions.NoOpOC, + }, + }, + }, + // also add a fake second-level ApplyData to ensure the recursive part works + ApplyData: transactions.ApplyData{ + EvalDelta: transactions.EvalDelta{ + InnerTxns: []transactions.SignedTxnWithAD{ + // Inner application call + MakeAppCallTxn(789, appSender), + }, + Logs: []string{ + "testing inner log", + "appId 789 log", }, }, }, }, + MakeAppCallTxnWithLogs(222, appSender, []string{ + "testing multiple logs 1", + "appId 222 log 1", + }), + MakeAppCallTxnWithLogs(222, appSender, []string{ + "testing multiple logs 2", + "appId 222 log 2", + }), + MakeAppCallTxnWithLogs(222, appSender, []string{ + "testing multiple logs 3", + "appId 222 log 3", + }), } return createApp From def35439d22a7c997128532b87ea4067d4115b72 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 10 Mar 2022 09:43:36 -0500 Subject: [PATCH 21/29] Feature flag to disable configurable api parameters. (#917) --- api/disabled_parameters.go | 3 +++ api/disabled_parameters_test.go | 24 +++++++++-------- cmd/algorand-indexer/daemon.go | 48 +++++++++++++++++++-------------- cmd/algorand-indexer/main.go | 4 ++- 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/api/disabled_parameters.go b/api/disabled_parameters.go index 3769e9708..42fb31426 100644 --- a/api/disabled_parameters.go +++ b/api/disabled_parameters.go @@ -411,6 +411,9 @@ func (dmc *DisabledMapConfig) validate(swag *openapi3.Swagger) error { // MakeDisabledMapFromOA3 Creates a new disabled map from an openapi3 definition func MakeDisabledMapFromOA3(swag *openapi3.Swagger, config *DisabledMapConfig) (*DisabledMap, error) { + if config == nil { + return nil, nil + } err := config.validate(swag) diff --git a/api/disabled_parameters_test.go b/api/disabled_parameters_test.go index 4444b1b24..984ca52d1 100644 --- a/api/disabled_parameters_test.go +++ b/api/disabled_parameters_test.go @@ -77,7 +77,7 @@ func TestSchemaCheck(t *testing.T) { type testingStruct struct { name string ddm *DisplayDisabledMap - expectError string + expectError []string } tests := []testingStruct{ {"test param types - good", @@ -87,7 +87,7 @@ func TestSchemaCheck(t *testing.T) { "optional": {{"p3": "enabled"}}, }}, }, - "", + nil, }, {"test param types - bad required", @@ -97,7 +97,7 @@ func TestSchemaCheck(t *testing.T) { "optional": {{"p3": "enabled"}}, }}, }, - "required-FAKE", + []string{"required-FAKE"}, }, {"test param types - bad optional", @@ -107,7 +107,7 @@ func TestSchemaCheck(t *testing.T) { "optional-FAKE": {{"p3": "enabled"}}, }}, }, - "optional-FAKE", + []string{"optional-FAKE"}, }, {"test param types - bad both", @@ -117,7 +117,7 @@ func TestSchemaCheck(t *testing.T) { "optional-FAKE": {{"p3": "enabled"}}, }}, }, - "required-FAKE optional-FAKE", + []string{"required-FAKE", "optional-FAKE"}, }, {"test param status - good", @@ -127,7 +127,7 @@ func TestSchemaCheck(t *testing.T) { "optional": {{"p3": "enabled"}}, }}, }, - "", + nil, }, {"test param status - bad required", @@ -137,7 +137,7 @@ func TestSchemaCheck(t *testing.T) { "optional": {{"p3": "enabled"}}, }}, }, - "p2", + []string{"p2"}, }, {"test param status - bad optional", @@ -147,7 +147,7 @@ func TestSchemaCheck(t *testing.T) { "optional": {{"p3": "enabled-FAKE"}}, }}, }, - "p3", + []string{"p3"}, }, {"test param status - bad both", @@ -157,7 +157,7 @@ func TestSchemaCheck(t *testing.T) { "optional": {{"p3": "enabled-FAKE"}}, }}, }, - "p1", + []string{"p1"}, }, } @@ -165,9 +165,11 @@ func TestSchemaCheck(t *testing.T) { t.Run(test.name, func(t *testing.T) { err := test.ddm.validateSchema() - if test.expectError != "" { + if len(test.expectError) != 0 { require.Error(t, err) - require.Contains(t, err.Error(), test.expectError) + for _, str := range test.expectError { + require.Contains(t, err.Error(), str) + } } else { require.NoError(t, err) } diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go index 611e2bc3a..c0d63efbd 100644 --- a/cmd/algorand-indexer/daemon.go +++ b/cmd/algorand-indexer/daemon.go @@ -39,6 +39,8 @@ var ( enableAllParameters bool ) +const paramConfigEnableFlag = false + var daemonCmd = &cobra.Command{ Use: "daemon", Short: "run indexer daemon", @@ -140,22 +142,6 @@ var daemonCmd = &cobra.Command{ options := makeOptions() - swag, err := generated.GetSwagger() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to get swagger: %v", err) - os.Exit(1) - } - - if suppliedAPIConfigFile != "" { - logger.Infof("supplied api configuration file located at: %s", suppliedAPIConfigFile) - potentialDisabledMapConfig, err := api.MakeDisabledMapConfigFromFile(swag, suppliedAPIConfigFile) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to created disabled map config from file: %v", err) - os.Exit(1) - } - options.DisabledMapConfig = potentialDisabledMapConfig - } - api.Serve(ctx, daemonServerAddr, db, bot, logger, options) wg.Wait() }, @@ -177,6 +163,10 @@ func init() { daemonCmd.Flags().Uint32VarP(&maxConn, "max-conn", "", 0, "set the maximum connections allowed in the connection pool, if the maximum is reached subsequent connections will wait until a connection becomes available, or timeout according to the read-timeout setting") daemonCmd.Flags().StringVar(&suppliedAPIConfigFile, "api-config-file", "", "supply an API config file to enable/disable parameters") daemonCmd.Flags().BoolVar(&enableAllParameters, "enable-all-parameters", false, "override default configuration and enable all parameters. Can't be used with --api-config-file") + if !paramConfigEnableFlag { + daemonCmd.Flags().MarkHidden("api-config-file") + daemonCmd.Flags().MarkHidden("enable-all-parameters") + } viper.RegisterAlias("algod", "algod-data-dir") viper.RegisterAlias("algod-net", "algod-address") @@ -205,10 +195,28 @@ func makeOptions() (options api.ExtraOptions) { options.WriteTimeout = writeTimeout options.ReadTimeout = readTimeout - if enableAllParameters { - options.DisabledMapConfig = api.MakeDisabledMapConfig() - } else { - options.DisabledMapConfig = api.GetDefaultDisabledMapConfigForPostgres() + if paramConfigEnableFlag { + if enableAllParameters { + options.DisabledMapConfig = api.MakeDisabledMapConfig() + } else { + options.DisabledMapConfig = api.GetDefaultDisabledMapConfigForPostgres() + } + + if suppliedAPIConfigFile != "" { + swag, err := generated.GetSwagger() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to get swagger: %v", err) + os.Exit(1) + } + + logger.Infof("supplied api configuration file located at: %s", suppliedAPIConfigFile) + potentialDisabledMapConfig, err := api.MakeDisabledMapConfigFromFile(swag, suppliedAPIConfigFile) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to created disabled map config from file: %v", err) + os.Exit(1) + } + options.DisabledMapConfig = potentialDisabledMapConfig + } } return diff --git a/cmd/algorand-indexer/main.go b/cmd/algorand-indexer/main.go index 7ca86e459..3dda5591e 100644 --- a/cmd/algorand-indexer/main.go +++ b/cmd/algorand-indexer/main.go @@ -137,7 +137,9 @@ func init() { rootCmd.AddCommand(importCmd) importCmd.Hidden = true rootCmd.AddCommand(daemonCmd) - rootCmd.AddCommand(apiConfigCmd) + if paramConfigEnableFlag { + rootCmd.AddCommand(apiConfigCmd) + } // Not applied globally to avoid adding to utility commands. addFlags := func(cmd *cobra.Command) { From ef46a1ac9d2b7bd673bc04602ae97c115c602ec9 Mon Sep 17 00:00:00 2001 From: AlgoStephenAkiki <85183435+AlgoStephenAkiki@users.noreply.github.com> Date: Thu, 10 Mar 2022 11:55:00 -0500 Subject: [PATCH 22/29] Add CLI doc generation command. (#919) --- cmd/algorand-indexer/main.go | 16 ++++++++++++++-- go.sum | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/algorand-indexer/main.go b/cmd/algorand-indexer/main.go index 3dda5591e..ca4dd9bbe 100644 --- a/cmd/algorand-indexer/main.go +++ b/cmd/algorand-indexer/main.go @@ -7,9 +7,9 @@ import ( "runtime/pprof" "strings" - "github.com/spf13/cobra" - //"github.com/spf13/cobra/doc" // TODO: enable cobra doc generation log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" "github.com/spf13/viper" bg "github.com/algorand/indexer/cmd/block-generator/core" @@ -206,6 +206,18 @@ func configureLogger() error { } func main() { + + // Hidden command to generate docs in a given directory + // algorand-indexer generate-docs [path] + if len(os.Args) == 3 && os.Args[1] == "generate-docs" { + err := doc.GenMarkdownTree(rootCmd, os.Args[2]) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + os.Exit(0) + } + if err := rootCmd.Execute(); err != nil { logger.WithError(err).Error("an error occurred running indexer") os.Exit(1) diff --git a/go.sum b/go.sum index efe5e77d7..1d3384e6d 100644 --- a/go.sum +++ b/go.sum @@ -143,6 +143,7 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man v1.0.8/go.mod h1:N6JayAiVKtlHSnuTCeuLSQVs75hb8q+dYQLjr7cDsKY= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= @@ -633,6 +634,7 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= From b303af10156402521a0c78b9ffb482fd41ed950a Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 10 Mar 2022 12:50:47 -0500 Subject: [PATCH 23/29] Fix dev mode by addressing off by one error. (#920) --- fetcher/fetcher.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index b7da57690..d1e2c7a0d 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -147,7 +147,8 @@ func (bot *fetcherImpl) followLoop(ctx context.Context) error { aclient := bot.Algod() for { for retries := 0; retries < 3; retries++ { - _, err = aclient.StatusAfterBlock(bot.nextRound).Do(ctx) + // nextRound - 1 because the endpoint waits until "StatusAfterBlock" + _, err = aclient.StatusAfterBlock(bot.nextRound - 1).Do(ctx) if err != nil { // If context has expired. if ctx.Err() != nil { From 31eec101eb41137e45c582a9375201df85caa4ba Mon Sep 17 00:00:00 2001 From: chris erway <51567+cce@users.noreply.github.com> Date: Thu, 10 Mar 2022 12:54:47 -0500 Subject: [PATCH 24/29] Add support for unlimited assets. (#900) Co-authored-by: John Lee Co-authored-by: Brice Rising Co-authored-by: Tolik Zinovyev Co-authored-by: Barbara Poon Co-authored-by: Will Winder --- .circleci/config.yml | 2 +- Makefile | 4 +- README.md | 41 +- accounting/eval_preload.go | 202 ++++ api/converter_utils.go | 49 +- api/disabled_parameters.go | 2 +- api/generated/common/routes.go | 300 ++--- api/generated/common/types.go | 40 +- api/generated/v2/routes.go | 716 ++++++++--- api/generated/v2/types.go | 116 +- api/handlers.go | 317 ++++- api/handlers_e2e_test.go | 697 ++++++++++- api/handlers_test.go | 90 +- api/indexer.oas2.json | 295 ++++- api/indexer.oas3.yml | 1068 ++++++++++++++--- api/server.go | 30 + cmd/algorand-indexer/daemon.go | 62 +- cmd/block-generator/generator/generate.go | 1 - cmd/idbtest/idbtest.go | 8 +- cmd/import-validator/README.md | 12 +- cmd/import-validator/core/service.go | 163 ++- idb/dummy/dummy.go | 8 +- idb/idb.go | 47 +- idb/mocks/IndexerDb.go | 31 +- idb/postgres/internal/encoding/encoding.go | 83 +- .../internal/encoding/encoding_test.go | 64 +- idb/postgres/internal/encoding/types.go | 27 + .../ledger_for_evaluator.go | 434 ++++--- .../ledger_for_evaluator_test.go | 615 ++++++---- .../migrations/convert_account_data/m.go | 188 +++ .../migrations/convert_account_data/m_test.go | 270 +++++ .../internal/schema/setup_postgres.sql | 2 +- .../internal/schema/setup_postgres_sql.go | 2 +- idb/postgres/internal/testing/testing.go | 13 + idb/postgres/internal/writer/writer.go | 129 +- idb/postgres/internal/writer/writer_test.go | 241 ++-- idb/postgres/postgres.go | 567 ++++++--- idb/postgres/postgres_integration_test.go | 49 +- idb/postgres/postgres_migrations.go | 53 +- idb/postgres/postgres_migrations_test.go | 34 + idb/postgres/postgres_rand_test.go | 248 +++- misc/Dockerfile | 2 +- misc/parity/reports/algod2indexer_dropped.yml | 3 + misc/parity/reports/algod2indexer_full.yml | 3 + test/common.sh | 1 - third_party/go-algorand | 2 +- 46 files changed, 5648 insertions(+), 1683 deletions(-) create mode 100644 accounting/eval_preload.go create mode 100644 idb/postgres/internal/migrations/convert_account_data/m.go create mode 100644 idb/postgres/internal/migrations/convert_account_data/m_test.go create mode 100644 idb/postgres/postgres_migrations_test.go diff --git a/.circleci/config.yml b/.circleci/config.yml index c4a692dfa..47b2a93d3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -83,7 +83,7 @@ commands: name: Install python and other python dependencies command: | sudo apt update - sudo apt -y install python3 python3-pip python3-setuptools python3-wheel libboost-all-dev libffi-dev + sudo apt -y install python3 python3-pip python3-setuptools python3-wheel libboost-math-dev libffi-dev pip3 install -r misc/requirements.txt - run: diff --git a/Makefile b/Makefile index 92585f6e9..88d0e6aa3 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ GOLDFLAGS += -X github.com/algorand/indexer/version.CompileTime=$(shell date -u GOLDFLAGS += -X github.com/algorand/indexer/version.GitDecorateBase64=$(shell git log -n 1 --pretty="%D"|base64|tr -d ' \n') GOLDFLAGS += -X github.com/algorand/indexer/version.ReleaseVersion=$(shell cat .version) +COVERPKG := $(shell go list ./... | grep -v '/cmd/' | egrep -v '(testing|test|mocks)$$' | paste -s -d, - ) + # Used for e2e test export GO_IMAGE = golang:$(shell go version | cut -d ' ' -f 3 | tail -c +3 ) @@ -45,7 +47,7 @@ fakepackage: go-algorand misc/release.py --host-only --outdir $(PKG_DIR) --fake-release test: idb/mocks/IndexerDb.go cmd/algorand-indexer/algorand-indexer - go test ./... -coverprofile=coverage.txt -covermode=atomic + go test -coverpkg=$(COVERPKG) ./... -coverprofile=coverage.txt -covermode=atomic lint: go-algorand golint -set_exit_status ./... diff --git a/README.md b/README.md index 3ce5ee47e..76ad1941a 100644 --- a/README.md +++ b/README.md @@ -178,20 +178,33 @@ If the maximum number of connections/active queries is reached, subsequent conne Settings can be provided from the command line, a configuration file, or an environment variable -| Command Line Flag (long) | (short) | Config File | Environment Variable | -| ------------------------ | ------- | -------------------------- | ---------------------------------- | -| postgres | P | postgres-connection-string | INDEXER_POSTGRES_CONNECTION_STRING | -| pidfile | | pidfile | INDEXER_PIDFILE | -| algod | d | algod-data-dir | INDEXER_ALGOD_DATA_DIR | -| algod-net | | algod-address | INDEXER_ALGOD_ADDRESS | -| algod-token | | algod-token | INDEXER_ALGOD_TOKEN | -| genesis | g | genesis | INDEXER_GENESIS | -| server | S | server-address | INDEXER_SERVER_ADDRESS | -| no-algod | | no-algod | INDEXER_NO_ALGOD | -| token | t | api-token | INDEXER_API_TOKEN | -| dev-mode | | dev-mode | INDEXER_DEV_MODE | -| metrics-mode | | metrics-mode | INDEXER_METRICS_MODE | -| max-conn | | max-conn | INDEXER_MAX_CONN | +| Command Line Flag (long) | (short) | Config File | Environment Variable | +|-------------------------------|---------|-------------------------------|---------------------------------------| +| postgres | P | postgres-connection-string | INDEXER_POSTGRES_CONNECTION_STRING | +| pidfile | | pidfile | INDEXER_PIDFILE | +| algod | d | algod-data-dir | INDEXER_ALGOD_DATA_DIR | +| algod-net | | algod-address | INDEXER_ALGOD_ADDRESS | +| algod-token | | algod-token | INDEXER_ALGOD_TOKEN | +| genesis | g | genesis | INDEXER_GENESIS | +| server | S | server-address | INDEXER_SERVER_ADDRESS | +| no-algod | | no-algod | INDEXER_NO_ALGOD | +| token | t | api-token | INDEXER_API_TOKEN | +| dev-mode | | dev-mode | INDEXER_DEV_MODE | +| metrics-mode | | metrics-mode | INDEXER_METRICS_MODE | +| max-conn | | max-conn | INDEXER_MAX_CONN | +| write-timeout | | write-timeout | INDEXER_WRITE_TIMEOUT | +| read-timeout | | read-timeout | INDEXER_READ_TIMEOUT | +| max-api-resources-per-account | | max-api-resources-per-account | INDEXER_MAX_API_RESOURCES_PER_ACCOUNT | +| max-transactions-limit | | max-transactions-limit | INDEXER_MAX_TRANSACTIONS_LIMIT | +| default-transactions-limit | | default-transactions-limit | INDEXER_DEFAULT_TRANSACTIONS_LIMIT | +| max-accounts-limit | | max-accounts-limit | INDEXER_MAX_ACCOUNTS_LIMIT | +| default-accounts-limit | | default-accounts-limit | INDEXER_DEFAULT_ACCOUNTS_LIMIT | +| max-assets-limit | | max-assets-limit | INDEXER_MAX_ASSETS_LIMIT | +| default-assets-limit | | default-assets-limit | INDEXER_DEFAULT_ASSETS_LIMIT | +| max-balances-limit | | max-balances-limit | INDEXER_MAX_BALANCES_LIMIT | +| default-balances-limit | | default-balances-limit | INDEXER_DEFAULT_BALANCES_LIMIT | +| max-applications-limit | | max-applications-limit | INDEXER_MAX_APPLICATIONS_LIMIT | +| default-applications-limit | | default-applications-limit | INDEXER_DEFAULT_APPLICATIONS_LIMIT | ## Command line diff --git a/accounting/eval_preload.go b/accounting/eval_preload.go new file mode 100644 index 000000000..72b5269a4 --- /dev/null +++ b/accounting/eval_preload.go @@ -0,0 +1,202 @@ +package accounting + +import ( + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/ledger" + "github.com/algorand/go-algorand/protocol" +) + +// Add requests for asset and app creators to `assetsReq` and `appsReq` for the given +// transaction. +func addToCreatorsRequest(stxnad *transactions.SignedTxnWithAD, assetsReq map[basics.AssetIndex]struct{}, appsReq map[basics.AppIndex]struct{}) { + txn := &stxnad.Txn + + switch txn.Type { + case protocol.AssetConfigTx: + fields := &txn.AssetConfigTxnFields + if fields.ConfigAsset != 0 { + assetsReq[fields.ConfigAsset] = struct{}{} + } + case protocol.AssetTransferTx: + fields := &txn.AssetTransferTxnFields + if fields.XferAsset != 0 { + assetsReq[fields.XferAsset] = struct{}{} + } + case protocol.AssetFreezeTx: + fields := &txn.AssetFreezeTxnFields + if fields.FreezeAsset != 0 { + assetsReq[fields.FreezeAsset] = struct{}{} + } + case protocol.ApplicationCallTx: + fields := &txn.ApplicationCallTxnFields + if fields.ApplicationID != 0 { + appsReq[fields.ApplicationID] = struct{}{} + } + for _, index := range fields.ForeignApps { + appsReq[index] = struct{}{} + } + for _, index := range fields.ForeignAssets { + assetsReq[index] = struct{}{} + } + } + + for i := range stxnad.ApplyData.EvalDelta.InnerTxns { + addToCreatorsRequest(&stxnad.ApplyData.EvalDelta.InnerTxns[i], assetsReq, appsReq) + } +} + +// MakePreloadCreatorsRequest makes a request for preloading creators in the batch mode. +func MakePreloadCreatorsRequest(payset transactions.Payset) (map[basics.AssetIndex]struct{}, map[basics.AppIndex]struct{}) { + assetsReq := make(map[basics.AssetIndex]struct{}, len(payset)) + appsReq := make(map[basics.AppIndex]struct{}, len(payset)) + + for i := range payset { + addToCreatorsRequest(&payset[i].SignedTxnWithAD, assetsReq, appsReq) + } + + return assetsReq, appsReq +} + +// Add requests for account data and account resources to `addressesReq` and +// `resourcesReq` respectively for the given transaction. +func addToAccountsResourcesRequest(stxnad *transactions.SignedTxnWithAD, assetCreators map[basics.AssetIndex]ledger.FoundAddress, appCreators map[basics.AppIndex]ledger.FoundAddress, addressesReq map[basics.Address]struct{}, resourcesReq map[basics.Address]map[ledger.Creatable]struct{}) { + setResourcesReq := func(addr basics.Address, creatable ledger.Creatable) { + c, ok := resourcesReq[addr] + if !ok { + c = make(map[ledger.Creatable]struct{}) + resourcesReq[addr] = c + } + c[creatable] = struct{}{} + } + + txn := &stxnad.Txn + + addressesReq[txn.Sender] = struct{}{} + + switch txn.Type { + case protocol.PaymentTx: + fields := &txn.PaymentTxnFields + addressesReq[fields.Receiver] = struct{}{} + // Close address is optional. + if !fields.CloseRemainderTo.IsZero() { + addressesReq[fields.CloseRemainderTo] = struct{}{} + } + case protocol.AssetConfigTx: + fields := &txn.AssetConfigTxnFields + if fields.ConfigAsset == 0 { + if stxnad.ApplyData.ConfigAsset != 0 { + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(stxnad.ApplyData.ConfigAsset), + Type: basics.AssetCreatable, + } + setResourcesReq(txn.Sender, creatable) + } + } else { + if creator := assetCreators[fields.ConfigAsset]; creator.Exists { + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(fields.ConfigAsset), + Type: basics.AssetCreatable, + } + addressesReq[creator.Address] = struct{}{} + setResourcesReq(creator.Address, creatable) + } + } + case protocol.AssetTransferTx: + fields := &txn.AssetTransferTxnFields + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(fields.XferAsset), + Type: basics.AssetCreatable, + } + if creator := assetCreators[fields.XferAsset]; creator.Exists { + setResourcesReq(creator.Address, creatable) + } + source := txn.Sender + // If asset sender is non-zero, it is a clawback transaction. Otherwise, + // the transaction sender address is used. + if !fields.AssetSender.IsZero() { + source = fields.AssetSender + } + addressesReq[source] = struct{}{} + setResourcesReq(source, creatable) + addressesReq[fields.AssetReceiver] = struct{}{} + setResourcesReq(fields.AssetReceiver, creatable) + // Asset close address is optional. + if !fields.AssetCloseTo.IsZero() { + addressesReq[fields.AssetCloseTo] = struct{}{} + setResourcesReq(fields.AssetCloseTo, creatable) + } + case protocol.AssetFreezeTx: + fields := &txn.AssetFreezeTxnFields + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(fields.FreezeAsset), + Type: basics.AssetCreatable, + } + if creator := assetCreators[fields.FreezeAsset]; creator.Exists { + setResourcesReq(creator.Address, creatable) + } + setResourcesReq(fields.FreezeAccount, creatable) + case protocol.ApplicationCallTx: + fields := &txn.ApplicationCallTxnFields + if fields.ApplicationID == 0 { + if stxnad.ApplyData.ApplicationID != 0 { + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(stxnad.ApplyData.ApplicationID), + Type: basics.AppCreatable, + } + setResourcesReq(txn.Sender, creatable) + } + } else { + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(fields.ApplicationID), + Type: basics.AppCreatable, + } + if creator := appCreators[fields.ApplicationID]; creator.Exists { + addressesReq[creator.Address] = struct{}{} + setResourcesReq(creator.Address, creatable) + } + setResourcesReq(txn.Sender, creatable) + } + for _, address := range fields.Accounts { + addressesReq[address] = struct{}{} + } + for _, index := range fields.ForeignApps { + if creator := appCreators[index]; creator.Exists { + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(index), + Type: basics.AppCreatable, + } + setResourcesReq(creator.Address, creatable) + } + } + for _, index := range fields.ForeignAssets { + if creator := assetCreators[index]; creator.Exists { + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(index), + Type: basics.AssetCreatable, + } + setResourcesReq(creator.Address, creatable) + } + } + } + + for i := range stxnad.ApplyData.EvalDelta.InnerTxns { + addToAccountsResourcesRequest( + &stxnad.ApplyData.EvalDelta.InnerTxns[i], assetCreators, appCreators, + addressesReq, resourcesReq) + } +} + +// MakePreloadAccountsResourcesRequest makes a request for preloading account data and +// account resources in the batch mode. +func MakePreloadAccountsResourcesRequest(payset transactions.Payset, assetCreators map[basics.AssetIndex]ledger.FoundAddress, appCreators map[basics.AppIndex]ledger.FoundAddress) (map[basics.Address]struct{}, map[basics.Address]map[ledger.Creatable]struct{}) { + addressesReq := make(map[basics.Address]struct{}, len(payset)) + resourcesReq := make(map[basics.Address]map[ledger.Creatable]struct{}, len(payset)) + + for i := range payset { + addToAccountsResourcesRequest( + &payset[i].SignedTxnWithAD, assetCreators, appCreators, addressesReq, resourcesReq) + } + + return addressesReq, resourcesReq +} diff --git a/api/converter_utils.go b/api/converter_utils.go index 29bef855e..27451b191 100644 --- a/api/converter_utils.go +++ b/api/converter_utils.go @@ -525,7 +525,7 @@ func signedTxnWithAdToTransaction(stxn *transactions.SignedTxnWithAD, extra rowD return txn, nil } -func assetParamsToAssetQuery(params generated.SearchForAssetsParams) (idb.AssetsQuery, error) { +func (si *ServerImplementation) assetParamsToAssetQuery(params generated.SearchForAssetsParams) (idb.AssetsQuery, error) { creator, errorArr := decodeAddress(params.Creator, "creator", make([]string, 0)) if len(errorArr) != 0 { return idb.AssetsQuery{}, errors.New(errUnableToParseAddress) @@ -548,13 +548,37 @@ func assetParamsToAssetQuery(params generated.SearchForAssetsParams) (idb.Assets Unit: strOrDefault(params.Unit), Query: "", IncludeDeleted: boolOrDefault(params.IncludeAll), - Limit: min(uintOrDefaultValue(params.Limit, defaultAssetsLimit), maxAssetsLimit), + Limit: min(uintOrDefaultValue(params.Limit, si.opts.DefaultAssetsLimit), si.opts.MaxAssetsLimit), } return query, nil } -func transactionParamsToTransactionFilter(params generated.SearchForTransactionsParams) (filter idb.TransactionFilter, err error) { +func (si *ServerImplementation) appParamsToApplicationQuery(params generated.SearchForApplicationsParams) (idb.ApplicationQuery, error) { + addr, errorArr := decodeAddress(params.Creator, "creator", make([]string, 0)) + if len(errorArr) != 0 { + return idb.ApplicationQuery{}, errors.New(errUnableToParseAddress) + } + + var appGreaterThan uint64 = 0 + if params.Next != nil { + agt, err := strconv.ParseUint(*params.Next, 10, 64) + if err != nil { + return idb.ApplicationQuery{}, fmt.Errorf("%s: %v", errUnableToParseNext, err) + } + appGreaterThan = agt + } + + return idb.ApplicationQuery{ + ApplicationID: uintOrDefault(params.ApplicationId), + ApplicationIDGreaterThan: appGreaterThan, + Address: addr, + IncludeDeleted: boolOrDefault(params.IncludeAll), + Limit: min(uintOrDefaultValue(params.Limit, si.opts.DefaultApplicationsLimit), si.opts.MaxApplicationsLimit), + }, nil +} + +func (si *ServerImplementation) transactionParamsToTransactionFilter(params generated.SearchForTransactionsParams) (filter idb.TransactionFilter, err error) { var errorArr = make([]string, 0) // Integer @@ -562,7 +586,7 @@ func transactionParamsToTransactionFilter(params generated.SearchForTransactions filter.MinRound = uintOrDefault(params.MinRound) filter.AssetID = uintOrDefault(params.AssetId) filter.ApplicationID = uintOrDefault(params.ApplicationId) - filter.Limit = min(uintOrDefaultValue(params.Limit, defaultTransactionsLimit), maxTransactionsLimit) + filter.Limit = min(uintOrDefaultValue(params.Limit, si.opts.DefaultTransactionsLimit), si.opts.MaxTransactionsLimit) // filter Algos or Asset but not both. if filter.AssetID != 0 { @@ -610,3 +634,20 @@ func transactionParamsToTransactionFilter(params generated.SearchForTransactions return } + +func (si *ServerImplementation) maxAccountsErrorToAccountsErrorResponse(maxErr idb.MaxAPIResourcesPerAccountError) generated.ErrorResponse { + addr := maxErr.Address.String() + max := uint64(si.opts.MaxAPIResourcesPerAccount) + extraData := map[string]interface{}{ + "max-results": max, + "address": addr, + "total-assets-opted-in": maxErr.TotalAssets, + "total-created-assets": maxErr.TotalAssetParams, + "total-apps-opted-in": maxErr.TotalAppLocalStates, + "total-created-apps": maxErr.TotalAppParams, + } + return generated.ErrorResponse{ + Message: "Result limit exceeded", + Data: &extraData, + } +} diff --git a/api/disabled_parameters.go b/api/disabled_parameters.go index 42fb31426..a5a385560 100644 --- a/api/disabled_parameters.go +++ b/api/disabled_parameters.go @@ -320,7 +320,7 @@ func GetDefaultDisabledMapConfigForPostgres() *DisabledMapConfig { get("/v2/accounts", []string{"currency-greater-than", "currency-less-than"}) get("/v2/accounts/{account-id}/transactions", []string{"note-prefix", "tx-type", "sig-type", "asset-id", "before-time", "after-time", "rekey-to"}) get("/v2/assets", []string{"name", "unit"}) - get("/v2/assets/{asset-id}/balances", []string{"round", "currency-greater-than", "currency-less-than"}) + get("/v2/assets/{asset-id}/balances", []string{"currency-greater-than", "currency-less-than"}) get("/v2/transactions", []string{"note-prefix", "tx-type", "sig-type", "asset-id", "before-time", "after-time", "currency-greater-than", "currency-less-than", "address-role", "exclude-close-to", "rekey-to", "application-id"}) get("/v2/assets/{asset-id}/transactions", []string{"note-prefix", "tx-type", "sig-type", "asset-id", "before-time", "after-time", "currency-greater-than", "currency-less-than", "address-role", "exclude-close-to", "rekey-to"}) diff --git a/api/generated/common/routes.go b/api/generated/common/routes.go index 68471ddfc..f5a02a12e 100644 --- a/api/generated/common/routes.go +++ b/api/generated/common/routes.go @@ -71,154 +71,158 @@ func RegisterHandlers(router interface { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "AP//Ltl59z6zAAA=", + "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", } // GetSwagger returns the Swagger specification corresponding to the generated code diff --git a/api/generated/common/types.go b/api/generated/common/types.go index fa13b5bff..7216e86ac 100644 --- a/api/generated/common/types.go +++ b/api/generated/common/types.go @@ -84,6 +84,18 @@ type Account struct { // * Online - indicates that the associated account used as part of the delegation pool. // * NotParticipating - indicates that the associated account is neither a delegator nor a delegate. Status string `json:"status"` + + // The count of all applications that have been opted in, equivalent to the count of application local data (AppLocalState objects) stored in this account. + TotalAppsOptedIn uint64 `json:"total-apps-opted-in"` + + // The count of all assets that have been opted in, equivalent to the count of AssetHolding objects held by this account. + TotalAssetsOptedIn uint64 `json:"total-assets-opted-in"` + + // The count of all apps (AppParams objects) created by this account. + TotalCreatedApps uint64 `json:"total-created-apps"` + + // The count of all assets (AssetParams objects) created by this account. + TotalCreatedAssets uint64 `json:"total-created-assets"` } // AccountParticipation defines model for AccountParticipation. @@ -235,9 +247,6 @@ type AssetHolding struct { // Asset ID of the holding. AssetId uint64 `json:"asset-id"` - // Address that created this asset. This is the address where the parameters for this asset can be found, and also the address where unwanted asset units can be sent in the worst case. - Creator string `json:"creator"` - // Whether or not the asset holding is currently deleted from its account. Deleted *bool `json:"deleted,omitempty"` @@ -847,6 +856,9 @@ type CurrencyGreaterThan uint64 // CurrencyLessThan defines model for currency-less-than. type CurrencyLessThan uint64 +// Exclude defines model for exclude. +type Exclude []string + // ExcludeCloseTo defines model for exclude-close-to. type ExcludeCloseTo bool @@ -913,6 +925,17 @@ type AccountsResponse struct { NextToken *string `json:"next-token,omitempty"` } +// ApplicationLocalStatesResponse defines model for ApplicationLocalStatesResponse. +type ApplicationLocalStatesResponse struct { + AppsLocalStates []ApplicationLocalState `json:"apps-local-states"` + + // Round at which the results were computed. + CurrentRound uint64 `json:"current-round"` + + // Used for pagination, when making another request provide this token with the next parameter. + NextToken *string `json:"next-token,omitempty"` +} + // ApplicationLogsResponse defines model for ApplicationLogsResponse. type ApplicationLogsResponse struct { @@ -959,6 +982,17 @@ type AssetBalancesResponse struct { NextToken *string `json:"next-token,omitempty"` } +// AssetHoldingsResponse defines model for AssetHoldingsResponse. +type AssetHoldingsResponse struct { + Assets []AssetHolding `json:"assets"` + + // Round at which the results were computed. + CurrentRound uint64 `json:"current-round"` + + // Used for pagination, when making another request provide this token with the next parameter. + NextToken *string `json:"next-token,omitempty"` +} + // AssetResponse defines model for AssetResponse. type AssetResponse struct { diff --git a/api/generated/v2/routes.go b/api/generated/v2/routes.go index b8fa5c63e..86e16bfb0 100644 --- a/api/generated/v2/routes.go +++ b/api/generated/v2/routes.go @@ -24,6 +24,18 @@ type ServerInterface interface { // (GET /v2/accounts/{account-id}) LookupAccountByID(ctx echo.Context, accountId string, params LookupAccountByIDParams) error + // (GET /v2/accounts/{account-id}/apps-local-state) + LookupAccountAppLocalStates(ctx echo.Context, accountId string, params LookupAccountAppLocalStatesParams) error + + // (GET /v2/accounts/{account-id}/assets) + LookupAccountAssets(ctx echo.Context, accountId string, params LookupAccountAssetsParams) error + + // (GET /v2/accounts/{account-id}/created-applications) + LookupAccountCreatedApplications(ctx echo.Context, accountId string, params LookupAccountCreatedApplicationsParams) error + + // (GET /v2/accounts/{account-id}/created-assets) + LookupAccountCreatedAssets(ctx echo.Context, accountId string, params LookupAccountCreatedAssetsParams) error + // (GET /v2/accounts/{account-id}/transactions) LookupAccountTransactions(ctx echo.Context, accountId string, params LookupAccountTransactionsParams) error @@ -73,6 +85,7 @@ func (w *ServerInterfaceWrapper) SearchForAccounts(ctx echo.Context) error { "next": true, "currency-greater-than": true, "include-all": true, + "exclude": true, "currency-less-than": true, "auth-addr": true, "round": true, @@ -140,6 +153,16 @@ func (w *ServerInterfaceWrapper) SearchForAccounts(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter include-all: %s", err)) } + // ------------- Optional query parameter "exclude" ------------- + if paramValue := ctx.QueryParam("exclude"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", false, false, "exclude", ctx.QueryParams(), ¶ms.Exclude) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter exclude: %s", err)) + } + // ------------- Optional query parameter "currency-less-than" ------------- if paramValue := ctx.QueryParam("currency-less-than"); paramValue != "" { @@ -192,6 +215,7 @@ func (w *ServerInterfaceWrapper) LookupAccountByID(ctx echo.Context) error { "pretty": true, "round": true, "include-all": true, + "exclude": true, } // Check for unknown query parameters. @@ -232,11 +256,317 @@ func (w *ServerInterfaceWrapper) LookupAccountByID(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter include-all: %s", err)) } + // ------------- Optional query parameter "exclude" ------------- + if paramValue := ctx.QueryParam("exclude"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", false, false, "exclude", ctx.QueryParams(), ¶ms.Exclude) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter exclude: %s", err)) + } + // Invoke the callback with all the unmarshalled arguments err = w.Handler.LookupAccountByID(ctx, accountId, params) return err } +// LookupAccountAppLocalStates converts echo context to params. +func (w *ServerInterfaceWrapper) LookupAccountAppLocalStates(ctx echo.Context) error { + + validQueryParams := map[string]bool{ + "pretty": true, + "application-id": true, + "include-all": true, + "limit": true, + "next": true, + } + + // Check for unknown query parameters. + for name, _ := range ctx.QueryParams() { + if _, ok := validQueryParams[name]; !ok { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unknown parameter detected: %s", name)) + } + } + + var err error + // ------------- Path parameter "account-id" ------------- + var accountId string + + err = runtime.BindStyledParameter("simple", false, "account-id", ctx.Param("account-id"), &accountId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter account-id: %s", err)) + } + + // Parameter object where we will unmarshal all parameters from the context + var params LookupAccountAppLocalStatesParams + // ------------- Optional query parameter "application-id" ------------- + if paramValue := ctx.QueryParam("application-id"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "application-id", ctx.QueryParams(), ¶ms.ApplicationId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter application-id: %s", err)) + } + + // ------------- Optional query parameter "include-all" ------------- + if paramValue := ctx.QueryParam("include-all"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "include-all", ctx.QueryParams(), ¶ms.IncludeAll) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter include-all: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + if paramValue := ctx.QueryParam("limit"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "next" ------------- + if paramValue := ctx.QueryParam("next"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "next", ctx.QueryParams(), ¶ms.Next) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter next: %s", err)) + } + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.LookupAccountAppLocalStates(ctx, accountId, params) + return err +} + +// LookupAccountAssets converts echo context to params. +func (w *ServerInterfaceWrapper) LookupAccountAssets(ctx echo.Context) error { + + validQueryParams := map[string]bool{ + "pretty": true, + "asset-id": true, + "include-all": true, + "limit": true, + "next": true, + } + + // Check for unknown query parameters. + for name, _ := range ctx.QueryParams() { + if _, ok := validQueryParams[name]; !ok { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unknown parameter detected: %s", name)) + } + } + + var err error + // ------------- Path parameter "account-id" ------------- + var accountId string + + err = runtime.BindStyledParameter("simple", false, "account-id", ctx.Param("account-id"), &accountId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter account-id: %s", err)) + } + + // Parameter object where we will unmarshal all parameters from the context + var params LookupAccountAssetsParams + // ------------- Optional query parameter "asset-id" ------------- + if paramValue := ctx.QueryParam("asset-id"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "asset-id", ctx.QueryParams(), ¶ms.AssetId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter asset-id: %s", err)) + } + + // ------------- Optional query parameter "include-all" ------------- + if paramValue := ctx.QueryParam("include-all"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "include-all", ctx.QueryParams(), ¶ms.IncludeAll) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter include-all: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + if paramValue := ctx.QueryParam("limit"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "next" ------------- + if paramValue := ctx.QueryParam("next"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "next", ctx.QueryParams(), ¶ms.Next) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter next: %s", err)) + } + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.LookupAccountAssets(ctx, accountId, params) + return err +} + +// LookupAccountCreatedApplications converts echo context to params. +func (w *ServerInterfaceWrapper) LookupAccountCreatedApplications(ctx echo.Context) error { + + validQueryParams := map[string]bool{ + "pretty": true, + "application-id": true, + "include-all": true, + "limit": true, + "next": true, + } + + // Check for unknown query parameters. + for name, _ := range ctx.QueryParams() { + if _, ok := validQueryParams[name]; !ok { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unknown parameter detected: %s", name)) + } + } + + var err error + // ------------- Path parameter "account-id" ------------- + var accountId string + + err = runtime.BindStyledParameter("simple", false, "account-id", ctx.Param("account-id"), &accountId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter account-id: %s", err)) + } + + // Parameter object where we will unmarshal all parameters from the context + var params LookupAccountCreatedApplicationsParams + // ------------- Optional query parameter "application-id" ------------- + if paramValue := ctx.QueryParam("application-id"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "application-id", ctx.QueryParams(), ¶ms.ApplicationId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter application-id: %s", err)) + } + + // ------------- Optional query parameter "include-all" ------------- + if paramValue := ctx.QueryParam("include-all"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "include-all", ctx.QueryParams(), ¶ms.IncludeAll) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter include-all: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + if paramValue := ctx.QueryParam("limit"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "next" ------------- + if paramValue := ctx.QueryParam("next"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "next", ctx.QueryParams(), ¶ms.Next) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter next: %s", err)) + } + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.LookupAccountCreatedApplications(ctx, accountId, params) + return err +} + +// LookupAccountCreatedAssets converts echo context to params. +func (w *ServerInterfaceWrapper) LookupAccountCreatedAssets(ctx echo.Context) error { + + validQueryParams := map[string]bool{ + "pretty": true, + "asset-id": true, + "include-all": true, + "limit": true, + "next": true, + } + + // Check for unknown query parameters. + for name, _ := range ctx.QueryParams() { + if _, ok := validQueryParams[name]; !ok { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unknown parameter detected: %s", name)) + } + } + + var err error + // ------------- Path parameter "account-id" ------------- + var accountId string + + err = runtime.BindStyledParameter("simple", false, "account-id", ctx.Param("account-id"), &accountId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter account-id: %s", err)) + } + + // Parameter object where we will unmarshal all parameters from the context + var params LookupAccountCreatedAssetsParams + // ------------- Optional query parameter "asset-id" ------------- + if paramValue := ctx.QueryParam("asset-id"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "asset-id", ctx.QueryParams(), ¶ms.AssetId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter asset-id: %s", err)) + } + + // ------------- Optional query parameter "include-all" ------------- + if paramValue := ctx.QueryParam("include-all"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "include-all", ctx.QueryParams(), ¶ms.IncludeAll) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter include-all: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + if paramValue := ctx.QueryParam("limit"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "next" ------------- + if paramValue := ctx.QueryParam("next"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "next", ctx.QueryParams(), ¶ms.Next) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter next: %s", err)) + } + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.LookupAccountCreatedAssets(ctx, accountId, params) + return err +} + // LookupAccountTransactions converts echo context to params. func (w *ServerInterfaceWrapper) LookupAccountTransactions(ctx echo.Context) error { @@ -438,6 +768,7 @@ func (w *ServerInterfaceWrapper) SearchForApplications(ctx echo.Context) error { validQueryParams := map[string]bool{ "pretty": true, "application-id": true, + "creator": true, "include-all": true, "limit": true, "next": true, @@ -464,6 +795,16 @@ func (w *ServerInterfaceWrapper) SearchForApplications(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter application-id: %s", err)) } + // ------------- Optional query parameter "creator" ------------- + if paramValue := ctx.QueryParam("creator"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "creator", ctx.QueryParams(), ¶ms.Creator) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter creator: %s", err)) + } + // ------------- Optional query parameter "include-all" ------------- if paramValue := ctx.QueryParam("include-all"); paramValue != "" { @@ -785,7 +1126,6 @@ func (w *ServerInterfaceWrapper) LookupAssetBalances(ctx echo.Context) error { "include-all": true, "limit": true, "next": true, - "round": true, "currency-greater-than": true, "currency-less-than": true, } @@ -838,16 +1178,6 @@ func (w *ServerInterfaceWrapper) LookupAssetBalances(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter next: %s", err)) } - // ------------- Optional query parameter "round" ------------- - if paramValue := ctx.QueryParam("round"); paramValue != "" { - - } - - err = runtime.BindQueryParameter("form", true, false, "round", ctx.QueryParams(), ¶ms.Round) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter round: %s", err)) - } - // ------------- Optional query parameter "currency-greater-than" ------------- if paramValue := ctx.QueryParam("currency-greater-than"); paramValue != "" { @@ -1397,6 +1727,10 @@ func RegisterHandlers(router interface { router.GET("/v2/accounts", wrapper.SearchForAccounts, m...) router.GET("/v2/accounts/:account-id", wrapper.LookupAccountByID, m...) + router.GET("/v2/accounts/:account-id/apps-local-state", wrapper.LookupAccountAppLocalStates, m...) + router.GET("/v2/accounts/:account-id/assets", wrapper.LookupAccountAssets, m...) + router.GET("/v2/accounts/:account-id/created-applications", wrapper.LookupAccountCreatedApplications, m...) + router.GET("/v2/accounts/:account-id/created-assets", wrapper.LookupAccountCreatedAssets, m...) router.GET("/v2/accounts/:account-id/transactions", wrapper.LookupAccountTransactions, m...) router.GET("/v2/applications", wrapper.SearchForApplications, m...) router.GET("/v2/applications/:application-id", wrapper.LookupApplicationByID, m...) @@ -1414,181 +1748,191 @@ func RegisterHandlers(router interface { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "n3769D8BAAD//9X7UAtdHwEA", + "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", + "Y+VJAQA=", } // GetSwagger returns the Swagger specification corresponding to the generated code diff --git a/api/generated/v2/types.go b/api/generated/v2/types.go index 6b6a120fe..298972d96 100644 --- a/api/generated/v2/types.go +++ b/api/generated/v2/types.go @@ -84,6 +84,18 @@ type Account struct { // * Online - indicates that the associated account used as part of the delegation pool. // * NotParticipating - indicates that the associated account is neither a delegator nor a delegate. Status string `json:"status"` + + // The count of all applications that have been opted in, equivalent to the count of application local data (AppLocalState objects) stored in this account. + TotalAppsOptedIn uint64 `json:"total-apps-opted-in"` + + // The count of all assets that have been opted in, equivalent to the count of AssetHolding objects held by this account. + TotalAssetsOptedIn uint64 `json:"total-assets-opted-in"` + + // The count of all apps (AppParams objects) created by this account. + TotalCreatedApps uint64 `json:"total-created-apps"` + + // The count of all assets (AssetParams objects) created by this account. + TotalCreatedAssets uint64 `json:"total-created-assets"` } // AccountParticipation defines model for AccountParticipation. @@ -235,9 +247,6 @@ type AssetHolding struct { // Asset ID of the holding. AssetId uint64 `json:"asset-id"` - // Address that created this asset. This is the address where the parameters for this asset can be found, and also the address where unwanted asset units can be sent in the worst case. - Creator string `json:"creator"` - // Whether or not the asset holding is currently deleted from its account. Deleted *bool `json:"deleted,omitempty"` @@ -847,6 +856,9 @@ type CurrencyGreaterThan uint64 // CurrencyLessThan defines model for currency-less-than. type CurrencyLessThan uint64 +// Exclude defines model for exclude. +type Exclude []string + // ExcludeCloseTo defines model for exclude-close-to. type ExcludeCloseTo bool @@ -913,6 +925,17 @@ type AccountsResponse struct { NextToken *string `json:"next-token,omitempty"` } +// ApplicationLocalStatesResponse defines model for ApplicationLocalStatesResponse. +type ApplicationLocalStatesResponse struct { + AppsLocalStates []ApplicationLocalState `json:"apps-local-states"` + + // Round at which the results were computed. + CurrentRound uint64 `json:"current-round"` + + // Used for pagination, when making another request provide this token with the next parameter. + NextToken *string `json:"next-token,omitempty"` +} + // ApplicationLogsResponse defines model for ApplicationLogsResponse. type ApplicationLogsResponse struct { @@ -959,6 +982,17 @@ type AssetBalancesResponse struct { NextToken *string `json:"next-token,omitempty"` } +// AssetHoldingsResponse defines model for AssetHoldingsResponse. +type AssetHoldingsResponse struct { + Assets []AssetHolding `json:"assets"` + + // Round at which the results were computed. + CurrentRound uint64 `json:"current-round"` + + // Used for pagination, when making another request provide this token with the next parameter. + NextToken *string `json:"next-token,omitempty"` +} + // AssetResponse defines model for AssetResponse. type AssetResponse struct { @@ -1035,6 +1069,9 @@ type SearchForAccountsParams struct { // Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates. IncludeAll *bool `json:"include-all,omitempty"` + // Exclude additional items such as asset holdings, application local data stored for this account, asset parameters created by this account, and application parameters created by this account. + Exclude *[]string `json:"exclude,omitempty"` + // Results should have an amount less than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used. CurrencyLessThan *uint64 `json:"currency-less-than,omitempty"` @@ -1056,6 +1093,73 @@ type LookupAccountByIDParams struct { // Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates. IncludeAll *bool `json:"include-all,omitempty"` + + // Exclude additional items such as asset holdings, application local data stored for this account, asset parameters created by this account, and application parameters created by this account. + Exclude *[]string `json:"exclude,omitempty"` +} + +// LookupAccountAppLocalStatesParams defines parameters for LookupAccountAppLocalStates. +type LookupAccountAppLocalStatesParams struct { + + // Application ID + ApplicationId *uint64 `json:"application-id,omitempty"` + + // Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates. + IncludeAll *bool `json:"include-all,omitempty"` + + // Maximum number of results to return. There could be additional pages even if the limit is not reached. + Limit *uint64 `json:"limit,omitempty"` + + // The next page of results. Use the next token provided by the previous results. + Next *string `json:"next,omitempty"` +} + +// LookupAccountAssetsParams defines parameters for LookupAccountAssets. +type LookupAccountAssetsParams struct { + + // Asset ID + AssetId *uint64 `json:"asset-id,omitempty"` + + // Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates. + IncludeAll *bool `json:"include-all,omitempty"` + + // Maximum number of results to return. There could be additional pages even if the limit is not reached. + Limit *uint64 `json:"limit,omitempty"` + + // The next page of results. Use the next token provided by the previous results. + Next *string `json:"next,omitempty"` +} + +// LookupAccountCreatedApplicationsParams defines parameters for LookupAccountCreatedApplications. +type LookupAccountCreatedApplicationsParams struct { + + // Application ID + ApplicationId *uint64 `json:"application-id,omitempty"` + + // Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates. + IncludeAll *bool `json:"include-all,omitempty"` + + // Maximum number of results to return. There could be additional pages even if the limit is not reached. + Limit *uint64 `json:"limit,omitempty"` + + // The next page of results. Use the next token provided by the previous results. + Next *string `json:"next,omitempty"` +} + +// LookupAccountCreatedAssetsParams defines parameters for LookupAccountCreatedAssets. +type LookupAccountCreatedAssetsParams struct { + + // Asset ID + AssetId *uint64 `json:"asset-id,omitempty"` + + // Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates. + IncludeAll *bool `json:"include-all,omitempty"` + + // Maximum number of results to return. There could be additional pages even if the limit is not reached. + Limit *uint64 `json:"limit,omitempty"` + + // The next page of results. Use the next token provided by the previous results. + Next *string `json:"next,omitempty"` } // LookupAccountTransactionsParams defines parameters for LookupAccountTransactions. @@ -1114,6 +1218,9 @@ type SearchForApplicationsParams struct { // Application ID ApplicationId *uint64 `json:"application-id,omitempty"` + // Filter just applications with the given creator address. + Creator *string `json:"creator,omitempty"` + // Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates. IncludeAll *bool `json:"include-all,omitempty"` @@ -1197,9 +1304,6 @@ type LookupAssetBalancesParams struct { // The next page of results. Use the next token provided by the previous results. Next *string `json:"next,omitempty"` - // Include results for the specified round. - Round *uint64 `json:"round,omitempty"` - // Results should have an amount greater than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used. CurrencyGreaterThan *uint64 `json:"currency-greater-than,omitempty"` diff --git a/api/handlers.go b/api/handlers.go index 481a78ef8..96253674c 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -40,31 +40,9 @@ type ServerImplementation struct { log *log.Logger disabledParams *DisabledMap -} - -///////////////////// -// Limit Constants // -///////////////////// - -// Transactions -const maxTransactionsLimit = 10000 -const defaultTransactionsLimit = 1000 - -// Accounts -const maxAccountsLimit = 1000 -const defaultAccountsLimit = 100 - -// Assets -const maxAssetsLimit = 1000 -const defaultAssetsLimit = 100 -// Asset Balances -const maxBalancesLimit = 10000 -const defaultBalancesLimit = 1000 - -// Applications -const maxApplicationsLimit = 1000 -const defaultApplicationsLimit = 100 + opts ExtraOptions +} ////////////////////// // Helper functions // @@ -147,6 +125,33 @@ func (si *ServerImplementation) MakeHealthCheck(ctx echo.Context) error { }) } +var errInvalidExcludeParameter = errors.New("invalid exclude argument") + +// set query options based on the value of the "exclude" parameter +func setExcludeQueryOptions(exclude []string, opts *idb.AccountQueryOptions) error { + for _, e := range exclude { + switch e { + case "all": + opts.IncludeAssetHoldings = false + opts.IncludeAssetParams = false + opts.IncludeAppLocalState = false + opts.IncludeAppParams = false + case "assets": + opts.IncludeAssetHoldings = false + case "created-assets": + opts.IncludeAssetParams = false + case "apps-local-state": + opts.IncludeAppLocalState = false + case "created-apps": + opts.IncludeAppParams = false + case "none": + default: + return fmt.Errorf(`unknown value "%s": %w`, e, errInvalidExcludeParameter) + } + } + return nil +} + func (si *ServerImplementation) verifyHandler(operationID string, ctx echo.Context) error { return Verify(si.disabledParams, operationID, ctx, si.log) } @@ -158,21 +163,35 @@ func (si *ServerImplementation) LookupAccountByID(ctx echo.Context, accountID st return badRequest(ctx, err.Error()) } - addr, errors := decodeAddress(&accountID, "account-id", make([]string, 0)) - if len(errors) != 0 { - return badRequest(ctx, errors[0]) + addr, decodeErrors := decodeAddress(&accountID, "account-id", make([]string, 0)) + if len(decodeErrors) != 0 { + return badRequest(ctx, decodeErrors[0]) } options := idb.AccountQueryOptions{ EqualToAddress: addr[:], IncludeAssetHoldings: true, IncludeAssetParams: true, + IncludeAppLocalState: true, + IncludeAppParams: true, Limit: 1, IncludeDeleted: boolOrDefault(params.IncludeAll), + MaxResources: uint64(si.opts.MaxAPIResourcesPerAccount), + } + + if params.Exclude != nil { + err := setExcludeQueryOptions(*params.Exclude, &options) + if err != nil { + return badRequest(ctx, err.Error()) + } } accounts, round, err := si.fetchAccounts(ctx.Request().Context(), options, params.Round) if err != nil { + var maxErr idb.MaxAPIResourcesPerAccountError + if errors.As(err, &maxErr) { + return ctx.JSON(http.StatusBadRequest, si.maxAccountsErrorToAccountsErrorResponse(maxErr)) + } return indexerError(ctx, fmt.Errorf("%s: %w", errFailedSearchingAccount, err)) } @@ -190,6 +209,123 @@ func (si *ServerImplementation) LookupAccountByID(ctx echo.Context, accountID st }) } +// LookupAccountAppLocalStates queries indexer for AppLocalState for a given account, and optionally a given app ID. +// (GET /v2/accounts/{account-id}/apps-local-state) +func (si *ServerImplementation) LookupAccountAppLocalStates(ctx echo.Context, accountID string, params generated.LookupAccountAppLocalStatesParams) error { + if err := si.verifyHandler("LookupAccountAppLocalStates", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + + search := generated.SearchForApplicationsParams{ + Creator: &accountID, + ApplicationId: params.ApplicationId, + IncludeAll: params.IncludeAll, + Limit: params.Limit, + Next: params.Next, + } + options, err := si.appParamsToApplicationQuery(search) + if err != nil { + return badRequest(ctx, err.Error()) + } + + apps, round, err := si.fetchAppLocalStates(ctx.Request().Context(), options) + if err != nil { + return indexerError(ctx, fmt.Errorf("%s: %w", errFailedSearchingApplication, err)) + } + + var next *string + if len(apps) > 0 { + next = strPtr(strconv.FormatUint(apps[len(apps)-1].Id, 10)) + } + + out := generated.ApplicationLocalStatesResponse{ + AppsLocalStates: apps, + CurrentRound: round, + NextToken: next, + } + return ctx.JSON(http.StatusOK, out) +} + +// LookupAccountAssets queries indexer for AssetHolding for a given account, and optionally a given asset ID. +// (GET /v2/accounts/{account-id}/assets) +func (si *ServerImplementation) LookupAccountAssets(ctx echo.Context, accountID string, params generated.LookupAccountAssetsParams) error { + if err := si.verifyHandler("LookupAccountAssets", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + + addr, errors := decodeAddress(&accountID, "account-id", make([]string, 0)) + if len(errors) != 0 { + return badRequest(ctx, errors[0]) + } + + var assetGreaterThan uint64 = 0 + if params.Next != nil { + agt, err := strconv.ParseUint(*params.Next, 10, 64) + if err != nil { + return badRequest(ctx, fmt.Sprintf("%s: %v", errUnableToParseNext, err)) + } + assetGreaterThan = agt + } + + query := idb.AssetBalanceQuery{ + Address: addr, + AssetID: uintOrDefault(params.AssetId), + AssetIDGT: assetGreaterThan, + IncludeDeleted: boolOrDefault(params.IncludeAll), + Limit: min(uintOrDefaultValue(params.Limit, si.opts.DefaultBalancesLimit), si.opts.MaxBalancesLimit), + } + + assets, round, err := si.fetchAssetHoldings(ctx.Request().Context(), query) + if err != nil { + return indexerError(ctx, fmt.Errorf("%s: %w", errFailedSearchingAssetBalances, err)) + } + + var next *string + if len(assets) > 0 { + next = strPtr(strconv.FormatUint(assets[len(assets)-1].AssetId, 10)) + } + + return ctx.JSON(http.StatusOK, generated.AssetHoldingsResponse{ + CurrentRound: round, + NextToken: next, + Assets: assets, + }) +} + +// LookupAccountCreatedApplications queries indexer for AppParams for a given account, and optionally a given app ID. +// (GET /v2/accounts/{account-id}/created-applications) +func (si *ServerImplementation) LookupAccountCreatedApplications(ctx echo.Context, accountID string, params generated.LookupAccountCreatedApplicationsParams) error { + if err := si.verifyHandler("LookupAccountCreatedApplications", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + + search := generated.SearchForApplicationsParams{ + Creator: &accountID, + ApplicationId: params.ApplicationId, + IncludeAll: params.IncludeAll, + Limit: params.Limit, + Next: params.Next, + } + return si.SearchForApplications(ctx, search) +} + +// LookupAccountCreatedAssets queries indexer for AssetParams for a given account, and optionally a given asset ID. +// (GET /v2/accounts/{account-id}/created-assets) +func (si *ServerImplementation) LookupAccountCreatedAssets(ctx echo.Context, accountID string, params generated.LookupAccountCreatedAssetsParams) error { + if err := si.verifyHandler("LookupAccountCreatedAssets", ctx); err != nil { + return badRequest(ctx, err.Error()) + } + + search := generated.SearchForAssetsParams{ + Creator: &accountID, + AssetId: params.AssetId, + IncludeAll: params.IncludeAll, + Limit: params.Limit, + Next: params.Next, + } + return si.SearchForAssets(ctx, search) +} + // SearchForAccounts returns accounts matching the provided parameters // (GET /v2/accounts) func (si *ServerImplementation) SearchForAccounts(ctx echo.Context, params generated.SearchForAccountsParams) error { @@ -201,19 +337,29 @@ func (si *ServerImplementation) SearchForAccounts(ctx echo.Context, params gener return badRequest(ctx, errMultiAcctRewind) } - spendingAddr, errors := decodeAddress(params.AuthAddr, "account-id", make([]string, 0)) - if len(errors) != 0 { - return badRequest(ctx, errors[0]) + spendingAddr, decodeErrors := decodeAddress(params.AuthAddr, "account-id", make([]string, 0)) + if len(decodeErrors) != 0 { + return badRequest(ctx, decodeErrors[0]) } options := idb.AccountQueryOptions{ IncludeAssetHoldings: true, IncludeAssetParams: true, - Limit: min(uintOrDefaultValue(params.Limit, defaultAccountsLimit), maxAccountsLimit), + IncludeAppLocalState: true, + IncludeAppParams: true, + Limit: min(uintOrDefaultValue(params.Limit, si.opts.DefaultAccountsLimit), si.opts.MaxAccountsLimit), HasAssetID: uintOrDefault(params.AssetId), HasAppID: uintOrDefault(params.ApplicationId), EqualToAuthAddr: spendingAddr[:], IncludeDeleted: boolOrDefault(params.IncludeAll), + MaxResources: uint64(si.opts.MaxAPIResourcesPerAccount), + } + + if params.Exclude != nil { + err := setExcludeQueryOptions(*params.Exclude, &options) + if err != nil { + return badRequest(ctx, err.Error()) + } } // Set GT/LT on Algos or Asset depending on whether or not an assetID was specified @@ -234,8 +380,11 @@ func (si *ServerImplementation) SearchForAccounts(ctx echo.Context, params gener } accounts, round, err := si.fetchAccounts(ctx.Request().Context(), options, params.Round) - if err != nil { + var maxErr idb.MaxAPIResourcesPerAccountError + if errors.As(err, &maxErr) { + return ctx.JSON(http.StatusBadRequest, si.maxAccountsErrorToAccountsErrorResponse(maxErr)) + } return indexerError(ctx, fmt.Errorf("%s: %w", errFailedSearchingAccount, err)) } @@ -297,7 +446,13 @@ func (si *ServerImplementation) SearchForApplications(ctx echo.Context, params g if err := si.verifyHandler("SearchForApplications", ctx); err != nil { return badRequest(ctx, err.Error()) } - apps, round, err := si.fetchApplications(ctx.Request().Context(), params) + + options, err := si.appParamsToApplicationQuery(params) + if err != nil { + return badRequest(ctx, err.Error()) + } + + apps, round, err := si.fetchApplications(ctx.Request().Context(), options) if err != nil { return indexerError(ctx, fmt.Errorf("%s: %w", errFailedSearchingApplication, err)) } @@ -321,13 +476,13 @@ func (si *ServerImplementation) LookupApplicationByID(ctx echo.Context, applicat if err := si.verifyHandler("LookupApplicationByID", ctx); err != nil { return badRequest(ctx, err.Error()) } - p := generated.SearchForApplicationsParams{ - ApplicationId: &applicationID, - IncludeAll: params.IncludeAll, - Limit: uint64Ptr(1), + q := idb.ApplicationQuery{ + ApplicationID: applicationID, + IncludeDeleted: boolOrDefault(params.IncludeAll), + Limit: 1, } - apps, round, err := si.fetchApplications(ctx.Request().Context(), p) + apps, round, err := si.fetchApplications(ctx.Request().Context(), q) if err != nil { return indexerError(ctx, fmt.Errorf("%s: %w", errFailedSearchingApplication, err)) } @@ -364,7 +519,7 @@ func (si *ServerImplementation) LookupApplicationLogsByID(ctx echo.Context, appl Address: params.SenderAddress, } - filter, err := transactionParamsToTransactionFilter(searchParams) + filter, err := si.transactionParamsToTransactionFilter(searchParams) if err != nil { return badRequest(ctx, err.Error()) } @@ -421,7 +576,7 @@ func (si *ServerImplementation) LookupAssetByID(ctx echo.Context, assetID uint64 Limit: uint64Ptr(1), IncludeAll: params.IncludeAll, } - options, err := assetParamsToAssetQuery(search) + options, err := si.assetParamsToAssetQuery(search) if err != nil { return badRequest(ctx, err.Error()) } @@ -457,7 +612,7 @@ func (si *ServerImplementation) LookupAssetBalances(ctx echo.Context, assetID ui AmountGT: params.CurrencyGreaterThan, AmountLT: params.CurrencyLessThan, IncludeDeleted: boolOrDefault(params.IncludeAll), - Limit: min(uintOrDefaultValue(params.Limit, defaultBalancesLimit), maxBalancesLimit), + Limit: min(uintOrDefaultValue(params.Limit, si.opts.DefaultBalancesLimit), si.opts.MaxBalancesLimit), } if params.Next != nil { @@ -524,7 +679,7 @@ func (si *ServerImplementation) SearchForAssets(ctx echo.Context, params generat return badRequest(ctx, err.Error()) } - options, err := assetParamsToAssetQuery(params) + options, err := si.assetParamsToAssetQuery(params) if err != nil { return badRequest(ctx, err.Error()) } @@ -570,7 +725,7 @@ func (si *ServerImplementation) LookupTransaction(ctx echo.Context, txid string) return badRequest(ctx, err.Error()) } - filter, err := transactionParamsToTransactionFilter(generated.SearchForTransactionsParams{ + filter, err := si.transactionParamsToTransactionFilter(generated.SearchForTransactionsParams{ Txid: strPtr(txid), }) if err != nil { @@ -611,7 +766,7 @@ func (si *ServerImplementation) SearchForTransactions(ctx echo.Context, params g return badRequest(ctx, err.Error()) } - filter, err := transactionParamsToTransactionFilter(params) + filter, err := si.transactionParamsToTransactionFilter(params) if err != nil { return badRequest(ctx, err.Error()) } @@ -677,13 +832,12 @@ func notFound(ctx echo.Context, err string) error { /////////////////////// // fetchApplications fetches all results -func (si *ServerImplementation) fetchApplications(ctx context.Context, params generated.SearchForApplicationsParams) ([]generated.Application, uint64, error) { - params.Limit = uint64Ptr(min(uintOrDefaultValue(params.Limit, defaultApplicationsLimit), maxApplicationsLimit)) +func (si *ServerImplementation) fetchApplications(ctx context.Context, params idb.ApplicationQuery) ([]generated.Application, uint64, error) { var apps []generated.Application var round uint64 err := callWithTimeout(ctx, si.log, si.timeout, func(ctx context.Context) error { var results <-chan idb.ApplicationRow - results, round = si.db.Applications(ctx, ¶ms) + results, round = si.db.Applications(ctx, params) for result := range results { if result.Error != nil { @@ -698,7 +852,31 @@ func (si *ServerImplementation) fetchApplications(ctx context.Context, params ge return nil, 0, err } - return apps, round, err + return apps, round, nil +} + +// fetchAppLocalStates fetches all generated.AppLocalState from a query +func (si *ServerImplementation) fetchAppLocalStates(ctx context.Context, params idb.ApplicationQuery) ([]generated.ApplicationLocalState, uint64, error) { + var als []generated.ApplicationLocalState + var round uint64 + err := callWithTimeout(ctx, si.log, si.timeout, func(ctx context.Context) error { + var results <-chan idb.AppLocalStateRow + results, round = si.db.AppLocalState(ctx, params) + + for result := range results { + if result.Error != nil { + return result.Error + } + als = append(als, result.AppLocalState) + } + + return nil + }) + if err != nil { + return nil, 0, err + } + + return als, round, nil } // fetchAssets fetches all results and converts them into generated.Asset objects @@ -797,6 +975,47 @@ func (si *ServerImplementation) fetchAssetBalances(ctx context.Context, options return balances, round, nil } +// fetchAssetHoldings fetches all balances from a query and converts them into +// generated.AssetHolding objects +func (si *ServerImplementation) fetchAssetHoldings(ctx context.Context, options idb.AssetBalanceQuery) ([]generated.AssetHolding, uint64 /*round*/, error) { + var round uint64 + balances := make([]generated.AssetHolding, 0) + err := callWithTimeout(ctx, si.log, si.timeout, func(ctx context.Context) error { + var assetbalchan <-chan idb.AssetBalanceRow + assetbalchan, round = si.db.AssetBalances(ctx, options) + + for row := range assetbalchan { + if row.Error != nil { + return row.Error + } + + addr := basics.Address{} + if len(row.Address) != len(addr) { + return fmt.Errorf(errInvalidCreatorAddress) + } + copy(addr[:], row.Address[:]) + + bal := generated.AssetHolding{ + Amount: row.Amount, + AssetId: row.AssetID, + IsFrozen: row.Frozen, + OptedInAtRound: row.CreatedRound, + OptedOutAtRound: row.ClosedRound, + Deleted: row.Deleted, + } + + balances = append(balances, bal) + } + + return nil + }) + if err != nil { + return nil, 0, err + } + + return balances, round, nil +} + // fetchBlock looks up a block and converts it into a generated.Block object // the method also loads the transactions into the returned block object. func (si *ServerImplementation) fetchBlock(ctx context.Context, round uint64) (generated.Block, error) { @@ -975,7 +1194,7 @@ func (si *ServerImplementation) fetchTransactions(ctx context.Context, filter id return nil, "", 0, err } - return results, nextToken, round, err + return results, nextToken, round, nil } ////////////////////// diff --git a/api/handlers_e2e_test.go b/api/handlers_e2e_test.go index 753ddb822..9d675dc06 100644 --- a/api/handlers_e2e_test.go +++ b/api/handlers_e2e_test.go @@ -1,16 +1,21 @@ package api import ( + "context" "encoding/base64" "fmt" + "io/ioutil" "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" "github.com/algorand/go-algorand/crypto" "github.com/labstack/echo/v4" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/algorand/go-algorand-sdk/encoding/json" @@ -26,6 +31,29 @@ import ( "github.com/algorand/indexer/util/test" ) +var defaultOpts = ExtraOptions{ + MaxTransactionsLimit: 10000, + DefaultTransactionsLimit: 1000, + + MaxAccountsLimit: 1000, + DefaultAccountsLimit: 100, + + MaxAssetsLimit: 1000, + DefaultAssetsLimit: 100, + + MaxBalancesLimit: 10000, + DefaultBalancesLimit: 1000, + + MaxApplicationsLimit: 1000, + DefaultApplicationsLimit: 100, + + DisabledMapConfig: MakeDisabledMapConfig(), +} + +func testServerImplementation(db idb.IndexerDb) *ServerImplementation { + return &ServerImplementation{db: db, timeout: 30 * time.Second, opts: defaultOpts} +} + func setupIdb(t *testing.T, genesis bookkeeping.Genesis, genesisBlock bookkeeping.Block) (*postgres.IndexerDb /*db*/, func() /*shutdownFunc*/) { _, connStr, shutdownFunc := pgtest.SetupPostgres(t) @@ -46,12 +74,12 @@ func setupIdb(t *testing.T, genesis bookkeeping.Genesis, genesisBlock bookkeepin return db, newShutdownFunc } -func TestApplicationHandler(t *testing.T) { +func TestApplicationHandlers(t *testing.T) { db, shutdownFunc := setupIdb(t, test.MakeGenesis(), test.MakeGenesisBlock()) defer shutdownFunc() /////////// - // Given // A block containing an app call txn with ExtraProgramPages + // Given // A block containing an app call txn with ExtraProgramPages, that the creator and another account have opted into /////////// const expectedAppIdx = 1 // must be 1 since this is the first txn @@ -76,8 +104,10 @@ func TestApplicationHandler(t *testing.T) { ApplicationID: expectedAppIdx, }, } + optInTxnA := test.MakeAppOptInTxn(expectedAppIdx, test.AccountA) + optInTxnB := test.MakeAppOptInTxn(expectedAppIdx, test.AccountB) - block, err := test.MakeBlockForTxns(test.MakeGenesisBlock().BlockHeader, &txn) + block, err := test.MakeBlockForTxns(test.MakeGenesisBlock().BlockHeader, &txn, &optInTxnA, &optInTxnB) require.NoError(t, err) err = db.AddBlock(&block) @@ -87,30 +117,641 @@ func TestApplicationHandler(t *testing.T) { // When // We query the app ////////// - e := echo.New() - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - c.SetPath("/v2/applications/:appidx") - c.SetParamNames("appidx") - c.SetParamValues(strconv.Itoa(expectedAppIdx)) + setupReq := func(path, paramName, paramValue string) (echo.Context, *ServerImplementation, *httptest.ResponseRecorder) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath(path) + c.SetParamNames(paramName) + c.SetParamValues(paramValue) + api := testServerImplementation(db) + return c, api, rec + } - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + c, api, rec := setupReq("/v2/applications/:appidx", "appidx", strconv.Itoa(expectedAppIdx)) params := generated.LookupApplicationByIDParams{} err = api.LookupApplicationByID(c, expectedAppIdx, params) require.NoError(t, err) require.Equal(t, http.StatusOK, rec.Code, fmt.Sprintf("unexpected return code, body: %s", rec.Body.String())) ////////// - // Then // The response has non-zero ExtraProgramPages + // Then // The response has non-zero ExtraProgramPages and other app data ////////// + checkApp := func(t *testing.T, app *generated.Application) { + require.NotNil(t, app) + require.NotNil(t, app.Params.ExtraProgramPages) + require.Equal(t, uint64(extraPages), *app.Params.ExtraProgramPages) + require.Equal(t, app.Id, uint64(expectedAppIdx)) + require.NotNil(t, app.Params.Creator) + require.Equal(t, *app.Params.Creator, test.AccountA.String()) + require.Equal(t, app.Params.ApprovalProgram, []byte{0x02, 0x20, 0x01, 0x01, 0x22}) + require.Equal(t, app.Params.ClearStateProgram, []byte{0x02, 0x20, 0x01, 0x01, 0x22}) + } + var response generated.ApplicationResponse data := rec.Body.Bytes() err = json.Decode(data, &response) require.NoError(t, err) - require.NotNil(t, response.Application.Params.ExtraProgramPages) - require.Equal(t, uint64(extraPages), *response.Application.Params.ExtraProgramPages) + checkApp(t, response.Application) + + t.Run("created-applications", func(t *testing.T) { + ////////// + // When // We look up the app by creator address + ////////// + + c, api, rec := setupReq("/v2/accounts/:accountid/created-applications", "accountid", test.AccountA.String()) + params := generated.LookupAccountCreatedApplicationsParams{} + err = api.LookupAccountCreatedApplications(c, test.AccountA.String(), params) + require.NoError(t, err) + require.Equal(t, http.StatusOK, rec.Code, fmt.Sprintf("unexpected return code, body: %s", rec.Body.String())) + + ////////// + // Then // The response has non-zero ExtraProgramPages and other app data + ////////// + + var response generated.ApplicationsResponse + data := rec.Body.Bytes() + err = json.Decode(data, &response) + require.NoError(t, err) + require.Len(t, response.Applications, 1) + checkApp(t, &response.Applications[0]) + }) + + checkAppLocalState := func(t *testing.T, ls *generated.ApplicationLocalState) { + require.NotNil(t, ls) + require.NotNil(t, ls.Deleted) + require.False(t, *ls.Deleted) + require.Equal(t, ls.Id, uint64(expectedAppIdx)) + } + + for _, tc := range []struct{ name, addr string }{ + {"creator", test.AccountA.String()}, + {"opted-in-account", test.AccountB.String()}, + } { + t.Run("app-local-state-"+tc.name, func(t *testing.T) { + ////////// + // When // We look up the app's local state for an address that has opted in + ////////// + + c, api, rec := setupReq("/v2/accounts/:accountid/apps-local-state", "accountid", test.AccountA.String()) + params := generated.LookupAccountAppLocalStatesParams{} + err = api.LookupAccountAppLocalStates(c, tc.addr, params) + require.NoError(t, err) + require.Equal(t, http.StatusOK, rec.Code, fmt.Sprintf("unexpected return code, body: %s", rec.Body.String())) + + ////////// + // Then // AppLocalState is available for that address + ////////// + + var response generated.ApplicationLocalStatesResponse + data := rec.Body.Bytes() + err = json.Decode(data, &response) + require.NoError(t, err) + require.Len(t, response.AppsLocalStates, 1) + checkAppLocalState(t, &response.AppsLocalStates[0]) + }) + } +} + +func TestAccountExcludeParameters(t *testing.T) { + db, shutdownFunc := setupIdb(t, test.MakeGenesis(), test.MakeGenesisBlock()) + defer shutdownFunc() + + /////////// + // Given // A block containing a creator of an app, an asset, who also holds and has opted-into those apps. + /////////// + + const expectedAppIdx = 1 // must be 1 since this is the first txn + const expectedAssetIdx = 2 + createAppTxn := test.MakeCreateAppTxn(test.AccountA) + createAssetTxn := test.MakeAssetConfigTxn(0, 100, 0, false, "UNIT", "Asset 2", "http://asset2.com", test.AccountA) + appOptInTxnA := test.MakeAppOptInTxn(expectedAppIdx, test.AccountA) + appOptInTxnB := test.MakeAppOptInTxn(expectedAppIdx, test.AccountB) + assetOptInTxnA := test.MakeAssetOptInTxn(expectedAssetIdx, test.AccountA) + assetOptInTxnB := test.MakeAssetOptInTxn(expectedAssetIdx, test.AccountB) + + block, err := test.MakeBlockForTxns(test.MakeGenesisBlock().BlockHeader, &createAppTxn, &createAssetTxn, + &appOptInTxnA, &appOptInTxnB, &assetOptInTxnA, &assetOptInTxnB) + require.NoError(t, err) + + err = db.AddBlock(&block) + require.NoError(t, err, "failed to commit") + + ////////// + // When // We look up the address using various exclude parameters. + ////////// + + setupReq := func(path, paramName, paramValue string) (echo.Context, *ServerImplementation, *httptest.ResponseRecorder) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath(path) + c.SetParamNames(paramName) + c.SetParamValues(paramValue) + api := testServerImplementation(db) + return c, api, rec + } + + ////////// + // Then // Those parameters are excluded. + ////////// + + testCases := []struct { + address basics.Address + exclude []string + check func(*testing.T, generated.AccountResponse) + errStatus int + }{{ + address: test.AccountA, + exclude: []string{"all"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.Nil(t, r.Account.CreatedAssets) + require.Nil(t, r.Account.CreatedApps) + require.Nil(t, r.Account.Assets) + require.Nil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountA, + exclude: []string{"none"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.NotNil(t, r.Account.CreatedAssets) + require.NotNil(t, r.Account.CreatedApps) + require.NotNil(t, r.Account.Assets) + require.NotNil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountA, + check: func(t *testing.T, r generated.AccountResponse) { + require.NotNil(t, r.Account.CreatedAssets) + require.NotNil(t, r.Account.CreatedApps) + require.NotNil(t, r.Account.Assets) + require.NotNil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountA, + exclude: []string{"created-assets", "created-apps", "apps-local-state", "assets"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.Nil(t, r.Account.CreatedAssets) + require.Nil(t, r.Account.CreatedApps) + require.Nil(t, r.Account.Assets) + require.Nil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountA, + exclude: []string{"created-assets"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.Nil(t, r.Account.CreatedAssets) + require.NotNil(t, r.Account.CreatedApps) + require.NotNil(t, r.Account.Assets) + require.NotNil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountA, + exclude: []string{"created-apps"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.NotNil(t, r.Account.CreatedAssets) + require.Nil(t, r.Account.CreatedApps) + require.NotNil(t, r.Account.Assets) + require.NotNil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountA, + exclude: []string{"apps-local-state"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.NotNil(t, r.Account.CreatedAssets) + require.NotNil(t, r.Account.CreatedApps) + require.NotNil(t, r.Account.Assets) + require.Nil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountA, + exclude: []string{"assets"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.NotNil(t, r.Account.CreatedAssets) + require.NotNil(t, r.Account.CreatedApps) + require.Nil(t, r.Account.Assets) + require.NotNil(t, r.Account.AppsLocalState) + }}, { + address: test.AccountB, + exclude: []string{"assets", "apps-local-state"}, + check: func(t *testing.T, r generated.AccountResponse) { + require.Nil(t, r.Account.CreatedAssets) + require.Nil(t, r.Account.CreatedApps) + require.Nil(t, r.Account.Assets) + require.Nil(t, r.Account.AppsLocalState) + }}, + { + address: test.AccountA, + exclude: []string{"abc"}, + errStatus: http.StatusBadRequest, + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("exclude %v", tc.exclude), func(t *testing.T) { + c, api, rec := setupReq("/v2/accounts/:account-id", "account-id", tc.address.String()) + err := api.LookupAccountByID(c, tc.address.String(), generated.LookupAccountByIDParams{Exclude: &tc.exclude}) + require.NoError(t, err) + if tc.errStatus != 0 { + require.Equal(t, tc.errStatus, rec.Code) + return + } + require.Equal(t, http.StatusOK, rec.Code, fmt.Sprintf("unexpected return code, body: %s", rec.Body.String())) + data := rec.Body.Bytes() + var response generated.AccountResponse + err = json.Decode(data, &response) + require.NoError(t, err) + tc.check(t, response) + }) + } + +} + +type accountsErrorResponse struct { + Data struct { + Address *string `json:"address,omitempty"` + MaxResults *uint64 `json:"max-results,omitempty"` + Message string `json:"message"` + TotalAppsOptedIn *uint64 `json:"total-apps-opted-in,omitempty"` + TotalAssetsOptedIn *uint64 `json:"total-assets-opted-in,omitempty"` + TotalCreatedApps *uint64 `json:"total-created-apps,omitempty"` + TotalCreatedAssets *uint64 `json:"total-created-assets,omitempty"` + } `json:"data,omitempty"` + Message string `json:"message"` +} + +func TestAccountMaxResultsLimit(t *testing.T) { + db, shutdownFunc := setupIdb(t, test.MakeGenesis(), test.MakeGenesisBlock()) + defer shutdownFunc() + + /////////// + // Given // A block containing an address that has created 10 apps, deleted 5 apps, and created 10 assets, + // // deleted 5 assets, and another address that has opted into the 5 apps and 5 assets remaining + /////////// + + deletedAppIDs := []uint64{1, 2, 3, 4, 5} + deletedAssetIDs := []uint64{6, 7, 8, 9, 10} + expectedAppIDs := []uint64{11, 12, 13, 14, 15} + expectedAssetIDs := []uint64{16, 17, 18, 19, 20} + + var txns []transactions.SignedTxnWithAD + // make apps and assets + for range deletedAppIDs { + txns = append(txns, test.MakeCreateAppTxn(test.AccountA)) + } + for _, id := range deletedAssetIDs { + txns = append(txns, test.MakeAssetConfigTxn(0, 100, 0, false, "UNIT", + fmt.Sprintf("Asset %d", id), "http://asset.com", test.AccountA)) + } + for range expectedAppIDs { + txns = append(txns, test.MakeCreateAppTxn(test.AccountA)) + } + for _, id := range expectedAssetIDs { + txns = append(txns, test.MakeAssetConfigTxn(0, 100, 0, false, "UNIT", + fmt.Sprintf("Asset %d", id), "http://asset.com", test.AccountA)) + } + // delete some apps and assets + for _, id := range deletedAppIDs { + txns = append(txns, test.MakeAppDestroyTxn(id, test.AccountA)) + } + for _, id := range deletedAssetIDs { + txns = append(txns, test.MakeAssetDestroyTxn(id, test.AccountA)) + } + + // opt in to the remaining ones + for _, id := range expectedAppIDs { + txns = append(txns, test.MakeAppOptInTxn(id, test.AccountA)) + txns = append(txns, test.MakeAppOptInTxn(id, test.AccountB)) + } + for _, id := range expectedAssetIDs { + txns = append(txns, test.MakeAssetOptInTxn(id, test.AccountA)) + txns = append(txns, test.MakeAssetOptInTxn(id, test.AccountB)) + } + + ptxns := make([]*transactions.SignedTxnWithAD, len(txns)) + for i := range txns { + ptxns[i] = &txns[i] + } + block, err := test.MakeBlockForTxns(test.MakeGenesisBlock().BlockHeader, ptxns...) + require.NoError(t, err) + + err = db.AddBlock(&block) + require.NoError(t, err, "failed to commit") + + ////////// + // When // We look up the address using a ServerImplementation with a maxAccountsAPIResults limit set, + // // and addresses with max # apps over & under the limit + ////////// + + maxResults := 14 + serverCtx, serverCancel := context.WithCancel(context.Background()) + defer serverCancel() + opts := defaultOpts + opts.MaxAPIResourcesPerAccount = uint64(maxResults) + listenAddr := "localhost:8989" + go Serve(serverCtx, listenAddr, db, nil, logrus.New(), opts) + + // wait at most a few seconds for server to come up + serverUp := false + for maxWait := 3 * time.Second; !serverUp && maxWait > 0; maxWait -= 50 * time.Millisecond { + time.Sleep(50 * time.Millisecond) + resp, err := http.Get("http://" + listenAddr + "/health") + if err != nil { + t.Log("waiting for server:", err) + continue + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Log("waiting for server OK:", resp.StatusCode) + continue + } + serverUp = true // server is up now + } + require.True(t, serverUp, "api.Serve did not start server in time") + + // make a real HTTP request (to additionally test generated param parsing logic) + makeReq := func(t *testing.T, path string, exclude []string, includeDeleted bool, next *string, limit *uint64) (*http.Response, []byte) { + var query []string + if len(exclude) > 0 { + query = append(query, "exclude="+strings.Join(exclude, ",")) + } + if includeDeleted { + query = append(query, "include-all=true") + } + if next != nil { + query = append(query, "next="+*next) + } + if limit != nil { + query = append(query, fmt.Sprintf("limit=%d", *limit)) + } + if len(query) > 0 { + path += "?" + strings.Join(query, "&") + } + t.Log("making HTTP request path", path) + resp, err := http.Get("http://" + listenAddr + path) + require.NoError(t, err) + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + return resp, body + } + + ////////// + // Then // The limit is enforced, leading to a 400 error + ////////// + + checkExclude := func(t *testing.T, acct generated.Account, exclude []string) { + for _, exc := range exclude { + switch exc { + case "all": + assert.Nil(t, acct.CreatedApps) + assert.Nil(t, acct.AppsLocalState) + assert.Nil(t, acct.CreatedAssets) + assert.Nil(t, acct.Assets) + case "created-assets": + assert.Nil(t, acct.CreatedAssets) + case "apps-local-state": + assert.Nil(t, acct.AppsLocalState) + case "created-apps": + assert.Nil(t, acct.CreatedApps) + case "assets": + assert.Nil(t, acct.Assets) + } + } + } + + testCases := []struct { + address basics.Address + exclude []string + includeDeleted bool + errStatus int + }{ + {address: test.AccountA, exclude: []string{"all"}}, + {address: test.AccountA, exclude: []string{"created-assets", "created-apps", "apps-local-state", "assets"}}, + {address: test.AccountA, exclude: []string{"assets", "created-apps"}}, + {address: test.AccountA, exclude: []string{"assets", "apps-local-state"}}, + {address: test.AccountA, exclude: []string{"assets", "apps-local-state"}, includeDeleted: true, errStatus: http.StatusBadRequest}, + {address: test.AccountB, exclude: []string{"created-assets", "apps-local-state"}}, + {address: test.AccountB, exclude: []string{"assets", "apps-local-state"}}, + {address: test.AccountA, exclude: []string{"created-assets"}, errStatus: http.StatusBadRequest}, + {address: test.AccountA, exclude: []string{"created-apps"}, errStatus: http.StatusBadRequest}, + {address: test.AccountA, exclude: []string{"apps-local-state"}, errStatus: http.StatusBadRequest}, + {address: test.AccountA, exclude: []string{"assets"}, errStatus: http.StatusBadRequest}, + } + + for _, tc := range testCases { + maxResults := 14 + t.Run(fmt.Sprintf("LookupAccountByID exclude %v", tc.exclude), func(t *testing.T) { + path := "/v2/accounts/" + tc.address.String() + resp, data := makeReq(t, path, tc.exclude, tc.includeDeleted, nil, nil) + if tc.errStatus != 0 { // was a 400 error expected? check error response + require.Equal(t, tc.errStatus, resp.StatusCode) + var response accountsErrorResponse + err = json.Decode(data, &response) + require.NoError(t, err) + assert.Equal(t, tc.address.String(), *response.Data.Address) + assert.Equal(t, uint64(maxResults), *response.Data.MaxResults) + if tc.includeDeleted { + assert.Equal(t, uint64(10), *response.Data.TotalCreatedApps) + assert.Equal(t, uint64(10), *response.Data.TotalCreatedAssets) + } else { + assert.Equal(t, uint64(5), *response.Data.TotalAppsOptedIn) + assert.Equal(t, uint64(5), *response.Data.TotalAssetsOptedIn) + assert.Equal(t, uint64(5), *response.Data.TotalCreatedApps) + assert.Equal(t, uint64(5), *response.Data.TotalCreatedAssets) + } + return + } + require.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("unexpected return code, body: %s", string(data))) + var response generated.AccountResponse + err = json.Decode(data, &response) + require.NoError(t, err) + checkExclude(t, response.Account, tc.exclude) + }) + } + + ////////// + // When // We search all addresses using a ServerImplementation with a maxAccountsAPIResults limit set, + // // and one of those addresses is over the limit, but another address is not + ////////// + + for _, tc := range []struct { + exclude []string + errStatus int + errAddress basics.Address + }{ + {exclude: []string{"all"}}, + {exclude: []string{"created-assets", "created-apps", "apps-local-state", "assets"}}, + {exclude: []string{"assets", "apps-local-state"}}, + {errAddress: test.AccountA, exclude: nil, errStatus: 400}, + {errAddress: test.AccountA, exclude: []string{"created-assets"}, errStatus: http.StatusBadRequest}, + {errAddress: test.AccountA, exclude: []string{"created-apps"}, errStatus: http.StatusBadRequest}, + {errAddress: test.AccountA, exclude: []string{"apps-local-state"}, errStatus: http.StatusBadRequest}, + {errAddress: test.AccountA, exclude: []string{"assets"}, errStatus: http.StatusBadRequest}, + } { + t.Run(fmt.Sprintf("SearchForAccounts exclude %v", tc.exclude), func(t *testing.T) { + maxResults := 14 + resp, data := makeReq(t, "/v2/accounts", tc.exclude, false, nil, nil) + if tc.errStatus != 0 { // was a 400 error expected? check error response + require.Equal(t, tc.errStatus, resp.StatusCode) + var response accountsErrorResponse + err = json.Decode(data, &response) + require.NoError(t, err) + require.Equal(t, *response.Data.Address, tc.errAddress.String()) + require.Equal(t, *response.Data.MaxResults, uint64(maxResults)) + require.Equal(t, *response.Data.TotalAppsOptedIn, uint64(5)) + require.Equal(t, *response.Data.TotalCreatedApps, uint64(5)) + require.Equal(t, *response.Data.TotalAssetsOptedIn, uint64(5)) + require.Equal(t, *response.Data.TotalCreatedAssets, uint64(5)) + return + } + require.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("unexpected return code, body: %s", string(data))) + var response generated.AccountsResponse + err = json.Decode(data, &response) + require.NoError(t, err) + + // check that the accounts are in there + var sawAccountA, sawAccountB bool + for _, acct := range response.Accounts { + switch acct.Address { + case test.AccountA.String(): + sawAccountA = true + require.Equal(t, acct.TotalAppsOptedIn, uint64(5)) + require.Equal(t, acct.TotalCreatedApps, uint64(5)) + require.Equal(t, acct.TotalAssetsOptedIn, uint64(5)) + require.Equal(t, acct.TotalCreatedAssets, uint64(5)) + case test.AccountB.String(): + sawAccountB = true + require.Equal(t, acct.TotalAppsOptedIn, uint64(5)) + require.Equal(t, acct.TotalCreatedApps, uint64(0)) + require.Equal(t, acct.TotalAssetsOptedIn, uint64(5)) + require.Equal(t, acct.TotalCreatedAssets, uint64(0)) + } + checkExclude(t, acct, tc.exclude) + } + require.True(t, sawAccountA && sawAccountB) + }) + } + + ////////// + // When // We look up the assets an account holds, and paginate through them using "Next" + ////////// + + t.Run("LookupAccountAssets", func(t *testing.T) { + var next *string // nil/unset to start + limit := uint64(2) // 2 at a time + var assets []generated.AssetHolding + for { + resp, data := makeReq(t, "/v2/accounts/"+test.AccountB.String()+"/assets", nil, false, next, &limit) + require.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("unexpected return code, body: %s", string(data))) + var response generated.AssetHoldingsResponse + err = json.Decode(data, &response) + require.NoError(t, err) + if len(response.Assets) == 0 { + require.Nil(t, response.NextToken) + break + } + require.NotEmpty(t, response.Assets) + assets = append(assets, response.Assets...) + next = response.NextToken // paginate + } + ////////// + // Then // We can see all the assets, even though there were more than the limit + ////////// + require.Len(t, assets, 5) + for i, asset := range assets { + require.Equal(t, expectedAssetIDs[i], asset.AssetId) + } + }) + + ////////// + // When // We look up the assets an account has created, and paginate through them using "Next" + ////////// + + t.Run("LookupAccountCreatedAssets", func(t *testing.T) { + var next *string // nil/unset to start + limit := uint64(2) // 2 at a time + var assets []generated.Asset + for { + resp, data := makeReq(t, "/v2/accounts/"+test.AccountA.String()+"/created-assets", nil, false, next, &limit) + require.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("unexpected return code, body: %s", string(data))) + var response generated.AssetsResponse + err = json.Decode(data, &response) + require.NoError(t, err) + if len(response.Assets) == 0 { + require.Nil(t, response.NextToken) + break + } + require.NotEmpty(t, response.Assets) + assets = append(assets, response.Assets...) + next = response.NextToken // paginate + } + ////////// + // Then // We can see all the assets, even though there were more than the limit + ////////// + require.Len(t, assets, 5) + for i, asset := range assets { + require.Equal(t, expectedAssetIDs[i], asset.Index) + } + }) + + ////////// + // When // We look up the apps an account has opted in to, and paginate through them using "Next" + ////////// + + t.Run("LookupAccountAppLocalStates", func(t *testing.T) { + var next *string // nil/unset to start + limit := uint64(2) // 2 at a time + var apps []generated.ApplicationLocalState + for { + resp, data := makeReq(t, "/v2/accounts/"+test.AccountA.String()+"/apps-local-state", nil, false, next, &limit) + require.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("unexpected return code, body: %s", string(data))) + var response generated.ApplicationLocalStatesResponse + err = json.Decode(data, &response) + require.NoError(t, err) + if len(response.AppsLocalStates) == 0 { + require.Nil(t, response.NextToken) + break + } + require.NotEmpty(t, response.AppsLocalStates) + apps = append(apps, response.AppsLocalStates...) + next = response.NextToken // paginate + } + ////////// + // Then // We can see all the apps, even though there were more than the limit + ////////// + require.Len(t, apps, 5) + for i, app := range apps { + require.Equal(t, expectedAppIDs[i], app.Id) + } + }) + + ////////// + // When // We look up the apps an account has opted in to, and paginate through them using "Next" + ////////// + + t.Run("LookupAccountCreatedApplications", func(t *testing.T) { + var next *string // nil/unset to start + limit := uint64(2) // 2 at a time + var apps []generated.Application + for { + resp, data := makeReq(t, "/v2/accounts/"+test.AccountA.String()+"/created-applications", nil, false, next, &limit) + require.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("unexpected return code, body: %s", string(data))) + var response generated.ApplicationsResponse + err = json.Decode(data, &response) + require.NoError(t, err) + if len(response.Applications) == 0 { + require.Nil(t, response.NextToken) + break + } + require.NotEmpty(t, response.Applications) + apps = append(apps, response.Applications...) + next = response.NextToken // paginate + } + ////////// + // Then // We can see all the apps, even though there were more than the limit + ////////// + require.Len(t, apps, 5) + for i, app := range apps { + require.Equal(t, expectedAppIDs[i], app.Id) + } + }) } func TestBlockNotFound(t *testing.T) { @@ -132,7 +773,7 @@ func TestBlockNotFound(t *testing.T) { c.SetParamNames("round-number") c.SetParamValues(strconv.Itoa(100)) - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) err := api.LookupBlock(c, 100) require.NoError(t, err) @@ -205,7 +846,7 @@ func TestInnerTxn(t *testing.T) { c := e.NewContext(req, rec) c.SetPath("/v2/transactions/") - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) err = api.SearchForTransactions(c, tc.filter) require.NoError(t, err) @@ -214,7 +855,8 @@ func TestInnerTxn(t *testing.T) { ////////// require.Equal(t, http.StatusOK, rec.Code) var response generated.TransactionsResponse - json.Decode(rec.Body.Bytes(), &response) + err = json.Decode(rec.Body.Bytes(), &response) + require.NoError(t, err) require.Len(t, response.Transactions, 1) require.Equal(t, expectedID, *(response.Transactions[0].Id)) @@ -276,13 +918,14 @@ func TestPagingRootTxnDeduplication(t *testing.T) { // Get first page with limit 1. // Address filter causes results to return newest to oldest. - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) err = api.SearchForTransactions(c, tc.params) require.NoError(t, err) require.Equal(t, http.StatusOK, rec1.Code) var response generated.TransactionsResponse - json.Decode(rec1.Body.Bytes(), &response) + err = json.Decode(rec1.Body.Bytes(), &response) + require.NoError(t, err) require.Len(t, response.Transactions, 1) require.Equal(t, expectedID, *(response.Transactions[0].Id)) pageOneNextToken := *response.NextToken @@ -304,7 +947,8 @@ func TestPagingRootTxnDeduplication(t *testing.T) { ////////// var response2 generated.TransactionsResponse require.Equal(t, http.StatusOK, rec2.Code) - json.Decode(rec2.Body.Bytes(), &response2) + err = json.Decode(rec2.Body.Bytes(), &response2) + require.NoError(t, err) require.Len(t, response2.Transactions, 0) // The fact that NextToken changes indicates that the search results were different. @@ -327,7 +971,7 @@ func TestPagingRootTxnDeduplication(t *testing.T) { // Get first page with limit 1. // Address filter causes results to return newest to oldest. - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) err = api.LookupBlock(c, uint64(block.Round())) require.NoError(t, err) @@ -336,7 +980,8 @@ func TestPagingRootTxnDeduplication(t *testing.T) { ////////// var response generated.BlockResponse require.Equal(t, http.StatusOK, rec.Code) - json.Decode(rec.Body.Bytes(), &response) + err = json.Decode(rec.Body.Bytes(), &response) + require.NoError(t, err) require.NotNil(t, response.Transactions) require.Len(t, *response.Transactions, 1) @@ -442,7 +1087,7 @@ func TestVersion(t *testing.T) { /////////// db, shutdownFunc := setupIdb(t, test.MakeGenesis(), test.MakeGenesisBlock()) defer shutdownFunc() - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) e := echo.New() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -518,7 +1163,7 @@ func TestAccountClearsNonUTF8(t *testing.T) { c := e.NewContext(req, rec) c.SetPath("/v2/assets/") - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) err = api.SearchForAssets(c, generated.SearchForAssetsParams{}) require.NoError(t, err) @@ -543,7 +1188,7 @@ func TestAccountClearsNonUTF8(t *testing.T) { c := e.NewContext(req, rec) c.SetPath("/v2/accounts/") - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) err = api.LookupAccountByID(c, test.AccountA.String(), generated.LookupAccountByIDParams{}) require.NoError(t, err) @@ -626,7 +1271,7 @@ func TestLookupInnerLogs(t *testing.T) { c.SetParamNames("appIdx") c.SetParamValues(fmt.Sprintf("%d", tc.appID)) - api := &ServerImplementation{db: db, timeout: 30 * time.Second} + api := testServerImplementation(db) err = api.LookupApplicationLogsByID(c, tc.appID, params) require.NoError(t, err) diff --git a/api/handlers_test.go b/api/handlers_test.go index 4d62cd6a6..b1dfdcea7 100644 --- a/api/handlers_test.go +++ b/api/handlers_test.go @@ -41,49 +41,49 @@ func TestTransactionParamToTransactionFilter(t *testing.T) { { "Default", generated.SearchForTransactionsParams{}, - idb.TransactionFilter{Limit: defaultTransactionsLimit}, + idb.TransactionFilter{Limit: defaultOpts.DefaultTransactionsLimit}, nil, }, { "Limit", - generated.SearchForTransactionsParams{Limit: uint64Ptr(defaultTransactionsLimit + 10)}, - idb.TransactionFilter{Limit: defaultTransactionsLimit + 10}, + generated.SearchForTransactionsParams{Limit: uint64Ptr(defaultOpts.DefaultTransactionsLimit + 10)}, + idb.TransactionFilter{Limit: defaultOpts.DefaultTransactionsLimit + 10}, nil, }, { "Limit Max", - generated.SearchForTransactionsParams{Limit: uint64Ptr(maxTransactionsLimit + 10)}, - idb.TransactionFilter{Limit: maxTransactionsLimit}, + generated.SearchForTransactionsParams{Limit: uint64Ptr(defaultOpts.MaxTransactionsLimit + 10)}, + idb.TransactionFilter{Limit: defaultOpts.MaxTransactionsLimit}, nil, }, { "Int field", generated.SearchForTransactionsParams{AssetId: uint64Ptr(1234)}, - idb.TransactionFilter{AssetID: 1234, Limit: defaultTransactionsLimit}, + idb.TransactionFilter{AssetID: 1234, Limit: defaultOpts.DefaultTransactionsLimit}, nil, }, { "Pointer field", generated.SearchForTransactionsParams{Round: uint64Ptr(1234)}, - idb.TransactionFilter{Round: uint64Ptr(1234), Limit: defaultTransactionsLimit}, + idb.TransactionFilter{Round: uint64Ptr(1234), Limit: defaultOpts.DefaultTransactionsLimit}, nil, }, { "Base64 field", generated.SearchForTransactionsParams{NotePrefix: strPtr(base64.StdEncoding.EncodeToString([]byte("SomeData")))}, - idb.TransactionFilter{NotePrefix: []byte("SomeData"), Limit: defaultTransactionsLimit}, + idb.TransactionFilter{NotePrefix: []byte("SomeData"), Limit: defaultOpts.DefaultTransactionsLimit}, nil, }, { "Enum fields", generated.SearchForTransactionsParams{TxType: strPtr("pay"), SigType: strPtr("lsig")}, - idb.TransactionFilter{TypeEnum: 1, SigType: "lsig", Limit: defaultTransactionsLimit}, + idb.TransactionFilter{TypeEnum: 1, SigType: "lsig", Limit: defaultOpts.DefaultTransactionsLimit}, nil, }, { "Date time fields", generated.SearchForTransactionsParams{AfterTime: timePtr(time.Date(2020, 3, 4, 12, 0, 0, 0, time.FixedZone("UTC", 0)))}, - idb.TransactionFilter{AfterTime: time.Date(2020, 3, 4, 12, 0, 0, 0, time.FixedZone("UTC", 0)), Limit: defaultTransactionsLimit}, + idb.TransactionFilter{AfterTime: time.Date(2020, 3, 4, 12, 0, 0, 0, time.FixedZone("UTC", 0)), Limit: defaultOpts.DefaultTransactionsLimit}, nil, }, { @@ -95,7 +95,7 @@ func TestTransactionParamToTransactionFilter(t *testing.T) { { "As many fields as possible", generated.SearchForTransactionsParams{ - Limit: uint64Ptr(defaultTransactionsLimit + 1), + Limit: uint64Ptr(defaultOpts.DefaultTransactionsLimit + 1), Next: strPtr("next-token"), NotePrefix: strPtr(base64.StdEncoding.EncodeToString([]byte("custom-note"))), TxType: strPtr("pay"), @@ -115,7 +115,7 @@ func TestTransactionParamToTransactionFilter(t *testing.T) { ApplicationId: uint64Ptr(7), }, idb.TransactionFilter{ - Limit: defaultTransactionsLimit + 1, + Limit: defaultOpts.DefaultTransactionsLimit + 1, NextToken: "next-token", NotePrefix: []byte("custom-note"), TypeEnum: 1, @@ -157,56 +157,57 @@ func TestTransactionParamToTransactionFilter(t *testing.T) { { name: "Bitmask sender + closeTo(true)", params: generated.SearchForTransactionsParams{AddressRole: strPtr("sender"), ExcludeCloseTo: boolPtr(true)}, - filter: idb.TransactionFilter{AddressRole: 9, Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{AddressRole: 9, Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, { name: "Bitmask sender + closeTo(false)", params: generated.SearchForTransactionsParams{AddressRole: strPtr("sender"), ExcludeCloseTo: boolPtr(false)}, - filter: idb.TransactionFilter{AddressRole: 9, Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{AddressRole: 9, Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, { name: "Bitmask receiver + closeTo(true)", params: generated.SearchForTransactionsParams{AddressRole: strPtr("receiver"), ExcludeCloseTo: boolPtr(true)}, - filter: idb.TransactionFilter{AddressRole: 18, Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{AddressRole: 18, Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, { name: "Bitmask receiver + closeTo(false)", params: generated.SearchForTransactionsParams{AddressRole: strPtr("receiver"), ExcludeCloseTo: boolPtr(false)}, - filter: idb.TransactionFilter{AddressRole: 54, Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{AddressRole: 54, Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, { name: "Bitmask receiver + implicit closeTo (false)", params: generated.SearchForTransactionsParams{AddressRole: strPtr("receiver")}, - filter: idb.TransactionFilter{AddressRole: 54, Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{AddressRole: 54, Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, { name: "Bitmask freeze-target", params: generated.SearchForTransactionsParams{AddressRole: strPtr("freeze-target")}, - filter: idb.TransactionFilter{AddressRole: 64, Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{AddressRole: 64, Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, { name: "Currency to Algos when no asset-id", params: generated.SearchForTransactionsParams{CurrencyGreaterThan: uint64Ptr(10), CurrencyLessThan: uint64Ptr(20)}, - filter: idb.TransactionFilter{AlgosGT: uint64Ptr(10), AlgosLT: uint64Ptr(20), Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{AlgosGT: uint64Ptr(10), AlgosLT: uint64Ptr(20), Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, { name: "Searching by application-id", params: generated.SearchForTransactionsParams{ApplicationId: uint64Ptr(1234)}, - filter: idb.TransactionFilter{ApplicationID: 1234, Limit: defaultTransactionsLimit}, + filter: idb.TransactionFilter{ApplicationID: 1234, Limit: defaultOpts.DefaultTransactionsLimit}, errorContains: nil, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - filter, err := transactionParamsToTransactionFilter(test.params) + si := testServerImplementation(nil) + filter, err := si.transactionParamsToTransactionFilter(test.params) if len(test.errorContains) > 0 { require.Error(t, err) for _, msg := range test.errorContains { @@ -228,7 +229,7 @@ func TestValidateTransactionFilter(t *testing.T) { }{ { "Default", - idb.TransactionFilter{Limit: defaultTransactionsLimit}, + idb.TransactionFilter{Limit: defaultOpts.DefaultTransactionsLimit}, nil, }, { @@ -540,11 +541,9 @@ func TestFetchTransactions(t *testing.T) { // Setup the mocked responses mockIndexer := &mocks.IndexerDb{} - si := ServerImplementation{ - EnableAddressSearchRoundRewind: true, - db: mockIndexer, - timeout: 1 * time.Second, - } + si := testServerImplementation(mockIndexer) + si.EnableAddressSearchRoundRewind = true + si.timeout = 1 * time.Second roundTime := time.Now() roundTime64 := uint64(roundTime.Unix()) @@ -627,10 +626,8 @@ func TestFetchAccountsRewindRoundTooLarge(t *testing.T) { db := &mocks.IndexerDb{} db.On("GetAccounts", mock.Anything, mock.Anything).Return(outCh, uint64(7)).Once() - si := ServerImplementation{ - EnableAddressSearchRoundRewind: true, - db: db, - } + si := testServerImplementation(db) + si.EnableAddressSearchRoundRewind = true atRound := uint64(8) _, _, err := si.fetchAccounts(context.Background(), idb.AccountQueryOptions{}, &atRound) assert.Error(t, err) @@ -680,10 +677,8 @@ func createTxn(t *testing.T, target string) []byte { func TestLookupApplicationLogsByID(t *testing.T) { mockIndexer := &mocks.IndexerDb{} - si := ServerImplementation{ - EnableAddressSearchRoundRewind: true, - db: mockIndexer, - } + si := testServerImplementation(mockIndexer) + si.EnableAddressSearchRoundRewind = true txnBytes := loadResourceFileOrPanic("test_resources/app_call_logs.txn") var stxn transactions.SignedTxnWithAD @@ -894,10 +889,8 @@ func TestTimeouts(t *testing.T) { // Make a mock indexer and tell the mock to timeout. mockIndexer := &mocks.IndexerDb{} - si := ServerImplementation{ - db: mockIndexer, - timeout: 5 * time.Millisecond, - } + si := testServerImplementation(mockIndexer) + si.timeout = 5 * time.Millisecond // Setup context... e := echo.New() @@ -907,7 +900,7 @@ func TestTimeouts(t *testing.T) { // configure the mock to timeout, then call the handler. tc.mockCall(mockIndexer, timeout) - err := tc.callHandler(c, si) + err := tc.callHandler(c, *si) require.NoError(t, err) bodyStr := rec1.Body.String() @@ -927,21 +920,19 @@ func TestApplicationLimits(t *testing.T) { { name: "Default", limit: nil, - expected: defaultApplicationsLimit, + expected: defaultOpts.DefaultApplicationsLimit, }, { name: "Max", limit: uint64Ptr(math.MaxUint64), - expected: maxApplicationsLimit, + expected: defaultOpts.MaxApplicationsLimit, }, } // Mock backend to capture default limits mockIndexer := &mocks.IndexerDb{} - si := ServerImplementation{ - db: mockIndexer, - timeout: 5 * time.Millisecond, - } + si := testServerImplementation(mockIndexer) + si.timeout = 5 * time.Millisecond for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { @@ -957,10 +948,9 @@ func TestApplicationLimits(t *testing.T) { Return(nil, uint64(0)). Run(func(args mock.Arguments) { require.Len(t, args, 2) - require.IsType(t, &generated.SearchForApplicationsParams{}, args[1]) - params := args[1].(*generated.SearchForApplicationsParams) - require.NotNil(t, params.Limit) - require.Equal(t, *params.Limit, tc.expected) + require.IsType(t, idb.ApplicationQuery{}, args[1]) + params := args[1].(idb.ApplicationQuery) + require.Equal(t, params.Limit, tc.expected) }) err := si.SearchForApplications(c, generated.SearchForApplicationsParams{ diff --git a/api/indexer.oas2.json b/api/indexer.oas2.json index 342da079f..a3c33ff8b 100644 --- a/api/indexer.oas2.json +++ b/api/indexer.oas2.json @@ -69,6 +69,9 @@ { "$ref": "#/parameters/include-all" }, + { + "$ref": "#/parameters/exclude" + }, { "$ref": "#/parameters/currency-less-than" }, @@ -120,6 +123,9 @@ }, { "$ref": "#/parameters/include-all" + }, + { + "$ref": "#/parameters/exclude" } ], "responses": { @@ -138,6 +144,190 @@ } } }, + "/v2/accounts/{account-id}/assets": { + "get": { + "description": "Lookup an account's asset holdings, optionally for a specific ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "lookup" + ], + "operationId": "lookupAccountAssets", + "parameters": [ + { + "$ref": "#/parameters/account-id" + }, + { + "$ref": "#/parameters/asset-id" + }, + { + "$ref": "#/parameters/include-all" + }, + { + "$ref": "#/parameters/limit" + }, + { + "$ref": "#/parameters/next" + } + ], + "responses": { + "200": { + "$ref": "#/responses/AssetHoldingsResponse" + }, + "400": { + "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/ErrorResponse" + }, + "500": { + "$ref": "#/responses/ErrorResponse" + } + } + } + }, + "/v2/accounts/{account-id}/created-assets": { + "get": { + "description": "Lookup an account's created asset parameters, optionally for a specific ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "lookup" + ], + "operationId": "lookupAccountCreatedAssets", + "parameters": [ + { + "$ref": "#/parameters/account-id" + }, + { + "$ref": "#/parameters/asset-id" + }, + { + "$ref": "#/parameters/include-all" + }, + { + "$ref": "#/parameters/limit" + }, + { + "$ref": "#/parameters/next" + } + ], + "responses": { + "200": { + "$ref": "#/responses/AssetsResponse" + }, + "400": { + "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/ErrorResponse" + }, + "500": { + "$ref": "#/responses/ErrorResponse" + } + } + } + }, + "/v2/accounts/{account-id}/apps-local-state": { + "get": { + "description": "Lookup an account's asset holdings, optionally for a specific ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "lookup" + ], + "operationId": "lookupAccountAppLocalStates", + "parameters": [ + { + "$ref": "#/parameters/account-id" + }, + { + "$ref": "#/parameters/application-id" + }, + { + "$ref": "#/parameters/include-all" + }, + { + "$ref": "#/parameters/limit" + }, + { + "$ref": "#/parameters/next" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ApplicationLocalStatesResponse" + }, + "400": { + "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/ErrorResponse" + }, + "500": { + "$ref": "#/responses/ErrorResponse" + } + } + } + }, + "/v2/accounts/{account-id}/created-applications": { + "get": { + "description": "Lookup an account's created application parameters, optionally for a specific ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "lookup" + ], + "operationId": "lookupAccountCreatedApplications", + "parameters": [ + { + "$ref": "#/parameters/account-id" + }, + { + "$ref": "#/parameters/application-id" + }, + { + "$ref": "#/parameters/include-all" + }, + { + "$ref": "#/parameters/limit" + }, + { + "$ref": "#/parameters/next" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ApplicationsResponse" + }, + "400": { + "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/ErrorResponse" + }, + "500": { + "$ref": "#/responses/ErrorResponse" + } + } + } + }, "/v2/accounts/{account-id}/transactions": { "get": { "description": "Lookup account transactions.", @@ -231,6 +421,12 @@ { "$ref": "#/parameters/application-id" }, + { + "type": "string", + "description": "Filter just applications with the given creator address.", + "name": "creator", + "in": "query" + }, { "$ref": "#/parameters/include-all" }, @@ -455,9 +651,6 @@ { "$ref": "#/parameters/next" }, - { - "$ref": "#/parameters/round" - }, { "$ref": "#/parameters/currency-greater-than" }, @@ -734,7 +927,11 @@ "pending-rewards", "amount-without-pending-rewards", "rewards", - "status" + "status", + "total-apps-opted-in", + "total-assets-opted-in", + "total-created-apps", + "total-created-assets" ], "properties": { "address": { @@ -817,6 +1014,22 @@ "lsig" ] }, + "total-apps-opted-in": { + "description": "The count of all applications that have been opted in, equivalent to the count of application local data (AppLocalState objects) stored in this account.", + "type": "integer" + }, + "total-assets-opted-in": { + "description": "The count of all assets that have been opted in, equivalent to the count of AssetHolding objects held by this account.", + "type": "integer" + }, + "total-created-apps": { + "description": "The count of all apps (AppParams objects) created by this account.", + "type": "integer" + }, + "total-created-assets": { + "description": "The count of all assets (AssetParams objects) created by this account.", + "type": "integer" + }, "auth-addr": { "description": "\\[spend\\] the address against which signing should be checked. If empty, the address of the current account is used. This field can be updated in any transaction by setting the RekeyTo field.", "type": "string", @@ -1112,7 +1325,6 @@ "type": "object", "required": [ "asset-id", - "creator", "amount", "is-frozen" ], @@ -1126,10 +1338,6 @@ "description": "Asset ID of the holding.", "type": "integer" }, - "creator": { - "description": "Address that created this asset. This is the address where the parameters for this asset can be found, and also the address where unwanted asset units can be sent in the worst case.", - "type": "string" - }, "is-frozen": { "description": "\\[f\\] whether or not the holding is frozen.", "type": "boolean" @@ -2092,6 +2300,23 @@ "name": "include-all", "in": "query" }, + "exclude": { + "description": "Exclude additional items such as asset holdings, application local data stored for this account, asset parameters created by this account, and application parameters created by this account.", + "name": "exclude", + "in": "query", + "type": "array", + "items": { + "type": "string", + "enum": [ + "all", + "assets", + "created-assets", + "apps-local-state", + "created-apps", + "none" + ] + } + }, "limit": { "type": "integer", "description": "Maximum number of results to return. There could be additional pages even if the limit is not reached.", @@ -2207,6 +2432,32 @@ } } }, + "AssetHoldingsResponse": { + "description": "(empty)", + "schema": { + "type": "object", + "required": [ + "current-round", + "assets" + ], + "properties": { + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/definitions/AssetHolding" + } + } + } + } + }, "AccountsResponse": { "description": "(empty)", "schema": { @@ -2334,6 +2585,32 @@ } } }, + "ApplicationLocalStatesResponse": { + "description": "(empty)", + "schema": { + "type": "object", + "required": [ + "current-round", + "apps-local-states" + ], + "properties": { + "apps-local-states": { + "type": "array", + "items": { + "$ref": "#/definitions/ApplicationLocalState" + } + }, + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + } + } + } + }, "AssetResponse": { "description": "(empty)", "schema": { diff --git a/api/indexer.oas3.yml b/api/indexer.oas3.yml index afb6456dc..4f86e7c9d 100644 --- a/api/indexer.oas3.yml +++ b/api/indexer.oas3.yml @@ -97,6 +97,27 @@ "type": "integer" } }, + "exclude": { + "description": "Exclude additional items such as asset holdings, application local data stored for this account, asset parameters created by this account, and application parameters created by this account.", + "explode": false, + "in": "query", + "name": "exclude", + "schema": { + "items": { + "enum": [ + "all", + "assets", + "created-assets", + "apps-local-state", + "created-apps", + "none" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, "exclude-close-to": { "description": "Combine with address and address-role parameters to define what type of address to search for. The close to fields are normally treated as a receiver, if you would like to exclude them set this parameter to true.", "in": "query", @@ -281,6 +302,36 @@ }, "description": "(empty)" }, + "ApplicationLocalStatesResponse": { + "content": { + "application/json": { + "schema": { + "properties": { + "apps-local-states": { + "items": { + "$ref": "#/components/schemas/ApplicationLocalState" + }, + "type": "array" + }, + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + } + }, + "required": [ + "apps-local-states", + "current-round" + ], + "type": "object" + } + } + }, + "description": "(empty)" + }, "ApplicationLogsResponse": { "content": { "application/json": { @@ -397,6 +448,36 @@ }, "description": "(empty)" }, + "AssetHoldingsResponse": { + "content": { + "application/json": { + "schema": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetHolding" + }, + "type": "array" + }, + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + } + }, + "required": [ + "assets", + "current-round" + ], + "type": "object" + } + } + }, + "description": "(empty)" + }, "AssetResponse": { "content": { "application/json": { @@ -647,6 +728,22 @@ "status": { "description": "\\[onl\\] delegation status of the account's MicroAlgos\n* Offline - indicates that the associated account is delegated.\n* Online - indicates that the associated account used as part of the delegation pool.\n* NotParticipating - indicates that the associated account is neither a delegator nor a delegate.", "type": "string" + }, + "total-apps-opted-in": { + "description": "The count of all applications that have been opted in, equivalent to the count of application local data (AppLocalState objects) stored in this account.", + "type": "integer" + }, + "total-assets-opted-in": { + "description": "The count of all assets that have been opted in, equivalent to the count of AssetHolding objects held by this account.", + "type": "integer" + }, + "total-created-apps": { + "description": "The count of all apps (AppParams objects) created by this account.", + "type": "integer" + }, + "total-created-assets": { + "description": "The count of all assets (AssetParams objects) created by this account.", + "type": "integer" } }, "required": [ @@ -656,7 +753,11 @@ "pending-rewards", "rewards", "round", - "status" + "status", + "total-apps-opted-in", + "total-assets-opted-in", + "total-created-apps", + "total-created-assets" ], "type": "object" }, @@ -910,10 +1011,6 @@ "description": "Asset ID of the holding.", "type": "integer" }, - "creator": { - "description": "Address that created this asset. This is the address where the parameters for this asset can be found, and also the address where unwanted asset units can be sent in the worst case.", - "type": "string" - }, "deleted": { "description": "Whether or not the asset holding is currently deleted from its account.", "type": "boolean" @@ -936,7 +1033,6 @@ "required": [ "amount", "asset-id", - "creator", "is-frozen" ], "type": "object" @@ -1923,6 +2019,27 @@ "type": "boolean" } }, + { + "description": "Exclude additional items such as asset holdings, application local data stored for this account, asset parameters created by this account, and application parameters created by this account.", + "explode": false, + "in": "query", + "name": "exclude", + "schema": { + "items": { + "enum": [ + "all", + "assets", + "created-assets", + "apps-local-state", + "created-apps", + "none" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, { "description": "Results should have an amount less than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used.", "in": "query", @@ -2068,6 +2185,27 @@ "schema": { "type": "boolean" } + }, + { + "description": "Exclude additional items such as asset holdings, application local data stored for this account, asset parameters created by this account, and application parameters created by this account.", + "explode": false, + "in": "query", + "name": "exclude", + "schema": { + "items": { + "enum": [ + "all", + "assets", + "created-assets", + "apps-local-state", + "created-apps", + "none" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" } ], "responses": { @@ -2166,159 +2304,51 @@ ] } }, - "/v2/accounts/{account-id}/transactions": { + "/v2/accounts/{account-id}/apps-local-state": { "get": { - "description": "Lookup account transactions.", - "operationId": "lookupAccountTransactions", + "description": "Lookup an account's asset holdings, optionally for a specific ID.", + "operationId": "lookupAccountAppLocalStates", "parameters": [ { - "description": "Maximum number of results to return. There could be additional pages even if the limit is not reached.", - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, - { - "description": "The next page of results. Use the next token provided by the previous results.", - "in": "query", - "name": "next", - "schema": { - "type": "string" - } - }, - { - "description": "Specifies a prefix which must be contained in the note field.", - "in": "query", - "name": "note-prefix", - "schema": { - "type": "string", - "x-algorand-format": "base64" - }, - "x-algorand-format": "base64" - }, - { - "in": "query", - "name": "tx-type", - "schema": { - "enum": [ - "pay", - "keyreg", - "acfg", - "axfer", - "afrz", - "appl" - ], - "type": "string" - } - }, - { - "description": "SigType filters just results using the specified type of signature:\n* sig - Standard\n* msig - MultiSig\n* lsig - LogicSig", - "in": "query", - "name": "sig-type", - "schema": { - "enum": [ - "sig", - "msig", - "lsig" - ], - "type": "string" - } - }, - { - "description": "Lookup the specific transaction by ID.", - "in": "query", - "name": "txid", + "description": "account string", + "in": "path", + "name": "account-id", + "required": true, "schema": { "type": "string" } }, { - "description": "Include results for the specified round.", - "in": "query", - "name": "round", - "schema": { - "type": "integer" - } - }, - { - "description": "Include results at or after the specified min-round.", - "in": "query", - "name": "min-round", - "schema": { - "type": "integer" - } - }, - { - "description": "Include results at or before the specified max-round.", + "description": "Application ID", "in": "query", - "name": "max-round", + "name": "application-id", "schema": { "type": "integer" } }, { - "description": "Asset ID", + "description": "Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates.", "in": "query", - "name": "asset-id", + "name": "include-all", "schema": { - "type": "integer" + "type": "boolean" } }, { - "description": "Include results before the given time. Must be an RFC 3339 formatted string.", - "in": "query", - "name": "before-time", - "schema": { - "format": "date-time", - "type": "string", - "x-algorand-format": "RFC3339 String" - }, - "x-algorand-format": "RFC3339 String" - }, - { - "description": "Include results after the given time. Must be an RFC 3339 formatted string.", - "in": "query", - "name": "after-time", - "schema": { - "format": "date-time", - "type": "string", - "x-algorand-format": "RFC3339 String" - }, - "x-algorand-format": "RFC3339 String" - }, - { - "description": "Results should have an amount greater than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used.", + "description": "Maximum number of results to return. There could be additional pages even if the limit is not reached.", "in": "query", - "name": "currency-greater-than", + "name": "limit", "schema": { "type": "integer" } }, { - "description": "Results should have an amount less than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used.", + "description": "The next page of results. Use the next token provided by the previous results.", "in": "query", - "name": "currency-less-than", - "schema": { - "type": "integer" - } - }, - { - "description": "account string", - "in": "path", - "name": "account-id", - "required": true, + "name": "next", "schema": { "type": "string" } - }, - { - "description": "Include results which include the rekey-to field.", - "in": "query", - "name": "rekey-to", - "schema": { - "type": "boolean" - } } ], "responses": { @@ -2327,6 +2357,12 @@ "application/json": { "schema": { "properties": { + "apps-local-states": { + "items": { + "$ref": "#/components/schemas/ApplicationLocalState" + }, + "type": "array" + }, "current-round": { "description": "Round at which the results were computed.", "type": "integer" @@ -2334,17 +2370,11 @@ "next-token": { "description": "Used for pagination, when making another request provide this token with the next parameter.", "type": "string" - }, - "transactions": { - "items": { - "$ref": "#/components/schemas/Transaction" - }, - "type": "array" } }, "required": [ - "current-round", - "transactions" + "apps-local-states", + "current-round" ], "type": "object" } @@ -2374,7 +2404,7 @@ }, "description": "Response for errors" }, - "500": { + "404": { "content": { "application/json": { "schema": { @@ -2395,24 +2425,740 @@ } }, "description": "Response for errors" - } - }, - "tags": [ - "lookup" - ] - } - }, - "/v2/applications": { - "get": { - "description": "Search for applications", - "operationId": "searchForApplications", - "parameters": [ - { - "description": "Application ID", - "in": "query", - "name": "application-id", - "schema": { - "type": "integer" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + } + }, + "tags": [ + "lookup" + ] + } + }, + "/v2/accounts/{account-id}/assets": { + "get": { + "description": "Lookup an account's asset holdings, optionally for a specific ID.", + "operationId": "lookupAccountAssets", + "parameters": [ + { + "description": "account string", + "in": "path", + "name": "account-id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Asset ID", + "in": "query", + "name": "asset-id", + "schema": { + "type": "integer" + } + }, + { + "description": "Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates.", + "in": "query", + "name": "include-all", + "schema": { + "type": "boolean" + } + }, + { + "description": "Maximum number of results to return. There could be additional pages even if the limit is not reached.", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "description": "The next page of results. Use the next token provided by the previous results.", + "in": "query", + "name": "next", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetHolding" + }, + "type": "array" + }, + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + } + }, + "required": [ + "assets", + "current-round" + ], + "type": "object" + } + } + }, + "description": "(empty)" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + } + }, + "tags": [ + "lookup" + ] + } + }, + "/v2/accounts/{account-id}/created-applications": { + "get": { + "description": "Lookup an account's created application parameters, optionally for a specific ID.", + "operationId": "lookupAccountCreatedApplications", + "parameters": [ + { + "description": "account string", + "in": "path", + "name": "account-id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Application ID", + "in": "query", + "name": "application-id", + "schema": { + "type": "integer" + } + }, + { + "description": "Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates.", + "in": "query", + "name": "include-all", + "schema": { + "type": "boolean" + } + }, + { + "description": "Maximum number of results to return. There could be additional pages even if the limit is not reached.", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "description": "The next page of results. Use the next token provided by the previous results.", + "in": "query", + "name": "next", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "applications": { + "items": { + "$ref": "#/components/schemas/Application" + }, + "type": "array" + }, + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + } + }, + "required": [ + "applications", + "current-round" + ], + "type": "object" + } + } + }, + "description": "(empty)" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + } + }, + "tags": [ + "lookup" + ] + } + }, + "/v2/accounts/{account-id}/created-assets": { + "get": { + "description": "Lookup an account's created asset parameters, optionally for a specific ID.", + "operationId": "lookupAccountCreatedAssets", + "parameters": [ + { + "description": "account string", + "in": "path", + "name": "account-id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Asset ID", + "in": "query", + "name": "asset-id", + "schema": { + "type": "integer" + } + }, + { + "description": "Include all items including closed accounts, deleted applications, destroyed assets, opted-out asset holdings, and closed-out application localstates.", + "in": "query", + "name": "include-all", + "schema": { + "type": "boolean" + } + }, + { + "description": "Maximum number of results to return. There could be additional pages even if the limit is not reached.", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "description": "The next page of results. Use the next token provided by the previous results.", + "in": "query", + "name": "next", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/Asset" + }, + "type": "array" + }, + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + } + }, + "required": [ + "assets", + "current-round" + ], + "type": "object" + } + } + }, + "description": "(empty)" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + } + }, + "tags": [ + "lookup" + ] + } + }, + "/v2/accounts/{account-id}/transactions": { + "get": { + "description": "Lookup account transactions.", + "operationId": "lookupAccountTransactions", + "parameters": [ + { + "description": "Maximum number of results to return. There could be additional pages even if the limit is not reached.", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "description": "The next page of results. Use the next token provided by the previous results.", + "in": "query", + "name": "next", + "schema": { + "type": "string" + } + }, + { + "description": "Specifies a prefix which must be contained in the note field.", + "in": "query", + "name": "note-prefix", + "schema": { + "type": "string", + "x-algorand-format": "base64" + }, + "x-algorand-format": "base64" + }, + { + "in": "query", + "name": "tx-type", + "schema": { + "enum": [ + "pay", + "keyreg", + "acfg", + "axfer", + "afrz", + "appl" + ], + "type": "string" + } + }, + { + "description": "SigType filters just results using the specified type of signature:\n* sig - Standard\n* msig - MultiSig\n* lsig - LogicSig", + "in": "query", + "name": "sig-type", + "schema": { + "enum": [ + "sig", + "msig", + "lsig" + ], + "type": "string" + } + }, + { + "description": "Lookup the specific transaction by ID.", + "in": "query", + "name": "txid", + "schema": { + "type": "string" + } + }, + { + "description": "Include results for the specified round.", + "in": "query", + "name": "round", + "schema": { + "type": "integer" + } + }, + { + "description": "Include results at or after the specified min-round.", + "in": "query", + "name": "min-round", + "schema": { + "type": "integer" + } + }, + { + "description": "Include results at or before the specified max-round.", + "in": "query", + "name": "max-round", + "schema": { + "type": "integer" + } + }, + { + "description": "Asset ID", + "in": "query", + "name": "asset-id", + "schema": { + "type": "integer" + } + }, + { + "description": "Include results before the given time. Must be an RFC 3339 formatted string.", + "in": "query", + "name": "before-time", + "schema": { + "format": "date-time", + "type": "string", + "x-algorand-format": "RFC3339 String" + }, + "x-algorand-format": "RFC3339 String" + }, + { + "description": "Include results after the given time. Must be an RFC 3339 formatted string.", + "in": "query", + "name": "after-time", + "schema": { + "format": "date-time", + "type": "string", + "x-algorand-format": "RFC3339 String" + }, + "x-algorand-format": "RFC3339 String" + }, + { + "description": "Results should have an amount greater than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used.", + "in": "query", + "name": "currency-greater-than", + "schema": { + "type": "integer" + } + }, + { + "description": "Results should have an amount less than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used.", + "in": "query", + "name": "currency-less-than", + "schema": { + "type": "integer" + } + }, + { + "description": "account string", + "in": "path", + "name": "account-id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Include results which include the rekey-to field.", + "in": "query", + "name": "rekey-to", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "current-round": { + "description": "Round at which the results were computed.", + "type": "integer" + }, + "next-token": { + "description": "Used for pagination, when making another request provide this token with the next parameter.", + "type": "string" + }, + "transactions": { + "items": { + "$ref": "#/components/schemas/Transaction" + }, + "type": "array" + } + }, + "required": [ + "current-round", + "transactions" + ], + "type": "object" + } + } + }, + "description": "(empty)" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "Response for errors" + } + }, + "tags": [ + "lookup" + ] + } + }, + "/v2/applications": { + "get": { + "description": "Search for applications", + "operationId": "searchForApplications", + "parameters": [ + { + "description": "Application ID", + "in": "query", + "name": "application-id", + "schema": { + "type": "integer" + } + }, + { + "description": "Filter just applications with the given creator address.", + "in": "query", + "name": "creator", + "schema": { + "type": "string" } }, { @@ -2989,14 +3735,6 @@ "type": "string" } }, - { - "description": "Include results for the specified round.", - "in": "query", - "name": "round", - "schema": { - "type": "integer" - } - }, { "description": "Results should have an amount greater than this value. MicroAlgos are the default currency unless an asset-id is provided, in which case the asset will be used.", "in": "query", diff --git a/api/server.go b/api/server.go index 74f5616ee..6854eb7a8 100644 --- a/api/server.go +++ b/api/server.go @@ -40,6 +40,35 @@ type ExtraOptions struct { // DisabledMapConfig is the disabled map configuration that is being used by the server DisabledMapConfig *DisabledMapConfig + + // MaxAPIResourcesPerAccount is the maximum number of combined AppParams, AppLocalState, AssetParams, + // and AssetHolding resources per address that can be returned by the /v2/accounts endpoints. + // If an address exceeds this number, a 400 error is returned. Zero means unlimited. + MaxAPIResourcesPerAccount uint64 + + ///////////////////// + // Limit Constants // + ///////////////////// + + // Transactions + MaxTransactionsLimit uint64 + DefaultTransactionsLimit uint64 + + // Accounts + MaxAccountsLimit uint64 + DefaultAccountsLimit uint64 + + // Assets + MaxAssetsLimit uint64 + DefaultAssetsLimit uint64 + + // Asset Balances + MaxBalancesLimit uint64 + DefaultBalancesLimit uint64 + + // Applications + MaxApplicationsLimit uint64 + DefaultApplicationsLimit uint64 } func (e ExtraOptions) handlerTimeout() time.Duration { @@ -98,6 +127,7 @@ func Serve(ctx context.Context, serveAddr string, db idb.IndexerDb, fetcherError timeout: options.handlerTimeout(), log: log, disabledParams: disabledMap, + opts: options, } generated.RegisterHandlers(e, &api, middleware...) diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go index c0d63efbd..dba0b3ff5 100644 --- a/cmd/algorand-indexer/daemon.go +++ b/cmd/algorand-indexer/daemon.go @@ -24,19 +24,30 @@ import ( ) var ( - algodDataDir string - algodAddr string - algodToken string - daemonServerAddr string - noAlgod bool - developerMode bool - allowMigration bool - metricsMode string - tokenString string - writeTimeout time.Duration - readTimeout time.Duration - maxConn uint32 - enableAllParameters bool + algodDataDir string + algodAddr string + algodToken string + daemonServerAddr string + noAlgod bool + developerMode bool + allowMigration bool + metricsMode string + tokenString string + writeTimeout time.Duration + readTimeout time.Duration + maxConn uint32 + maxAPIResourcesPerAccount uint32 + maxTransactionsLimit uint32 + defaultTransactionsLimit uint32 + maxAccountsLimit uint32 + defaultAccountsLimit uint32 + maxAssetsLimit uint32 + defaultAssetsLimit uint32 + maxBalancesLimit uint32 + defaultBalancesLimit uint32 + maxApplicationsLimit uint32 + defaultApplicationsLimit uint32 + enableAllParameters bool ) const paramConfigEnableFlag = false @@ -168,6 +179,19 @@ func init() { daemonCmd.Flags().MarkHidden("enable-all-parameters") } + daemonCmd.Flags().Uint32VarP(&maxAPIResourcesPerAccount, "max-api-resources-per-account", "", 0, "set the maximum total number of resources (created assets, created apps, asset holdings, and application local state) per account that will be allowed in REST API lookupAccountByID and searchForAccounts responses before returning a 400 Bad Request. Set zero for no limit (default: unlimited)") + + daemonCmd.Flags().Uint32VarP(&maxTransactionsLimit, "max-transactions-limit", "", 10000, "set the maximum allowed Limit parameter for querying transactions") + daemonCmd.Flags().Uint32VarP(&defaultTransactionsLimit, "default-transactions-limit", "", 1000, "set the default Limit parameter for querying transactions, if none is provided") + daemonCmd.Flags().Uint32VarP(&maxAccountsLimit, "max-accounts-limit", "", 1000, "set the maximum allowed Limit parameter for querying accounts") + daemonCmd.Flags().Uint32VarP(&defaultAccountsLimit, "default-accounts-limit", "", 100, "set the default Limit parameter for querying accounts, if none is provided") + daemonCmd.Flags().Uint32VarP(&maxAssetsLimit, "max-assets-limit", "", 1000, "set the maximum allowed Limit parameter for querying assets") + daemonCmd.Flags().Uint32VarP(&defaultAssetsLimit, "default-assets-limit", "", 100, "set the default Limit parameter for querying assets, if none is provided") + daemonCmd.Flags().Uint32VarP(&maxBalancesLimit, "max-balances-limit", "", 10000, "set the maximum allowed Limit parameter for querying balances") + daemonCmd.Flags().Uint32VarP(&defaultBalancesLimit, "default-balances-limit", "", 1000, "set the default Limit parameter for querying balances, if none is provided") + daemonCmd.Flags().Uint32VarP(&maxApplicationsLimit, "max-applications-limit", "", 1000, "set the maximum allowed Limit parameter for querying applications") + daemonCmd.Flags().Uint32VarP(&defaultApplicationsLimit, "default-applications-limit", "", 100, "set the default Limit parameter for querying applications, if none is provided") + viper.RegisterAlias("algod", "algod-data-dir") viper.RegisterAlias("algod-net", "algod-address") viper.RegisterAlias("server", "server-address") @@ -195,6 +219,18 @@ func makeOptions() (options api.ExtraOptions) { options.WriteTimeout = writeTimeout options.ReadTimeout = readTimeout + options.MaxAPIResourcesPerAccount = uint64(maxAPIResourcesPerAccount) + options.MaxTransactionsLimit = uint64(maxTransactionsLimit) + options.DefaultTransactionsLimit = uint64(defaultTransactionsLimit) + options.MaxAccountsLimit = uint64(maxAccountsLimit) + options.DefaultAccountsLimit = uint64(defaultAccountsLimit) + options.MaxAssetsLimit = uint64(maxAssetsLimit) + options.DefaultAssetsLimit = uint64(defaultAssetsLimit) + options.MaxBalancesLimit = uint64(maxBalancesLimit) + options.DefaultBalancesLimit = uint64(defaultBalancesLimit) + options.MaxApplicationsLimit = uint64(maxApplicationsLimit) + options.DefaultApplicationsLimit = uint64(defaultApplicationsLimit) + if paramConfigEnableFlag { if enableAllParameters { options.DisabledMapConfig = api.MakeDisabledMapConfig() diff --git a/cmd/block-generator/generator/generate.go b/cmd/block-generator/generator/generate.go index 6ed7eb4ea..9b221eaa9 100644 --- a/cmd/block-generator/generator/generate.go +++ b/cmd/block-generator/generator/generate.go @@ -681,7 +681,6 @@ func (g *generator) WriteAccount(output io.Writer, accountString string) error { assets = append(assets, generated.AssetHolding{ Amount: holding.balance, AssetId: a.assetID, - Creator: indexToAccount(a.creator).String(), IsFrozen: false, }) } diff --git a/cmd/idbtest/idbtest.go b/cmd/idbtest/idbtest.go index 1f945342c..465932287 100644 --- a/cmd/idbtest/idbtest.go +++ b/cmd/idbtest/idbtest.go @@ -134,7 +134,13 @@ func main() { <-availableCh if accounttest { - printAccountQuery(db, idb.AccountQueryOptions{IncludeAssetHoldings: true, IncludeAssetParams: true, AlgosGreaterThan: uint64Ptr(10000000000), Limit: 20}) + printAccountQuery(db, idb.AccountQueryOptions{ + IncludeAssetHoldings: true, + IncludeAssetParams: true, + IncludeAppLocalState: true, + IncludeAppParams: true, + AlgosGreaterThan: uint64Ptr(10000000000), + Limit: 20}) printAccountQuery(db, idb.AccountQueryOptions{HasAssetID: 312769, Limit: 19}) } if assettest { diff --git a/cmd/import-validator/README.md b/cmd/import-validator/README.md index 7a9e83000..aaffa31e7 100644 --- a/cmd/import-validator/README.md +++ b/cmd/import-validator/README.md @@ -1,14 +1,14 @@ # Import validation tool The import validator tool imports blocks into indexer database and algod's -sqlite database in lockstep and checks that the modified accounts are the same -in the two databases. +sqlite database in lockstep and checks that the modified accounts and resources +are the same in the two databases. It lets us detect the first round where an accounting discrepancy occurs and it prints out the difference before crashing. There is a small limitation, however. -The set of modified accounts is computed using the sqlite database. -Thus, if indexer's accounting were to modify a superset of those accounts, +The list of modified address and resources is computed using the sqlite database. +Thus, if indexer's accounting were to modify a superset of that data, this tool would not detect it. This, however, should be unlikely. @@ -45,5 +45,5 @@ or one round behind; otherwise, the import validator will fail to start. Reading and writing to/from the sqlite database is negligible compared to importing blocks into the postgres database. -However, the tool has to read the modified accounts after importing each block. -Thus, we can expect the import validator to be 1.5 times slower than indexer. +However, the tool has to read the modified state after importing each block. +Thus, we can expect the import validator to be about 1.5 times slower than indexer. diff --git a/cmd/import-validator/core/service.go b/cmd/import-validator/core/service.go index 27874faf6..66a91afc2 100644 --- a/cmd/import-validator/core/service.go +++ b/cmd/import-validator/core/service.go @@ -128,37 +128,91 @@ func openLedger(ledgerPath string, genesis *bookkeeping.Genesis, genesisBlock *b return ledger, nil } -func getModifiedAccounts(l *ledger.Ledger, block *bookkeeping.Block) ([]basics.Address, error) { +func getModifiedState(l *ledger.Ledger, block *bookkeeping.Block) (map[basics.Address]struct{}, map[basics.Address]map[ledger.Creatable]struct{}, error) { eval, err := l.StartEvaluator(block.BlockHeader, len(block.Payset), 0) if err != nil { - return nil, fmt.Errorf("changedAccounts() start evaluator err: %w", err) + return nil, nil, fmt.Errorf("getModifiedState() start evaluator err: %w", err) } paysetgroups, err := block.DecodePaysetGroups() if err != nil { - return nil, fmt.Errorf("changedAccounts() decode payset groups err: %w", err) + return nil, nil, fmt.Errorf("getModifiedState() decode payset groups err: %w", err) } for _, group := range paysetgroups { err = eval.TransactionGroup(group) if err != nil { - return nil, fmt.Errorf("changedAccounts() apply transaction group err: %w", err) + return nil, nil, + fmt.Errorf("getModifiedState() apply transaction group err: %w", err) } } vb, err := eval.GenerateBlock() if err != nil { - return nil, fmt.Errorf("changedAccounts() generate block err: %w", err) + return nil, nil, fmt.Errorf("getModifiedState() generate block err: %w", err) } accountDeltas := vb.Delta().Accts - return accountDeltas.ModifiedAccounts(), nil + + modifiedAccounts := make(map[basics.Address]struct{}) + for _, address := range accountDeltas.ModifiedAccounts() { + modifiedAccounts[address] = struct{}{} + } + + modifiedResources := make(map[basics.Address]map[ledger.Creatable]struct{}) + for _, r := range accountDeltas.GetAllAssetResources() { + c, ok := modifiedResources[r.Addr] + if !ok { + c = make(map[ledger.Creatable]struct{}) + modifiedResources[r.Addr] = c + } + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(r.Aidx), + Type: basics.AssetCreatable, + } + c[creatable] = struct{}{} + } + for _, r := range accountDeltas.GetAllAppResources() { + c, ok := modifiedResources[r.Addr] + if !ok { + c = make(map[ledger.Creatable]struct{}) + modifiedResources[r.Addr] = c + } + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(r.Aidx), + Type: basics.AppCreatable, + } + c[creatable] = struct{}{} + } + + return modifiedAccounts, modifiedResources, nil +} + +func normalizeAccountResource(r ledgercore.AccountResource) ledgercore.AccountResource { + if (r.AppParams != nil) && (len(r.AppParams.GlobalState) == 0) { + // Make a copy of `AppParams` to avoid modifying ledger's storage. + appParams := new(basics.AppParams) + *appParams = *r.AppParams + appParams.GlobalState = nil + r.AppParams = appParams + } + if (r.AppLocalState != nil) && (len(r.AppLocalState.KeyValue) == 0) { + // Make a copy of `AppLocalState` to avoid modifying ledger's storage. + appLocalState := new(basics.AppLocalState) + *appLocalState = *r.AppLocalState + appLocalState.KeyValue = nil + r.AppLocalState = appLocalState + } + + return r } -func checkModifiedAccounts(db *postgres.IndexerDb, l *ledger.Ledger, block *bookkeeping.Block, addresses []basics.Address) error { - var accountsIndexer map[basics.Address]basics.AccountData +func checkModifiedState(db *postgres.IndexerDb, l *ledger.Ledger, block *bookkeeping.Block, addresses map[basics.Address]struct{}, resources map[basics.Address]map[ledger.Creatable]struct{}) error { + var accountsIndexer map[basics.Address]ledgercore.AccountData + var resourcesIndexer map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource var err0 error - var accountsAlgod map[basics.Address]basics.AccountData + var accountsAlgod map[basics.Address]ledgercore.AccountData + var resourcesAlgod map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource var err1 error var wg sync.WaitGroup @@ -166,64 +220,50 @@ func checkModifiedAccounts(db *postgres.IndexerDb, l *ledger.Ledger, block *book go func() { defer wg.Done() - accountsIndexer, err0 = db.GetAccountData(addresses) + var accounts map[basics.Address]*ledgercore.AccountData + accounts, resourcesIndexer, err0 = db.GetAccountState(addresses, resources) if err0 != nil { - err0 = fmt.Errorf("checkModifiedAccounts() err0: %w", err0) + err0 = fmt.Errorf("checkModifiedState() err0: %w", err0) return } + + accountsIndexer = make(map[basics.Address]ledgercore.AccountData, len(accounts)) + for address, accountData := range accounts { + if accountData == nil { + accountsIndexer[address] = ledgercore.AccountData{} + } else { + accountsIndexer[address] = *accountData + } + } }() wg.Add(1) go func() { defer wg.Done() - accountsAlgod = make(map[basics.Address]basics.AccountData, len(addresses)) - for _, address := range addresses { - var accountData basics.AccountData - accountData, _, err1 = l.LookupWithoutRewards(block.Round(), address) + accountsAlgod = make(map[basics.Address]ledgercore.AccountData, len(addresses)) + for address := range addresses { + accountsAlgod[address], _, err1 = l.LookupWithoutRewards(block.Round(), address) if err1 != nil { - err1 = fmt.Errorf("checkModifiedAccounts() lookup err1: %w", err1) + err1 = fmt.Errorf("checkModifiedState() lookup account err1: %w", err1) return } - - // Indexer returns nil for these maps if they are empty. Unfortunately, - // in go-algorand it's not well defined, and sometimes ledger returns empty - // maps and sometimes nil maps. So we set those maps to nil if they are empty so - // that comparison works. - if len(accountData.AssetParams) == 0 { - accountData.AssetParams = nil - } - if len(accountData.Assets) == 0 { - accountData.Assets = nil - } - - if accountData.AppParams != nil { - // Make a copy of `AppParams` to avoid modifying ledger's storage. - appParams := - make(map[basics.AppIndex]basics.AppParams, len(accountData.AppParams)) - for index, params := range accountData.AppParams { - if len(params.GlobalState) == 0 { - params.GlobalState = nil - } - appParams[index] = params - } - accountData.AppParams = appParams - } - - if accountData.AppLocalStates != nil { - // Make a copy of `AppLocalStates` to avoid modifying ledger's storage. - appLocalStates := - make(map[basics.AppIndex]basics.AppLocalState, len(accountData.AppLocalStates)) - for index, state := range accountData.AppLocalStates { - if len(state.KeyValue) == 0 { - state.KeyValue = nil - } - appLocalStates[index] = state + } + resourcesAlgod = + make(map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource) + for address, creatables := range resources { + resourcesForAddress := make(map[ledger.Creatable]ledgercore.AccountResource) + resourcesAlgod[address] = resourcesForAddress + for creatable := range creatables { + var resource ledgercore.AccountResource + resource, err1 = + l.LookupResource(block.Round(), address, creatable.Index, creatable.Type) + if err1 != nil { + err1 = fmt.Errorf("checkModifiedState() lookup resource err1: %w", err1) + return } - accountData.AppLocalStates = appLocalStates + resourcesForAddress[creatable] = normalizeAccountResource(resource) } - - accountsAlgod[address] = accountData } }() @@ -238,10 +278,17 @@ func checkModifiedAccounts(db *postgres.IndexerDb, l *ledger.Ledger, block *book if !reflect.DeepEqual(accountsIndexer, accountsAlgod) { diff := util.Diff(accountsAlgod, accountsIndexer) return fmt.Errorf( - "checkModifiedAccounts() accounts differ,"+ + "checkModifiedState() accounts differ,"+ "\naccountsIndexer: %+v,\naccountsAlgod: %+v,\ndiff: %s", accountsIndexer, accountsAlgod, diff) } + if !reflect.DeepEqual(resourcesIndexer, resourcesAlgod) { + diff := util.Diff(resourcesAlgod, resourcesIndexer) + return fmt.Errorf( + "checkModifiedState() resources differ,"+ + "\nresourcesIndexer: %+v,\nresourcesAlgod: %+v,\ndiff: %s", + resourcesIndexer, resourcesAlgod, diff) + } return nil } @@ -267,14 +314,15 @@ func catchup(db *postgres.IndexerDb, l *ledger.Ledger, bot fetcher.Fetcher, logg } blockHandlerFunc := func(ctx context.Context, block *rpcs.EncodedBlockCert) error { - var modifiedAccounts []basics.Address + var modifiedAccounts map[basics.Address]struct{} + var modifiedResources map[basics.Address]map[ledger.Creatable]struct{} var err0 error var err1 error var wg sync.WaitGroup wg.Add(1) go func() { - modifiedAccounts, err0 = getModifiedAccounts(l, &block.Block) + modifiedAccounts, modifiedResources, err0 = getModifiedState(l, &block.Block) wg.Done() }() @@ -307,7 +355,8 @@ func catchup(db *postgres.IndexerDb, l *ledger.Ledger, bot fetcher.Fetcher, logg } nextRoundLedger++ - return checkModifiedAccounts(db, l, &block.Block, modifiedAccounts) + return checkModifiedState( + db, l, &block.Block, modifiedAccounts, modifiedResources) } bot.SetBlockHandler(blockHandlerFunc) bot.SetNextRound(nextRoundLedger) diff --git a/idb/dummy/dummy.go b/idb/dummy/dummy.go index 29ca96e61..d4cc71980 100644 --- a/idb/dummy/dummy.go +++ b/idb/dummy/dummy.go @@ -7,7 +7,6 @@ import ( "github.com/algorand/go-algorand/data/transactions" log "github.com/sirupsen/logrus" - models "github.com/algorand/indexer/api/generated/v2" "github.com/algorand/indexer/idb" ) @@ -74,7 +73,12 @@ func (db *dummyIndexerDb) AssetBalances(ctx context.Context, abq idb.AssetBalanc } // Applications is part of idb.IndexerDB -func (db *dummyIndexerDb) Applications(ctx context.Context, filter *models.SearchForApplicationsParams) (<-chan idb.ApplicationRow, uint64) { +func (db *dummyIndexerDb) Applications(ctx context.Context, filter idb.ApplicationQuery) (<-chan idb.ApplicationRow, uint64) { + return nil, 0 +} + +// AppLocalState is part of idb.IndexerDB +func (db *dummyIndexerDb) AppLocalState(ctx context.Context, filter idb.ApplicationQuery) (<-chan idb.AppLocalStateRow, uint64) { return nil, 0 } diff --git a/idb/idb.go b/idb/idb.go index fd86382a7..82901ed1c 100644 --- a/idb/idb.go +++ b/idb/idb.go @@ -179,7 +179,8 @@ type IndexerDb interface { GetAccounts(ctx context.Context, opts AccountQueryOptions) (<-chan AccountRow, uint64) Assets(ctx context.Context, filter AssetsQuery) (<-chan AssetRow, uint64) AssetBalances(ctx context.Context, abq AssetBalanceQuery) (<-chan AssetBalanceRow, uint64) - Applications(ctx context.Context, filter *models.SearchForApplicationsParams) (<-chan ApplicationRow, uint64) + Applications(ctx context.Context, filter ApplicationQuery) (<-chan ApplicationRow, uint64) + AppLocalState(ctx context.Context, filter ApplicationQuery) (<-chan AppLocalStateRow, uint64) Health(ctx context.Context) (status Health, err error) } @@ -259,6 +260,11 @@ type AccountQueryOptions struct { IncludeAssetHoldings bool IncludeAssetParams bool + IncludeAppLocalState bool + IncludeAppParams bool + + // MaxResources is the maximum combined number of AppParam, AppLocalState, AssetParam, and AssetHolding objects allowed. + MaxResources uint64 // IncludeDeleted indicated whether to include deleted Assets, Applications, etc within the account. IncludeDeleted bool @@ -269,7 +275,18 @@ type AccountQueryOptions struct { // AccountRow is metadata relating to one account in a account query. type AccountRow struct { Account models.Account - Error error + Error error // could be MaxAPIResourcesPerAccountError +} + +// MaxAPIResourcesPerAccountError records the offending address and resource count that exceeded the limit. +type MaxAPIResourcesPerAccountError struct { + Address basics.Address + + TotalAppLocalStates, TotalAppParams, TotalAssets, TotalAssetParams uint64 +} + +func (e MaxAPIResourcesPerAccountError) Error() string { + return "Max accounts API results limit exceeded" } // AssetsQuery is a parameter object with all of the asset filter options. @@ -306,9 +323,12 @@ type AssetRow struct { // AssetBalanceQuery is a parameter object with all of the asset balance filter options. type AssetBalanceQuery struct { - AssetID uint64 - AmountGT *uint64 // only rows > this - AmountLT *uint64 // only rows < this + AssetID uint64 + AssetIDGT uint64 + AmountGT *uint64 // only rows > this + AmountLT *uint64 // only rows < this + + Address []byte // IncludeDeleted indicated whether to include deleted AssetHoldingss in the results. IncludeDeleted bool @@ -332,12 +352,27 @@ type AssetBalanceRow struct { Deleted *bool } -// ApplicationRow is metadata relating to one application in an application query. +// ApplicationRow is metadata and global state (AppParams) relating to one application in an application query. type ApplicationRow struct { Application models.Application Error error } +// ApplicationQuery is a parameter object used for query local and global application state. +type ApplicationQuery struct { + Address []byte + ApplicationID uint64 + ApplicationIDGreaterThan uint64 + IncludeDeleted bool + Limit uint64 +} + +// AppLocalStateRow is metadata and local state (AppLocalState) relating to one application in an application query. +type AppLocalStateRow struct { + AppLocalState models.ApplicationLocalState + Error error +} + // IndexerDbOptions are the options common to all indexer backends. type IndexerDbOptions struct { ReadOnly bool diff --git a/idb/mocks/IndexerDb.go b/idb/mocks/IndexerDb.go index 1a110084a..0de088c21 100644 --- a/idb/mocks/IndexerDb.go +++ b/idb/mocks/IndexerDb.go @@ -7,8 +7,6 @@ import ( bookkeeping "github.com/algorand/go-algorand/data/bookkeeping" - generated "github.com/algorand/indexer/api/generated/v2" - idb "github.com/algorand/indexer/idb" mock "github.com/stretchr/testify/mock" @@ -35,12 +33,35 @@ func (_m *IndexerDb) AddBlock(block *bookkeeping.Block) error { return r0 } +// AppLocalState provides a mock function with given fields: ctx, filter +func (_m *IndexerDb) AppLocalState(ctx context.Context, filter idb.ApplicationQuery) (<-chan idb.AppLocalStateRow, uint64) { + ret := _m.Called(ctx, filter) + + var r0 <-chan idb.AppLocalStateRow + if rf, ok := ret.Get(0).(func(context.Context, idb.ApplicationQuery) <-chan idb.AppLocalStateRow); ok { + r0 = rf(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan idb.AppLocalStateRow) + } + } + + var r1 uint64 + if rf, ok := ret.Get(1).(func(context.Context, idb.ApplicationQuery) uint64); ok { + r1 = rf(ctx, filter) + } else { + r1 = ret.Get(1).(uint64) + } + + return r0, r1 +} + // Applications provides a mock function with given fields: ctx, filter -func (_m *IndexerDb) Applications(ctx context.Context, filter *generated.SearchForApplicationsParams) (<-chan idb.ApplicationRow, uint64) { +func (_m *IndexerDb) Applications(ctx context.Context, filter idb.ApplicationQuery) (<-chan idb.ApplicationRow, uint64) { ret := _m.Called(ctx, filter) var r0 <-chan idb.ApplicationRow - if rf, ok := ret.Get(0).(func(context.Context, *generated.SearchForApplicationsParams) <-chan idb.ApplicationRow); ok { + if rf, ok := ret.Get(0).(func(context.Context, idb.ApplicationQuery) <-chan idb.ApplicationRow); ok { r0 = rf(ctx, filter) } else { if ret.Get(0) != nil { @@ -49,7 +70,7 @@ func (_m *IndexerDb) Applications(ctx context.Context, filter *generated.SearchF } var r1 uint64 - if rf, ok := ret.Get(1).(func(context.Context, *generated.SearchForApplicationsParams) uint64); ok { + if rf, ok := ret.Get(1).(func(context.Context, idb.ApplicationQuery) uint64); ok { r1 = rf(ctx, filter) } else { r1 = ret.Get(1).(uint64) diff --git a/idb/postgres/internal/encoding/encoding.go b/idb/postgres/internal/encoding/encoding.go index b792cae86..6f6bf35dd 100644 --- a/idb/postgres/internal/encoding/encoding.go +++ b/idb/postgres/internal/encoding/encoding.go @@ -366,20 +366,6 @@ func DecodeSignedTxnWithAD(data []byte) (transactions.SignedTxnWithAD, error) { return unconvertSignedTxnWithAD(stxn), nil } -// TrimAccountData deletes various information from account data that we do not write to -// `account.account_data`. -func TrimAccountData(ad basics.AccountData) basics.AccountData { - ad.MicroAlgos = basics.MicroAlgos{} - ad.RewardsBase = 0 - ad.RewardedMicroAlgos = basics.MicroAlgos{} - ad.AssetParams = nil - ad.Assets = nil - ad.AppLocalStates = nil - ad.AppParams = nil - - return ad -} - func convertTrimmedAccountData(ad basics.AccountData) trimmedAccountData { return trimmedAccountData{ AccountData: ad, @@ -683,3 +669,72 @@ func DecodeNetworkState(data []byte) (types.NetworkState, error) { return state, nil } + +// TrimLcAccountData deletes various information from account data that we do not write +// to `account.account_data`. +func TrimLcAccountData(ad ledgercore.AccountData) ledgercore.AccountData { + ad.MicroAlgos = basics.MicroAlgos{} + ad.RewardsBase = 0 + ad.RewardedMicroAlgos = basics.MicroAlgos{} + return ad +} + +func convertTrimmedLcAccountData(ad ledgercore.AccountData) baseAccountData { + return baseAccountData{ + Status: ad.Status, + AuthAddr: crypto.Digest(ad.AuthAddr), + TotalAppSchema: ad.TotalAppSchema, + TotalExtraAppPages: ad.TotalExtraAppPages, + TotalAssetParams: ad.TotalAssetParams, + TotalAssets: ad.TotalAssets, + TotalAppParams: ad.TotalAppParams, + TotalAppLocalStates: ad.TotalAppLocalStates, + baseOnlineAccountData: baseOnlineAccountData{ + VoteID: ad.VoteID, + SelectionID: ad.SelectionID, + StateProofID: ad.StateProofID, + VoteFirstValid: ad.VoteFirstValid, + VoteLastValid: ad.VoteLastValid, + VoteKeyDilution: ad.VoteKeyDilution, + }, + } +} + +func unconvertTrimmedLcAccountData(ba baseAccountData) ledgercore.AccountData { + return ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + Status: ba.Status, + AuthAddr: basics.Address(ba.AuthAddr), + TotalAppSchema: ba.TotalAppSchema, + TotalExtraAppPages: ba.TotalExtraAppPages, + TotalAppParams: ba.TotalAppParams, + TotalAppLocalStates: ba.TotalAppLocalStates, + TotalAssetParams: ba.TotalAssetParams, + TotalAssets: ba.TotalAssets, + }, + VotingData: ledgercore.VotingData{ + VoteID: ba.VoteID, + SelectionID: ba.SelectionID, + StateProofID: ba.StateProofID, + VoteFirstValid: ba.VoteFirstValid, + VoteLastValid: ba.VoteLastValid, + VoteKeyDilution: ba.VoteKeyDilution, + }, + } +} + +// EncodeTrimmedLcAccountData encodes ledgercore account data into json. +func EncodeTrimmedLcAccountData(ad ledgercore.AccountData) []byte { + return encodeJSON(convertTrimmedLcAccountData(ad)) +} + +// DecodeTrimmedLcAccountData decodes ledgercore account data from json. +func DecodeTrimmedLcAccountData(data []byte) (ledgercore.AccountData, error) { + var ba baseAccountData + err := DecodeJSON(data, &ba) + if err != nil { + return ledgercore.AccountData{}, err + } + + return unconvertTrimmedLcAccountData(ba), nil +} diff --git a/idb/postgres/internal/encoding/encoding_test.go b/idb/postgres/internal/encoding/encoding_test.go index e02062861..0a3816e85 100644 --- a/idb/postgres/internal/encoding/encoding_test.go +++ b/idb/postgres/internal/encoding/encoding_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/data/transactions" @@ -465,20 +466,21 @@ func TestSpecialAddressesEncoding(t *testing.T) { // Test that encoding of AccountTotals is as expected and that decoding results in the // same object. func TestAccountTotalsEncoding(t *testing.T) { + random := rand.New(rand.NewSource(1)) totals := ledgercore.AccountTotals{ Online: ledgercore.AlgoCount{ - Money: basics.MicroAlgos{Raw: rand.Uint64()}, - RewardUnits: rand.Uint64(), + Money: basics.MicroAlgos{Raw: random.Uint64()}, + RewardUnits: random.Uint64(), }, Offline: ledgercore.AlgoCount{ - Money: basics.MicroAlgos{Raw: rand.Uint64()}, - RewardUnits: rand.Uint64(), + Money: basics.MicroAlgos{Raw: random.Uint64()}, + RewardUnits: random.Uint64(), }, NotParticipating: ledgercore.AlgoCount{ - Money: basics.MicroAlgos{Raw: rand.Uint64()}, - RewardUnits: rand.Uint64(), + Money: basics.MicroAlgos{Raw: random.Uint64()}, + RewardUnits: random.Uint64(), }, - RewardsLevel: rand.Uint64(), + RewardsLevel: random.Uint64(), } buf := EncodeAccountTotals(&totals) @@ -549,3 +551,51 @@ func TestNetworkStateEncoding(t *testing.T) { require.NoError(t, err) assert.Equal(t, network, decodedNetwork) } + +// Test that encoding of ledgercore.AccountData is as expected and that decoding +// results in the same object. +func TestLcAccountDataEncoding(t *testing.T) { + var authAddr basics.Address + authAddr[0] = 6 + + var voteID crypto.OneTimeSignatureVerifier + voteID[0] = 14 + + var selectionID crypto.VRFVerifier + selectionID[0] = 15 + + var stateProofID merklesignature.Verifier + stateProofID[0] = 19 + + ad := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + Status: basics.Online, + AuthAddr: authAddr, + TotalAppSchema: basics.StateSchema{ + NumUint: 7, + NumByteSlice: 8, + }, + TotalExtraAppPages: 9, + TotalAppParams: 10, + TotalAppLocalStates: 11, + TotalAssetParams: 12, + TotalAssets: 13, + }, + VotingData: ledgercore.VotingData{ + VoteID: voteID, + SelectionID: selectionID, + StateProofID: stateProofID, + VoteFirstValid: 16, + VoteLastValid: 17, + VoteKeyDilution: 18, + }, + } + buf := EncodeTrimmedLcAccountData(ad) + + expectedString := `{"onl":1,"sel":"DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","spend":"BgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","stprf":"EwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==","tapl":11,"tapp":10,"tas":13,"tasp":12,"teap":9,"tsch":{"nbs":8,"nui":7},"vote":"DgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","voteFst":16,"voteKD":18,"voteLst":17}` + assert.Equal(t, expectedString, string(buf)) + + decodedAd, err := DecodeTrimmedLcAccountData(buf) + require.NoError(t, err) + assert.Equal(t, ad, decodedAd) +} diff --git a/idb/postgres/internal/encoding/types.go b/idb/postgres/internal/encoding/types.go index d2e4c8987..259be6aac 100644 --- a/idb/postgres/internal/encoding/types.go +++ b/idb/postgres/internal/encoding/types.go @@ -2,6 +2,7 @@ package encoding import ( "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/data/transactions" @@ -106,3 +107,29 @@ type specialAddresses struct { FeeSinkOverride crypto.Digest `codec:"FeeSink"` RewardsPoolOverride crypto.Digest `codec:"RewardsPool"` } + +type baseOnlineAccountData struct { + _struct struct{} `codec:",omitempty,omitemptyarray"` + + VoteID crypto.OneTimeSignatureVerifier `codec:"vote"` + SelectionID crypto.VRFVerifier `codec:"sel"` + StateProofID merklesignature.Verifier `codec:"stprf"` + VoteFirstValid basics.Round `codec:"voteFst"` + VoteLastValid basics.Round `codec:"voteLst"` + VoteKeyDilution uint64 `codec:"voteKD"` +} + +type baseAccountData struct { + _struct struct{} `codec:",omitempty,omitemptyarray"` + + Status basics.Status `codec:"onl"` + AuthAddr crypto.Digest `codec:"spend"` + TotalAppSchema basics.StateSchema `codec:"tsch"` + TotalExtraAppPages uint32 `codec:"teap"` + TotalAssetParams uint64 `codec:"tasp"` + TotalAssets uint64 `codec:"tas"` + TotalAppParams uint64 `codec:"tapp"` + TotalAppLocalStates uint64 `codec:"tapl"` + + baseOnlineAccountData +} diff --git a/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator.go b/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator.go index 005f27bc8..fbd275dd6 100644 --- a/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator.go +++ b/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator.go @@ -15,15 +15,15 @@ import ( ) const ( - blockHeaderStmtName = "block_header" - assetCreatorStmtName = "asset_creator" - appCreatorStmtName = "app_creator" - accountStmtName = "account" - assetHoldingsStmtName = "asset_holdings" - assetParamsStmtName = "asset_params" - appParamsStmtName = "app_params" - appLocalStatesStmtName = "app_local_states" - accountTotalsStmtName = "account_totals" + blockHeaderStmtName = "block_header" + assetCreatorStmtName = "asset_creator" + appCreatorStmtName = "app_creator" + accountStmtName = "account" + assetHoldingStmtName = "asset_holding" + assetParamsStmtName = "asset_params" + appParamsStmtName = "app_params" + appLocalStateStmtName = "app_local_state" + accountTotalsStmtName = "account_totals" ) var statements = map[string]string{ @@ -33,13 +33,13 @@ var statements = map[string]string{ appCreatorStmtName: "SELECT creator FROM app WHERE index = $1 AND NOT deleted", accountStmtName: "SELECT microalgos, rewardsbase, rewards_total, account_data " + "FROM account WHERE addr = $1 AND NOT deleted", - assetHoldingsStmtName: "SELECT assetid, amount, frozen FROM account_asset " + - "WHERE addr = $1 AND NOT deleted", - assetParamsStmtName: "SELECT index, params FROM asset " + - "WHERE creator_addr = $1 AND NOT deleted", - appParamsStmtName: "SELECT index, params FROM app WHERE creator = $1 AND NOT deleted", - appLocalStatesStmtName: "SELECT app, localstate FROM account_app " + - "WHERE addr = $1 AND NOT deleted", + assetHoldingStmtName: "SELECT amount, frozen FROM account_asset " + + "WHERE addr = $1 AND assetid = $2 AND NOT deleted", + assetParamsStmtName: "SELECT creator_addr, params FROM asset " + + "WHERE index = $1 AND NOT deleted", + appParamsStmtName: "SELECT creator, params FROM app WHERE index = $1 AND NOT deleted", + appLocalStateStmtName: "SELECT localstate FROM account_app " + + "WHERE addr = $1 AND app = $2 AND NOT deleted", accountTotalsStmtName: `SELECT v FROM metastate WHERE k = '` + schema.AccountTotals + `'`, } @@ -94,7 +94,7 @@ func (l LedgerForEvaluator) LatestBlockHdr() (bookkeeping.BlockHeader, error) { return res, nil } -func (l *LedgerForEvaluator) parseAccountTable(row pgx.Row) (basics.AccountData, bool /*exists*/, error) { +func (l *LedgerForEvaluator) parseAccountTable(row pgx.Row) (ledgercore.AccountData, bool /*exists*/, error) { var microalgos uint64 var rewardsbase uint64 var rewardsTotal uint64 @@ -102,17 +102,17 @@ func (l *LedgerForEvaluator) parseAccountTable(row pgx.Row) (basics.AccountData, err := row.Scan(µalgos, &rewardsbase, &rewardsTotal, &accountData) if err == pgx.ErrNoRows { - return basics.AccountData{}, false, nil + return ledgercore.AccountData{}, false, nil } if err != nil { - return basics.AccountData{}, false, fmt.Errorf("parseAccountTable() scan row err: %w", err) + return ledgercore.AccountData{}, false, fmt.Errorf("parseAccountTable() scan row err: %w", err) } - res := basics.AccountData{} + var res ledgercore.AccountData if accountData != nil { - res, err = encoding.DecodeTrimmedAccountData(accountData) + res, err = encoding.DecodeTrimmedLcAccountData(accountData) if err != nil { - return basics.AccountData{}, false, + return ledgercore.AccountData{}, false, fmt.Errorf("parseAccountTable() decode account data err: %w", err) } } @@ -124,258 +124,300 @@ func (l *LedgerForEvaluator) parseAccountTable(row pgx.Row) (basics.AccountData, return res, true, nil } -func (l *LedgerForEvaluator) parseAccountAssetTable(rows pgx.Rows) (map[basics.AssetIndex]basics.AssetHolding, error) { - defer rows.Close() - var res map[basics.AssetIndex]basics.AssetHolding - - var assetid uint64 - var amount uint64 - var frozen bool - - for rows.Next() { - err := rows.Scan(&assetid, &amount, &frozen) - if err != nil { - return nil, fmt.Errorf("parseAccountAssetTable() scan row err: %w", err) - } - - if res == nil { - res = make(map[basics.AssetIndex]basics.AssetHolding) - } - res[basics.AssetIndex(assetid)] = basics.AssetHolding{ - Amount: amount, - Frozen: frozen, - } +// LookupWithoutRewards is part of go-algorand's indexerLedgerForEval interface. +func (l LedgerForEvaluator) LookupWithoutRewards(addresses map[basics.Address]struct{}) (map[basics.Address]*ledgercore.AccountData, error) { + addressesArr := make([]basics.Address, 0, len(addresses)) + for address := range addresses { + addressesArr = append(addressesArr, address) } - err := rows.Err() - if err != nil { - return nil, fmt.Errorf("parseAccountAssetTable() scan end err: %w", err) + var batch pgx.Batch + for i := range addressesArr { + batch.Queue(accountStmtName, addressesArr[i][:]) } - return res, nil -} + results := l.tx.SendBatch(context.Background(), &batch) + defer results.Close() -func (l *LedgerForEvaluator) parseAssetTable(rows pgx.Rows) (map[basics.AssetIndex]basics.AssetParams, error) { - defer rows.Close() - var res map[basics.AssetIndex]basics.AssetParams + res := make(map[basics.Address]*ledgercore.AccountData, len(addresses)) + for _, address := range addressesArr { + row := results.QueryRow() - var index uint64 - var params []byte + lcAccountData := new(ledgercore.AccountData) + var exists bool + var err error - for rows.Next() { - err := rows.Scan(&index, ¶ms) + *lcAccountData, exists, err = l.parseAccountTable(row) if err != nil { - return nil, fmt.Errorf("parseAssetTable() scan row err: %w", err) + return nil, fmt.Errorf("LookupWithoutRewards() err: %w", err) } - if res == nil { - res = make(map[basics.AssetIndex]basics.AssetParams) - } - res[basics.AssetIndex(index)], err = encoding.DecodeAssetParams(params) - if err != nil { - return nil, fmt.Errorf("parseAssetTable() decode params err: %w", err) + if exists { + res[address] = lcAccountData + } else { + res[address] = nil } } - err := rows.Err() + err := results.Close() if err != nil { - return nil, fmt.Errorf("parseAssetTable() scan end err: %w", err) + return nil, fmt.Errorf("LookupWithoutRewards() close results err: %w", err) } return res, nil } -func (l *LedgerForEvaluator) parseAppTable(rows pgx.Rows) (map[basics.AppIndex]basics.AppParams, error) { - defer rows.Close() - var res map[basics.AppIndex]basics.AppParams - - var index uint64 - var params []byte - - for rows.Next() { - err := rows.Scan(&index, ¶ms) - if err != nil { - return nil, fmt.Errorf("parseAppTable() scan row err: %w", err) - } +func (l *LedgerForEvaluator) parseAccountAssetTable(row pgx.Row) (basics.AssetHolding, bool /*exists*/, error) { + var amount uint64 + var frozen bool - if res == nil { - res = make(map[basics.AppIndex]basics.AppParams) - } - res[basics.AppIndex(index)], err = encoding.DecodeAppParams(params) - if err != nil { - return nil, fmt.Errorf("parseAppTable() decode params err: %w", err) - } + err := row.Scan(&amount, &frozen) + if err == pgx.ErrNoRows { + return basics.AssetHolding{}, false, nil } - - err := rows.Err() if err != nil { - return nil, fmt.Errorf("parseAppTable() scan end err: %w", err) + return basics.AssetHolding{}, false, + fmt.Errorf("parseAccountAssetTable() scan row err: %w", err) } - return res, nil + assetHolding := basics.AssetHolding{ + Amount: amount, + Frozen: frozen, + } + return assetHolding, true, nil } -func (l *LedgerForEvaluator) parseAccountAppTable(rows pgx.Rows) (map[basics.AppIndex]basics.AppLocalState, error) { - defer rows.Close() - var res map[basics.AppIndex]basics.AppLocalState - - var app uint64 - var localstate []byte - - for rows.Next() { - err := rows.Scan(&app, &localstate) - if err != nil { - return nil, fmt.Errorf("parseAccountAppTable() scan row err: %w", err) - } +func (l *LedgerForEvaluator) parseAssetTable(row pgx.Row) (basics.Address /*creator*/, basics.AssetParams, bool /*exists*/, error) { + var creatorAddr []byte + var params []byte - if res == nil { - res = make(map[basics.AppIndex]basics.AppLocalState) - } - res[basics.AppIndex(app)], err = encoding.DecodeAppLocalState(localstate) - if err != nil { - return nil, fmt.Errorf("parseAccountAppTable() decode local state err: %w", err) - } + err := row.Scan(&creatorAddr, ¶ms) + if err == pgx.ErrNoRows { + return basics.Address{}, basics.AssetParams{}, false, nil } + if err != nil { + return basics.Address{}, basics.AssetParams{}, false, + fmt.Errorf("parseAssetTable() scan row err: %w", err) + } + + var creator basics.Address + copy(creator[:], creatorAddr) - err := rows.Err() + assetParams, err := encoding.DecodeAssetParams(params) if err != nil { - return nil, fmt.Errorf("parseAccountAppTable() scan end err: %w", err) + return basics.Address{}, basics.AssetParams{}, false, + fmt.Errorf("parseAssetTable() decode params err: %w", err) } - return res, nil + return creator, assetParams, true, nil } -// Load rows from the account table for the given addresses except the special accounts. -// nil is stored for those accounts that were not found. Uses batching. -func (l *LedgerForEvaluator) loadAccountTable(addresses map[basics.Address]struct{}) (map[basics.Address]*basics.AccountData, error) { - addressesArr := make([]basics.Address, 0, len(addresses)) - for address := range addresses { - addressesArr = append(addressesArr, address) - } +func (l *LedgerForEvaluator) parseAppTable(row pgx.Row) (basics.Address /*creator*/, basics.AppParams, bool /*exists*/, error) { + var creatorAddr []byte + var params []byte - var batch pgx.Batch - for i := range addressesArr { - batch.Queue(accountStmtName, addressesArr[i][:]) + err := row.Scan(&creatorAddr, ¶ms) + if err == pgx.ErrNoRows { + return basics.Address{}, basics.AppParams{}, false, nil + } + if err != nil { + return basics.Address{}, basics.AppParams{}, false, + fmt.Errorf("parseAppTable() scan row err: %w", err) } - results := l.tx.SendBatch(context.Background(), &batch) - defer results.Close() + var creator basics.Address + copy(creator[:], creatorAddr) - res := make(map[basics.Address]*basics.AccountData, len(addresses)) - for _, address := range addressesArr { - row := results.QueryRow() + appParams, err := encoding.DecodeAppParams(params) + if err != nil { + return basics.Address{}, basics.AppParams{}, false, + fmt.Errorf("parseAppTable() decode params err: %w", err) + } - accountData := new(basics.AccountData) - var exists bool - var err error + return creator, appParams, true, nil +} - *accountData, exists, err = l.parseAccountTable(row) - if err != nil { - return nil, fmt.Errorf("loadAccountTable() err: %w", err) - } +func (l *LedgerForEvaluator) parseAccountAppTable(row pgx.Row) (basics.AppLocalState, bool /*exists*/, error) { + var localstate []byte - if exists { - res[address] = accountData - } else { - res[address] = nil - } + err := row.Scan(&localstate) + if err == pgx.ErrNoRows { + return basics.AppLocalState{}, false, nil + } + if err != nil { + return basics.AppLocalState{}, false, + fmt.Errorf("parseAccountAppTable() scan row err: %w", err) } - err := results.Close() + appLocalState, err := encoding.DecodeAppLocalState(localstate) if err != nil { - return nil, fmt.Errorf("loadAccountTable() close results err: %w", err) + return basics.AppLocalState{}, false, + fmt.Errorf("parseAccountAppTable() decode local state err: %w", err) } - return res, nil + return appLocalState, true, nil } -// Load all creatables for the non-nil account data from the provided map into that -// account data. Uses batching. -func (l *LedgerForEvaluator) loadCreatables(accountDataMap *map[basics.Address]*basics.AccountData) error { - var batch pgx.Batch - - existingAddresses := make([]basics.Address, 0, len(*accountDataMap)) - for address, accountData := range *accountDataMap { - if accountData != nil { - existingAddresses = append(existingAddresses, address) +// LookupResources is part of go-algorand's indexerLedgerForEval interface. +func (l LedgerForEvaluator) LookupResources(input map[basics.Address]map[ledger.Creatable]struct{}) (map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource, error) { + // Create request arrays since iterating over maps is non-deterministic. + type AddrID struct { + addr basics.Address + id basics.CreatableIndex + } + // Asset holdings to request. + assetHoldingsReq := make([]AddrID, 0, len(input)) + // Asset params to request. + assetParamsReq := make([]basics.CreatableIndex, 0, len(input)) + // For each asset id, record for which addresses it was requested. + assetParamsToAddresses := make(map[basics.CreatableIndex]map[basics.Address]struct{}) + // App local states to request. + appLocalStatesReq := make([]AddrID, 0, len(input)) + // App params to request. + appParamsReq := make([]basics.CreatableIndex, 0, len(input)) + // For each app id, record for which addresses it was requested. + appParamsToAddresses := make(map[basics.CreatableIndex]map[basics.Address]struct{}) + + for address, creatables := range input { + for creatable := range creatables { + switch creatable.Type { + case basics.AssetCreatable: + assetHoldingsReq = append(assetHoldingsReq, AddrID{address, creatable.Index}) + if addresses, ok := assetParamsToAddresses[creatable.Index]; ok { + addresses[address] = struct{}{} + } else { + assetParamsReq = append(assetParamsReq, creatable.Index) + addresses = make(map[basics.Address]struct{}) + addresses[address] = struct{}{} + assetParamsToAddresses[creatable.Index] = addresses + } + case basics.AppCreatable: + appLocalStatesReq = append(appLocalStatesReq, AddrID{address, creatable.Index}) + if addresses, ok := appParamsToAddresses[creatable.Index]; ok { + addresses[address] = struct{}{} + } else { + appParamsReq = append(appParamsReq, creatable.Index) + addresses = make(map[basics.Address]struct{}) + addresses[address] = struct{}{} + appParamsToAddresses[creatable.Index] = addresses + } + default: + return nil, fmt.Errorf( + "LookupResources() unknown creatable type %d", creatable.Type) + } } } - for i := range existingAddresses { - batch.Queue(assetHoldingsStmtName, existingAddresses[i][:]) + // Prepare a batch of sql queries. + var batch pgx.Batch + for i := range assetHoldingsReq { + batch.Queue( + assetHoldingStmtName, assetHoldingsReq[i].addr[:], assetHoldingsReq[i].id) } - for i := range existingAddresses { - batch.Queue(assetParamsStmtName, existingAddresses[i][:]) + for _, cidx := range assetParamsReq { + batch.Queue(assetParamsStmtName, cidx) } - for i := range existingAddresses { - batch.Queue(appParamsStmtName, existingAddresses[i][:]) + for _, cidx := range appParamsReq { + batch.Queue(appParamsStmtName, cidx) } - for i := range existingAddresses { - batch.Queue(appLocalStatesStmtName, existingAddresses[i][:]) + for i := range appLocalStatesReq { + batch.Queue( + appLocalStateStmtName, appLocalStatesReq[i].addr[:], appLocalStatesReq[i].id) } + // Execute the sql queries. results := l.tx.SendBatch(context.Background(), &batch) defer results.Close() - for _, address := range existingAddresses { - rows, err := results.Query() - if err != nil { - return fmt.Errorf("loadCreatables() query asset holdings err: %w", err) + // Initialize the result `res` with the same structure as `input`. + res := make( + map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource, len(input)) + for address, creatables := range input { + creatablesOutput := + make(map[ledger.Creatable]ledgercore.AccountResource, len(creatables)) + res[address] = creatablesOutput + for creatable := range creatables { + creatablesOutput[creatable] = ledgercore.AccountResource{} } - (*accountDataMap)[address].Assets, err = l.parseAccountAssetTable(rows) + } + + // Parse sql query results in the same order the queries were made. + for _, addrID := range assetHoldingsReq { + row := results.QueryRow() + assetHolding, exists, err := l.parseAccountAssetTable(row) if err != nil { - return fmt.Errorf("loadCreatables() err: %w", err) + return nil, fmt.Errorf("LookupResources() err: %w", err) + } + if exists { + creatable := ledger.Creatable{ + Index: addrID.id, + Type: basics.AssetCreatable, + } + resource := res[addrID.addr][creatable] + resource.AssetHolding = new(basics.AssetHolding) + *resource.AssetHolding = assetHolding + res[addrID.addr][creatable] = resource } } - for _, address := range existingAddresses { - rows, err := results.Query() + for _, cidx := range assetParamsReq { + row := results.QueryRow() + creator, assetParams, exists, err := l.parseAssetTable(row) if err != nil { - return fmt.Errorf("loadCreatables() query asset params err: %w", err) + return nil, fmt.Errorf("LookupResources() err: %w", err) } - (*accountDataMap)[address].AssetParams, err = l.parseAssetTable(rows) - if err != nil { - return fmt.Errorf("loadCreatables() err: %w", err) + if exists { + if _, ok := assetParamsToAddresses[cidx][creator]; ok { + creatable := ledger.Creatable{ + Index: cidx, + Type: basics.AssetCreatable, + } + resource := res[creator][creatable] + resource.AssetParams = new(basics.AssetParams) + *resource.AssetParams = assetParams + res[creator][creatable] = resource + } } } - for _, address := range existingAddresses { - rows, err := results.Query() + for _, cidx := range appParamsReq { + row := results.QueryRow() + creator, appParams, exists, err := l.parseAppTable(row) if err != nil { - return fmt.Errorf("loadCreatables() query app params err: %w", err) + return nil, fmt.Errorf("LookupResources() err: %w", err) } - (*accountDataMap)[address].AppParams, err = l.parseAppTable(rows) - if err != nil { - return fmt.Errorf("loadCreatables() err: %w", err) + if exists { + if _, ok := appParamsToAddresses[cidx][creator]; ok { + creatable := ledger.Creatable{ + Index: cidx, + Type: basics.AppCreatable, + } + resource := res[creator][creatable] + resource.AppParams = new(basics.AppParams) + *resource.AppParams = appParams + res[creator][creatable] = resource + } } } - for _, address := range existingAddresses { - rows, err := results.Query() + for _, addrID := range appLocalStatesReq { + row := results.QueryRow() + appLocalState, exists, err := l.parseAccountAppTable(row) if err != nil { - return fmt.Errorf("loadCreatables() query app local states err: %w", err) + return nil, fmt.Errorf("LookupResources() err: %w", err) } - (*accountDataMap)[address].AppLocalStates, err = l.parseAccountAppTable(rows) - if err != nil { - return fmt.Errorf("loadCreatables() err: %w", err) + if exists { + creatable := ledger.Creatable{ + Index: addrID.id, + Type: basics.AppCreatable, + } + resource := res[addrID.addr][creatable] + resource.AppLocalState = new(basics.AppLocalState) + *resource.AppLocalState = appLocalState + res[addrID.addr][creatable] = resource } } err := results.Close() if err != nil { - return fmt.Errorf("loadCreatables() close results err: %w", err) - } - - return nil -} - -// LookupWithoutRewards is part of go-algorand's indexerLedgerForEval interface. -func (l LedgerForEvaluator) LookupWithoutRewards(addresses map[basics.Address]struct{}) (map[basics.Address]*basics.AccountData, error) { - res, err := l.loadAccountTable(addresses) - if err != nil { - return nil, fmt.Errorf("loadAccounts() err: %w", err) - } - - err = l.loadCreatables(&res) - if err != nil { - return nil, fmt.Errorf("loadAccounts() err: %w", err) + return nil, fmt.Errorf("LookupResources() close results err: %w", err) } return res, nil diff --git a/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator_test.go b/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator_test.go index 57e195fe1..cdccf7611 100644 --- a/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator_test.go +++ b/idb/postgres/internal/ledger_for_evaluator/ledger_for_evaluator_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/ledger" @@ -26,17 +27,8 @@ import ( var readonlyRepeatableRead = pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly} -func setupPostgres(t *testing.T) (*pgxpool.Pool, func()) { - db, _, shutdownFunc := pgtest.SetupPostgres(t) - - _, err := db.Exec(context.Background(), schema.SetupPostgresSql) - require.NoError(t, err) - - return db, shutdownFunc -} - func TestLedgerForEvaluatorLatestBlockHdr(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() query := @@ -65,7 +57,7 @@ func TestLedgerForEvaluatorLatestBlockHdr(t *testing.T) { } func TestLedgerForEvaluatorAccountTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() query := `INSERT INTO account @@ -76,26 +68,33 @@ func TestLedgerForEvaluatorAccountTableBasic(t *testing.T) { voteID[0] = 2 var selectionID crypto.VRFVerifier selectionID[0] = 3 - accountDataWritten := basics.AccountData{ - Status: basics.Online, - VoteID: voteID, - SelectionID: selectionID, - VoteFirstValid: basics.Round(4), - VoteLastValid: basics.Round(5), - VoteKeyDilution: 6, - AuthAddr: test.AccountA, + var stateProofID merklesignature.Verifier + stateProofID[0] = 10 + accountDataFull := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + Status: basics.Online, + MicroAlgos: basics.MicroAlgos{Raw: 4}, + RewardsBase: 5, + RewardedMicroAlgos: basics.MicroAlgos{Raw: 6}, + AuthAddr: test.AccountA, + }, + VotingData: ledgercore.VotingData{ + VoteID: voteID, + SelectionID: selectionID, + StateProofID: stateProofID, + VoteFirstValid: basics.Round(7), + VoteLastValid: basics.Round(8), + VoteKeyDilution: 9, + }, } - accountDataFull := accountDataWritten - accountDataFull.MicroAlgos = basics.MicroAlgos{Raw: 2} - accountDataFull.RewardsBase = 3 - accountDataFull.RewardedMicroAlgos = basics.MicroAlgos{Raw: 4} + accountDataWritten := encoding.TrimLcAccountData(accountDataFull) _, err := db.Exec( context.Background(), query, test.AccountB[:], accountDataFull.MicroAlgos.Raw, accountDataFull.RewardsBase, accountDataFull.RewardedMicroAlgos.Raw, - encoding.EncodeTrimmedAccountData(accountDataWritten)) + encoding.EncodeTrimmedLcAccountData(accountDataWritten)) require.NoError(t, err) tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) @@ -116,57 +115,69 @@ func TestLedgerForEvaluatorAccountTableBasic(t *testing.T) { assert.Equal(t, accountDataFull, *accountDataRet) } -func insertAccountData(db *pgxpool.Pool, account basics.Address, createdat uint64, deleted bool, data basics.AccountData) error { - // This could be 'upsertAccountStmtName' - query := - "INSERT INTO account (addr, microalgos, rewardsbase, rewards_total, deleted, " + - "created_at, account_data) " + - "VALUES ($1, $2, $3, $4, $5, $6, $7)" +func insertDeletedAccount(db *pgxpool.Pool, address basics.Address) error { + query := `INSERT INTO account (addr, microalgos, rewardsbase, rewards_total, deleted, + created_at, account_data) + VALUES ($1, 0, 0, 0, true, 0, 'null'::jsonb)` + + _, err := db.Exec( + context.Background(), query, address[:]) + return err +} + +func insertAccount(db *pgxpool.Pool, address basics.Address, data ledgercore.AccountData) error { + query := `INSERT INTO account (addr, microalgos, rewardsbase, rewards_total, deleted, + created_at, account_data) + VALUES ($1, $2, $3, $4, false, 0, $5)` + _, err := db.Exec( context.Background(), query, - account[:], data.MicroAlgos.Raw, data.RewardsBase, data.RewardedMicroAlgos.Raw, deleted, createdat, - encoding.EncodeTrimmedAccountData(data)) + address[:], data.MicroAlgos.Raw, data.RewardsBase, data.RewardedMicroAlgos.Raw, + encoding.EncodeTrimmedLcAccountData(data)) return err } // TestLedgerForEvaluatorAccountTableBasicSingleAccount a table driven single account test. func TestLedgerForEvaluatorAccountTableSingleAccount(t *testing.T) { tests := []struct { - name string - createdAt uint64 - deleted bool - data basics.AccountData - err string + name string + deleted bool + data ledgercore.AccountData + err string }{ { name: "small balance", - data: basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 1}, + data: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 1}, + }, }, }, { name: "max balance", - data: basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: math.MaxInt64}, + data: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: math.MaxInt64}, + }, }, }, { name: "over max balance", - data: basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: math.MaxUint64}, + data: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: math.MaxUint64}, + }, }, - err: fmt.Sprintf("%d is greater than maximum value for Int8", uint64(math.MaxUint64)), + err: fmt.Sprintf( + "%d is greater than maximum value for Int8", uint64(math.MaxUint64)), }, { name: "deleted", deleted: true, - data: basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: math.MaxInt64}, - }, }, } - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() for i, testcase := range tests { @@ -184,7 +195,12 @@ func TestLedgerForEvaluatorAccountTableSingleAccount(t *testing.T) { return false } - err := insertAccountData(db, addr, tc.createdAt, tc.deleted, tc.data) + var err error + if tc.deleted { + err = insertDeletedAccount(db, addr) + } else { + err = insertAccount(db, addr, tc.data) + } if checkError(err) { return } @@ -225,7 +241,7 @@ func TestLedgerForEvaluatorAccountTableSingleAccount(t *testing.T) { } func TestLedgerForEvaluatorAccountTableDeleted(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() query := @@ -233,12 +249,14 @@ func TestLedgerForEvaluatorAccountTableDeleted(t *testing.T) { "created_at, account_data) " + "VALUES ($1, 2, 3, 4, true, 0, $2)" - accountData := basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 5}, + accountData := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 5}, + }, } _, err := db.Exec( context.Background(), query, test.AccountB[:], - encoding.EncodeTrimmedAccountData(accountData)) + encoding.EncodeTrimmedLcAccountData(accountData)) require.NoError(t, err) tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) @@ -258,7 +276,7 @@ func TestLedgerForEvaluatorAccountTableDeleted(t *testing.T) { } func TestLedgerForEvaluatorAccountTableMissingAccount(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) @@ -277,27 +295,34 @@ func TestLedgerForEvaluatorAccountTableMissingAccount(t *testing.T) { assert.Nil(t, accountDataRet) } -func TestLedgerForEvaluatorAccountAssetTable(t *testing.T) { - db, shutdownFunc := setupPostgres(t) - defer shutdownFunc() +func insertDeletedAccountAsset(t *testing.T, db *pgxpool.Pool, addr basics.Address, assetid uint64) { + query := + "INSERT INTO account_asset (addr, assetid, amount, frozen, deleted, created_at) " + + "VALUES ($1, $2, 0, false, true, 0)" - query := `INSERT INTO account - (addr, microalgos, rewardsbase, rewards_total, deleted, created_at, account_data) - VALUES ($1, 0, 0, 0, false, 0, 'null'::jsonb)` - _, err := db.Exec(context.Background(), query, test.AccountA[:]) + _, err := db.Exec( + context.Background(), query, addr[:], assetid) require.NoError(t, err) +} - query = +func insertAccountAsset(t *testing.T, db *pgxpool.Pool, addr basics.Address, assetid uint64, amount uint64, frozen bool) { + query := "INSERT INTO account_asset (addr, assetid, amount, frozen, deleted, created_at) " + - "VALUES ($1, $2, $3, $4, $5, 0)" - _, err = db.Exec(context.Background(), query, test.AccountA[:], 1, 2, false, false) - require.NoError(t, err) - _, err = db.Exec(context.Background(), query, test.AccountA[:], 3, 4, true, false) - require.NoError(t, err) - _, err = db.Exec(context.Background(), query, test.AccountA[:], 5, 6, true, true) // deleted - require.NoError(t, err) - _, err = db.Exec(context.Background(), query, test.AccountB[:], 5, 6, true, false) // wrong account + "VALUES ($1, $2, $3, $4, false, 0)" + + _, err := db.Exec( + context.Background(), query, addr[:], assetid, amount, frozen) require.NoError(t, err) +} + +func TestLedgerForEvaluatorAccountAssetTable(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + insertAccountAsset(t, db, test.AccountA, 1, 2, false) + insertAccountAsset(t, db, test.AccountA, 3, 4, true) + insertDeletedAccountAsset(t, db, test.AccountA, 5) + insertAccountAsset(t, db, test.AccountB, 5, 6, true) tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) require.NoError(t, err) @@ -307,59 +332,76 @@ func TestLedgerForEvaluatorAccountAssetTable(t *testing.T) { require.NoError(t, err) defer l.Close() - ret, err := - l.LookupWithoutRewards(map[basics.Address]struct{}{test.AccountA: {}}) + ret, err := l.LookupResources(map[basics.Address]map[ledger.Creatable]struct{}{ + test.AccountA: { + {Index: 1, Type: basics.AssetCreatable}: {}, + {Index: 3, Type: basics.AssetCreatable}: {}, + {Index: 5, Type: basics.AssetCreatable}: {}, + {Index: 8, Type: basics.AssetCreatable}: {}, + }, + test.AccountB: { + {Index: 5, Type: basics.AssetCreatable}: {}, + }, + }) require.NoError(t, err) - accountDataRet := ret[test.AccountA] - require.NotNil(t, accountDataRet) - - accountDataExpected := basics.AccountData{ - Assets: map[basics.AssetIndex]basics.AssetHolding{ - 1: { - Amount: 2, - Frozen: false, + expected := map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource{ + test.AccountA: { + ledger.Creatable{Index: 1, Type: basics.AssetCreatable}: { + AssetHolding: &basics.AssetHolding{ + Amount: 2, + Frozen: false, + }, + }, + ledger.Creatable{Index: 3, Type: basics.AssetCreatable}: { + AssetHolding: &basics.AssetHolding{ + Amount: 4, + Frozen: true, + }, }, - 3: { - Amount: 4, - Frozen: true, + ledger.Creatable{Index: 5, Type: basics.AssetCreatable}: {}, + ledger.Creatable{Index: 8, Type: basics.AssetCreatable}: {}, + }, + test.AccountB: { + ledger.Creatable{Index: 5, Type: basics.AssetCreatable}: { + AssetHolding: &basics.AssetHolding{ + Amount: 6, + Frozen: true, + }, }, }, } - assert.Equal(t, accountDataExpected, *accountDataRet) + assert.Equal(t, expected, ret) } -func TestLedgerForEvaluatorAssetTable(t *testing.T) { - db, shutdownFunc := setupPostgres(t) - defer shutdownFunc() +func insertDeletedAsset(t *testing.T, db *pgxpool.Pool, index uint64, creatorAddr basics.Address) { + query := `INSERT INTO asset (index, creator_addr, params, deleted, created_at) + VALUES ($1, $2, 'null'::jsonb, true, 0)` - query := `INSERT INTO account - (addr, microalgos, rewardsbase, rewards_total, deleted, created_at, account_data) - VALUES ($1, 0, 0, 0, false, 0, 'null'::jsonb)` - _, err := db.Exec(context.Background(), query, test.AccountA[:]) + _, err := db.Exec( + context.Background(), query, index, creatorAddr[:]) require.NoError(t, err) +} - query = - "INSERT INTO asset (index, creator_addr, params, deleted, created_at) " + - "VALUES ($1, $2, $3, $4, 0)" - - _, err = db.Exec( - context.Background(), query, 1, test.AccountA[:], - encoding.EncodeAssetParams(basics.AssetParams{Manager: test.AccountB}), - false) - require.NoError(t, err) +func insertAsset(t *testing.T, db *pgxpool.Pool, index uint64, creatorAddr basics.Address, params *basics.AssetParams) { + query := `INSERT INTO asset (index, creator_addr, params, deleted, created_at) + VALUES ($1, $2, $3, false, 0)` - _, err = db.Exec( - context.Background(), query, 2, test.AccountA[:], - encoding.EncodeAssetParams(basics.AssetParams{Manager: test.AccountC}), - false) + _, err := db.Exec( + context.Background(), query, index, creatorAddr[:], + encoding.EncodeAssetParams(*params)) require.NoError(t, err) +} - _, err = db.Exec(context.Background(), query, 3, test.AccountA[:], "{}", true) // deleted - require.NoError(t, err) +func TestLedgerForEvaluatorAssetTable(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() - _, err = db.Exec(context.Background(), query, 4, test.AccountD[:], "{}", false) // wrong account - require.NoError(t, err) + insertAsset(t, db, 1, test.AccountA, &basics.AssetParams{Manager: test.AccountB}) + insertAsset(t, db, 2, test.AccountA, &basics.AssetParams{Total: 10}) + insertDeletedAsset(t, db, 3, test.AccountA) + insertAsset(t, db, 4, test.AccountC, &basics.AssetParams{Total: 12}) + insertAsset(t, db, 5, test.AccountD, &basics.AssetParams{Total: 13}) tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) require.NoError(t, err) @@ -369,65 +411,91 @@ func TestLedgerForEvaluatorAssetTable(t *testing.T) { require.NoError(t, err) defer l.Close() - ret, err := - l.LookupWithoutRewards(map[basics.Address]struct{}{test.AccountA: {}}) + ret, err := l.LookupResources(map[basics.Address]map[ledger.Creatable]struct{}{ + test.AccountA: { + {Index: 1, Type: basics.AssetCreatable}: {}, + {Index: 2, Type: basics.AssetCreatable}: {}, + {Index: 3, Type: basics.AssetCreatable}: {}, + {Index: 4, Type: basics.AssetCreatable}: {}, + {Index: 6, Type: basics.AssetCreatable}: {}, + }, + test.AccountD: { + {Index: 5, Type: basics.AssetCreatable}: {}, + }, + }) require.NoError(t, err) - accountDataRet := ret[test.AccountA] - require.NotNil(t, accountDataRet) - - accountDataExpected := basics.AccountData{ - AssetParams: map[basics.AssetIndex]basics.AssetParams{ - 1: { - Manager: test.AccountB, + expected := map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource{ + test.AccountA: { + ledger.Creatable{Index: 1, Type: basics.AssetCreatable}: { + AssetParams: &basics.AssetParams{ + Manager: test.AccountB, + }, }, - 2: { - Manager: test.AccountC, + ledger.Creatable{Index: 2, Type: basics.AssetCreatable}: { + AssetParams: &basics.AssetParams{ + Total: 10, + }, + }, + ledger.Creatable{Index: 3, Type: basics.AssetCreatable}: {}, + ledger.Creatable{Index: 4, Type: basics.AssetCreatable}: {}, + ledger.Creatable{Index: 6, Type: basics.AssetCreatable}: {}, + }, + test.AccountD: { + ledger.Creatable{Index: 5, Type: basics.AssetCreatable}: { + AssetParams: &basics.AssetParams{ + Total: 13, + }, }, }, } - assert.Equal(t, accountDataExpected, *accountDataRet) + assert.Equal(t, expected, ret) } -func TestLedgerForEvaluatorAppTable(t *testing.T) { - db, shutdownFunc := setupPostgres(t) - defer shutdownFunc() +func insertDeletedApp(t *testing.T, db *pgxpool.Pool, index uint64, creator basics.Address) { + query := `INSERT INTO app (index, creator, params, deleted, created_at) + VALUES ($1, $2, 'null'::jsonb, true, 0)` - query := `INSERT INTO account - (addr, microalgos, rewardsbase, rewards_total, deleted, created_at, account_data) - VALUES ($1, 0, 0, 0, false, 0, 'null'::jsonb)` - _, err := db.Exec(context.Background(), query, test.AccountA[:]) + _, err := db.Exec(context.Background(), query, index, creator[:]) require.NoError(t, err) +} - query = - "INSERT INTO app (index, creator, params, deleted, created_at) " + - "VALUES ($1, $2, $3, $4, 0)" +func insertApp(t *testing.T, db *pgxpool.Pool, index uint64, creator basics.Address, params *basics.AppParams) { + query := `INSERT INTO app (index, creator, params, deleted, created_at) + VALUES ($1, $2, $3, false, 0)` + + _, err := db.Exec( + context.Background(), query, index, creator[:], encoding.EncodeAppParams(*params)) + require.NoError(t, err) +} + +func TestLedgerForEvaluatorAppTable(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() params1 := basics.AppParams{ GlobalState: map[string]basics.TealValue{ string([]byte{0xff}): {}, // try a non-utf8 string }, } - _, err = db.Exec( - context.Background(), query, 1, test.AccountA[:], - encoding.EncodeAppParams(params1), false) - require.NoError(t, err) + insertApp(t, db, 1, test.AccountA, ¶ms1) params2 := basics.AppParams{ - ApprovalProgram: []byte{1, 2, 3}, + ApprovalProgram: []byte{1, 2, 3, 10}, } - _, err = db.Exec( - context.Background(), query, 2, test.AccountA[:], - encoding.EncodeAppParams(params2), false) - require.NoError(t, err) + insertApp(t, db, 2, test.AccountA, ¶ms2) - _, err = db.Exec( - context.Background(), query, 3, test.AccountA[:], "{}", true) // deteled - require.NoError(t, err) + insertDeletedApp(t, db, 3, test.AccountA) - _, err = db.Exec( - context.Background(), query, 4, test.AccountB[:], "{}", false) // wrong account - require.NoError(t, err) + params4 := basics.AppParams{ + ApprovalProgram: []byte{1, 2, 3, 12}, + } + insertApp(t, db, 4, test.AccountB, ¶ms4) + + params5 := basics.AppParams{ + ApprovalProgram: []byte{1, 2, 3, 13}, + } + insertApp(t, db, 5, test.AccountC, ¶ms5) tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) require.NoError(t, err) @@ -437,63 +505,86 @@ func TestLedgerForEvaluatorAppTable(t *testing.T) { require.NoError(t, err) defer l.Close() - ret, err := - l.LookupWithoutRewards(map[basics.Address]struct{}{test.AccountA: {}}) + ret, err := l.LookupResources(map[basics.Address]map[ledger.Creatable]struct{}{ + test.AccountA: { + {Index: 1, Type: basics.AppCreatable}: {}, + {Index: 2, Type: basics.AppCreatable}: {}, + {Index: 3, Type: basics.AppCreatable}: {}, + {Index: 4, Type: basics.AppCreatable}: {}, + {Index: 6, Type: basics.AppCreatable}: {}, + }, + test.AccountC: { + {Index: 5, Type: basics.AppCreatable}: {}, + }, + }) require.NoError(t, err) - accountDataRet := ret[test.AccountA] - require.NotNil(t, accountDataRet) - - accountDataExpected := basics.AccountData{ - AppParams: map[basics.AppIndex]basics.AppParams{ - 1: params1, - 2: params2, + expected := map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource{ + test.AccountA: { + ledger.Creatable{Index: 1, Type: basics.AppCreatable}: { + AppParams: ¶ms1, + }, + ledger.Creatable{Index: 2, Type: basics.AppCreatable}: { + AppParams: ¶ms2, + }, + ledger.Creatable{Index: 3, Type: basics.AppCreatable}: {}, + ledger.Creatable{Index: 4, Type: basics.AppCreatable}: {}, + ledger.Creatable{Index: 6, Type: basics.AppCreatable}: {}, + }, + test.AccountC: { + ledger.Creatable{Index: 5, Type: basics.AppCreatable}: { + AppParams: ¶ms5, + }, }, } - assert.Equal(t, accountDataExpected, *accountDataRet) + assert.Equal(t, expected, ret) } -func TestLedgerForEvaluatorAccountAppTable(t *testing.T) { - db, shutdownFunc := setupPostgres(t) - defer shutdownFunc() +func insertDeletedAccountApp(t *testing.T, db *pgxpool.Pool, addr basics.Address, app uint64) { + query := `INSERT INTO account_app (addr, app, localstate, deleted, created_at) + VALUES ($1, $2, 'null'::jsonb, true, 0)` - query := `INSERT INTO account - (addr, microalgos, rewardsbase, rewards_total, deleted, created_at, account_data) - VALUES ($1, 0, 0, 0, false, 0, 'null'::jsonb)` - _, err := db.Exec(context.Background(), query, test.AccountA[:]) + _, err := db.Exec( + context.Background(), query, addr[:], app) require.NoError(t, err) +} - query = - "INSERT INTO account_app (addr, app, localstate, deleted, created_at) " + - "VALUES ($1, $2, $3, $4, 0)" +func insertAccountApp(t *testing.T, db *pgxpool.Pool, addr basics.Address, app uint64, localstate *basics.AppLocalState) { + query := `INSERT INTO account_app (addr, app, localstate, deleted, created_at) + VALUES ($1, $2, $3, false, 0)` - params1 := basics.AppLocalState{ + _, err := db.Exec( + context.Background(), query, addr[:], app, + encoding.EncodeAppLocalState(*localstate)) + require.NoError(t, err) +} + +func TestLedgerForEvaluatorAccountAppTable(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + stateA1 := basics.AppLocalState{ KeyValue: map[string]basics.TealValue{ string([]byte{0xff}): {}, // try a non-utf8 string }, } - _, err = db.Exec( - context.Background(), query, test.AccountA[:], 1, - encoding.EncodeAppLocalState(params1), false) - require.NoError(t, err) + insertAccountApp(t, db, test.AccountA, 1, &stateA1) - params2 := basics.AppLocalState{ + stateA2 := basics.AppLocalState{ KeyValue: map[string]basics.TealValue{ "abc": {}, }, } - _, err = db.Exec( - context.Background(), query, test.AccountA[:], 2, - encoding.EncodeAppLocalState(params2), false) - require.NoError(t, err) + insertAccountApp(t, db, test.AccountA, 2, &stateA2) - _, err = db.Exec( - context.Background(), query, test.AccountA[:], 3, "{}", true) // deteled - require.NoError(t, err) + insertDeletedAccountApp(t, db, test.AccountA, 3) - _, err = db.Exec( - context.Background(), query, test.AccountB[:], 4, "{}", false) // wrong account - require.NoError(t, err) + stateB3 := basics.AppLocalState{ + KeyValue: map[string]basics.TealValue{ + "abf": {}, + }, + } + insertAccountApp(t, db, test.AccountB, 3, &stateB3) tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) require.NoError(t, err) @@ -503,70 +594,107 @@ func TestLedgerForEvaluatorAccountAppTable(t *testing.T) { require.NoError(t, err) defer l.Close() - ret, err := - l.LookupWithoutRewards(map[basics.Address]struct{}{test.AccountA: {}}) + ret, err := l.LookupResources(map[basics.Address]map[ledger.Creatable]struct{}{ + test.AccountA: { + {Index: 1, Type: basics.AppCreatable}: {}, + {Index: 2, Type: basics.AppCreatable}: {}, + {Index: 3, Type: basics.AppCreatable}: {}, + {Index: 4, Type: basics.AppCreatable}: {}, + }, + test.AccountB: { + {Index: 3, Type: basics.AppCreatable}: {}, + }, + }) require.NoError(t, err) - accountDataRet := ret[test.AccountA] - require.NotNil(t, accountDataRet) + expected := map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource{ + test.AccountA: { + ledger.Creatable{Index: 1, Type: basics.AppCreatable}: { + AppLocalState: &stateA1, + }, + ledger.Creatable{Index: 2, Type: basics.AppCreatable}: { + AppLocalState: &stateA2, + }, + ledger.Creatable{Index: 3, Type: basics.AppCreatable}: {}, + ledger.Creatable{Index: 4, Type: basics.AppCreatable}: {}, + }, + test.AccountB: { + ledger.Creatable{Index: 3, Type: basics.AppCreatable}: { + AppLocalState: &stateB3, + }, + }, + } + assert.Equal(t, expected, ret) +} + +func TestLedgerForEvaluatorFetchAllResourceTypes(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + insertAccountAsset(t, db, test.AccountA, 1, 2, true) + insertAsset(t, db, 1, test.AccountA, &basics.AssetParams{Total: 3}) + insertAccountApp( + t, db, test.AccountA, 4, + &basics.AppLocalState{Schema: basics.StateSchema{NumUint: 5}}) + insertApp(t, db, 4, test.AccountA, &basics.AppParams{ExtraProgramPages: 6}) + + tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) + require.NoError(t, err) + defer tx.Rollback(context.Background()) + + l, err := ledger_for_evaluator.MakeLedgerForEvaluator(tx, basics.Round(0)) + require.NoError(t, err) + defer l.Close() - accountDataExpected := basics.AccountData{ - AppLocalStates: map[basics.AppIndex]basics.AppLocalState{ - 1: params1, - 2: params2, + ret, err := l.LookupResources(map[basics.Address]map[ledger.Creatable]struct{}{ + test.AccountA: { + {Index: 1, Type: basics.AssetCreatable}: {}, + {Index: 4, Type: basics.AppCreatable}: {}, + }, + }) + require.NoError(t, err) + + expected := map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource{ + test.AccountA: { + ledger.Creatable{Index: 1, Type: basics.AssetCreatable}: { + AssetHolding: &basics.AssetHolding{ + Amount: 2, + Frozen: true, + }, + AssetParams: &basics.AssetParams{ + Total: 3, + }, + }, + ledger.Creatable{Index: 4, Type: basics.AppCreatable}: { + AppLocalState: &basics.AppLocalState{ + Schema: basics.StateSchema{ + NumUint: 5, + }, + }, + AppParams: &basics.AppParams{ + ExtraProgramPages: 6, + }, + }, }, } - assert.Equal(t, accountDataExpected, *accountDataRet) + assert.Equal(t, expected, ret) } -// Tests that queuing and reading from a batch when using PreloadAccounts() -// is in the same order. +// Tests that queuing and reading from a batch is in the same order. func TestLedgerForEvaluatorLookupMultipleAccounts(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() addAccountQuery := `INSERT INTO account (addr, microalgos, rewardsbase, rewards_total, deleted, created_at, account_data) VALUES ($1, 0, 0, 0, false, 0, 'null'::jsonb)` - addAccountAssetQuery := - "INSERT INTO account_asset (addr, assetid, amount, frozen, deleted, created_at) " + - "VALUES ($1, $2, 0, false, false, 0)" - addAssetQuery := - "INSERT INTO asset (index, creator_addr, params, deleted, created_at) " + - "VALUES ($1, $2, '{}', false, 0)" - addAppQuery := - "INSERT INTO app (index, creator, params, deleted, created_at) " + - "VALUES ($1, $2, '{}', false, 0)" - addAccountAppQuery := - "INSERT INTO account_app (addr, app, localstate, deleted, created_at) " + - "VALUES ($1, $2, '{}', false, 0)" addresses := []basics.Address{ test.AccountA, test.AccountB, test.AccountC, test.AccountD, test.AccountE} - seq := []int{4, 9, 3, 6, 5, 1} - for i, address := range addresses { + for _, address := range addresses { _, err := db.Exec(context.Background(), addAccountQuery, address[:]) require.NoError(t, err) - - // Insert all types of creatables. Note that no creatable id is ever repeated. - for j := range seq { - _, err = db.Exec( - context.Background(), addAccountAssetQuery, address[:], i+10*j+100) - require.NoError(t, err) - - _, err = db.Exec( - context.Background(), addAssetQuery, i+10*j+200, address[:]) - require.NoError(t, err) - - _, err = db.Exec( - context.Background(), addAppQuery, i+10*j+300, address[:]) - require.NoError(t, err) - - _, err = db.Exec( - context.Background(), addAccountAppQuery, address[:], i+10*j+400) - require.NoError(t, err) - } } tx, err := db.BeginTx(context.Background(), readonlyRepeatableRead) @@ -587,33 +715,14 @@ func TestLedgerForEvaluatorLookupMultipleAccounts(t *testing.T) { ret, err := l.LookupWithoutRewards(addressesMap) require.NoError(t, err) - for i, address := range addresses { + for _, address := range addresses { accountData, _ := ret[address] require.NotNil(t, accountData) - - assert.Equal(t, len(seq), len(accountData.Assets)) - assert.Equal(t, len(seq), len(accountData.AssetParams)) - assert.Equal(t, len(seq), len(accountData.AppParams)) - assert.Equal(t, len(seq), len(accountData.AppLocalStates)) - - for j := range seq { - _, ok := accountData.Assets[basics.AssetIndex(i+10*j+100)] - assert.True(t, ok) - - _, ok = accountData.AssetParams[basics.AssetIndex(i+10*j+200)] - assert.True(t, ok) - - _, ok = accountData.AppParams[basics.AppIndex(i+10*j+300)] - assert.True(t, ok) - - _, ok = accountData.AppLocalStates[basics.AppIndex(i+10*j+400)] - assert.True(t, ok) - } } } func TestLedgerForEvaluatorAssetCreatorBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() query := @@ -645,7 +754,7 @@ func TestLedgerForEvaluatorAssetCreatorBasic(t *testing.T) { } func TestLedgerForEvaluatorAssetCreatorDeleted(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() query := @@ -673,7 +782,7 @@ func TestLedgerForEvaluatorAssetCreatorDeleted(t *testing.T) { } func TestLedgerForEvaluatorAssetCreatorMultiple(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() creatorsMap := map[basics.AssetIndex]basics.Address{ @@ -729,7 +838,7 @@ func TestLedgerForEvaluatorAssetCreatorMultiple(t *testing.T) { } func TestLedgerForEvaluatorAppCreatorBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() query := @@ -761,7 +870,7 @@ func TestLedgerForEvaluatorAppCreatorBasic(t *testing.T) { } func TestLedgerForEvaluatorAppCreatorDeleted(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() query := @@ -789,7 +898,7 @@ func TestLedgerForEvaluatorAppCreatorDeleted(t *testing.T) { } func TestLedgerForEvaluatorAppCreatorMultiple(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() creatorsMap := map[basics.AppIndex]basics.Address{ @@ -845,7 +954,7 @@ func TestLedgerForEvaluatorAppCreatorMultiple(t *testing.T) { } func TestLedgerForEvaluatorAccountTotals(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() accountTotals := ledgercore.AccountTotals{ diff --git a/idb/postgres/internal/migrations/convert_account_data/m.go b/idb/postgres/internal/migrations/convert_account_data/m.go new file mode 100644 index 000000000..e7cb13021 --- /dev/null +++ b/idb/postgres/internal/migrations/convert_account_data/m.go @@ -0,0 +1,188 @@ +package convertaccountdata + +import ( + "context" + "fmt" + + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/ledger/ledgercore" + + "github.com/jackc/pgx/v4" + + "github.com/algorand/indexer/idb/postgres/internal/encoding" +) + +type aad struct { + address basics.Address + trimmedAccountData basics.AccountData +} + +func getAccounts(tx pgx.Tx, batchSize uint, lastAddress *basics.Address) ([]aad, error) { + var rows pgx.Rows + var err error + if lastAddress == nil { + query := + `SELECT addr, account_data FROM account WHERE NOT deleted ORDER BY addr LIMIT $1` + rows, err = tx.Query(context.Background(), query, batchSize) + } else { + query := `SELECT addr, account_data FROM account WHERE NOT deleted AND addr > $1 + ORDER BY addr LIMIT $2` + rows, err = tx.Query(context.Background(), query, (*lastAddress)[:], batchSize) + } + if err != nil { + return nil, fmt.Errorf("getAccounts() query err: %w", err) + } + + res := make([]aad, 0, batchSize) + for rows.Next() { + var addr []byte + var accountData []byte + err = rows.Scan(&addr, &accountData) + if err != nil { + return nil, fmt.Errorf("getAccounts() scan err: %w", err) + } + + res = append(res, aad{}) + e := &res[len(res)-1] + copy(e.address[:], addr) + e.trimmedAccountData, err = encoding.DecodeTrimmedAccountData(accountData) + if err != nil { + return nil, fmt.Errorf("getAccounts() decode err: %w", err) + } + } + err = rows.Err() + if err != nil { + return nil, fmt.Errorf("getAccounts() rows error err: %w", err) + } + + return res, nil +} + +func computeLcAccountData(tx pgx.Tx, accounts []aad) ([]ledgercore.AccountData, error) { + res := make([]ledgercore.AccountData, 0, len(accounts)) + for i := range accounts { + res = append(res, ledgercore.ToAccountData(accounts[i].trimmedAccountData)) + } + + var batch pgx.Batch + for i := range accounts { + batch.Queue( + "SELECT COUNT(*) FROM account_asset WHERE NOT deleted AND addr = $1", + accounts[i].address[:]) + } + for i := range accounts { + batch.Queue( + "SELECT COUNT(*) FROM asset WHERE NOT deleted AND creator_addr = $1", + accounts[i].address[:]) + } + for i := range accounts { + batch.Queue( + "SELECT COUNT(*) FROM app WHERE NOT deleted AND creator = $1", + accounts[i].address[:]) + } + for i := range accounts { + batch.Queue( + "SELECT COUNT(*) FROM account_app WHERE NOT deleted AND addr = $1", + accounts[i].address[:]) + } + + results := tx.SendBatch(context.Background(), &batch) + defer results.Close() + + for i := range accounts { + err := results.QueryRow().Scan(&res[i].TotalAssets) + if err != nil { + return nil, fmt.Errorf("computeLcAccountData() scan total assets err: %w", err) + } + } + for i := range accounts { + err := results.QueryRow().Scan(&res[i].TotalAssetParams) + if err != nil { + return nil, fmt.Errorf("computeLcAccountData() scan total asset params err: %w", err) + } + } + for i := range accounts { + err := results.QueryRow().Scan(&res[i].TotalAppParams) + if err != nil { + return nil, fmt.Errorf("computeLcAccountData() scan total app params err: %w", err) + } + } + for i := range accounts { + err := results.QueryRow().Scan(&res[i].TotalAppLocalStates) + if err != nil { + return nil, fmt.Errorf("computeLcAccountData() scan total app local states err: %w", err) + } + } + + err := results.Close() + if err != nil { + return nil, fmt.Errorf("computeLcAccountData() close results err: %w", err) + } + + return res, nil +} + +func writeLcAccountData(tx pgx.Tx, accounts []aad, lcAccountData []ledgercore.AccountData) error { + var batch pgx.Batch + for i := range accounts { + query := "UPDATE account SET account_data = $1 WHERE addr = $2" + batch.Queue( + query, encoding.EncodeTrimmedLcAccountData(lcAccountData[i]), + accounts[i].address[:]) + } + + results := tx.SendBatch(context.Background(), &batch) + // Clean the results off the connection's queue. Without this, weird things happen. + for i := 0; i < batch.Len(); i++ { + _, err := results.Exec() + if err != nil { + results.Close() + return fmt.Errorf("writeLcAccountData() exec err: %w", err) + } + } + err := results.Close() + if err != nil { + return fmt.Errorf("writeLcAccountData() close results err: %w", err) + } + + return nil +} + +func processAccounts(tx pgx.Tx, accounts []aad) error { + lcAccountData, err := computeLcAccountData(tx, accounts) + if err != nil { + return fmt.Errorf("processAccounts() err: %w", err) + } + + err = writeLcAccountData(tx, accounts, lcAccountData) + if err != nil { + return fmt.Errorf("processAccounts() err: %w", err) + } + + return nil +} + +// RunMigration executes the migration core functionality. +func RunMigration(tx pgx.Tx, batchSize uint) error { + accounts, err := getAccounts(tx, batchSize, nil) + if err != nil { + return fmt.Errorf("RunMigration() err: %w", err) + } + err = processAccounts(tx, accounts) + if err != nil { + return fmt.Errorf("RunMigration() err: %w", err) + } + + for uint(len(accounts)) >= batchSize { + accounts, err = getAccounts(tx, batchSize, &accounts[len(accounts)-1].address) + if err != nil { + return fmt.Errorf("RunMigration() err: %w", err) + } + err = processAccounts(tx, accounts) + if err != nil { + return fmt.Errorf("RunMigration() err: %w", err) + } + } + + return nil +} diff --git a/idb/postgres/internal/migrations/convert_account_data/m_test.go b/idb/postgres/internal/migrations/convert_account_data/m_test.go new file mode 100644 index 000000000..ba0665ba2 --- /dev/null +++ b/idb/postgres/internal/migrations/convert_account_data/m_test.go @@ -0,0 +1,270 @@ +package convertaccountdata_test + +import ( + "context" + "fmt" + "testing" + + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/ledger/ledgercore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" + + "github.com/algorand/indexer/idb/postgres/internal/encoding" + cad "github.com/algorand/indexer/idb/postgres/internal/migrations/convert_account_data" + pgtest "github.com/algorand/indexer/idb/postgres/internal/testing" + pgutil "github.com/algorand/indexer/idb/postgres/internal/util" +) + +func makeAddress(i int) basics.Address { + var address basics.Address + address[0] = byte(i) + return address +} + +func insertAccount(t *testing.T, db *pgxpool.Pool, address basics.Address, trimmedAccountData *basics.AccountData) { + query := `INSERT INTO account (addr, microalgos, rewardsbase, rewards_total, deleted, + created_at, account_data) VALUES ($1, 0, 0, 0, false, 0, $2)` + _, err := db.Exec( + context.Background(), query, address[:], + encoding.EncodeTrimmedAccountData(*trimmedAccountData)) + require.NoError(t, err) +} + +func insertDeletedAccount(t *testing.T, db *pgxpool.Pool, address basics.Address) { + query := `INSERT INTO account (addr, microalgos, rewardsbase, rewards_total, deleted, + created_at, account_data) VALUES ($1, 0, 0, 0, true, 0, 'null'::jsonb)` + _, err := db.Exec(context.Background(), query, address[:]) + require.NoError(t, err) +} + +func checkAccount(t *testing.T, db *pgxpool.Pool, address basics.Address, accountData *ledgercore.AccountData) { + query := "SELECT account_data FROM account WHERE addr = $1" + row := db.QueryRow(context.Background(), query, address[:]) + + var buf []byte + err := row.Scan(&buf) + require.NoError(t, err) + + ret, err := encoding.DecodeTrimmedLcAccountData(buf) + require.NoError(t, err) + + assert.Equal(t, accountData, &ret) +} + +func checkDeletedAccount(t *testing.T, db *pgxpool.Pool, address basics.Address) { + query := "SELECT account_data FROM account WHERE addr = $1" + row := db.QueryRow(context.Background(), query, address[:]) + + var buf []byte + err := row.Scan(&buf) + require.NoError(t, err) + + assert.Equal(t, []byte("null"), buf) +} + +func insertAccountAsset(t *testing.T, db *pgxpool.Pool, address basics.Address, assetid uint64, deleted bool) { + query := `INSERT INTO account_asset (addr, assetid, amount, frozen, deleted, + created_at) VALUES ($1, $2, 0, false, $3, 0)` + _, err := db.Exec(context.Background(), query, address[:], assetid, deleted) + require.NoError(t, err) +} + +func insertAsset(t *testing.T, db *pgxpool.Pool, assetid uint64, address basics.Address, deleted bool) { + query := `INSERT INTO asset (index, creator_addr, params, deleted, created_at) + VALUES ($1, $2, 'null'::jsonb, $3, 0)` + _, err := db.Exec(context.Background(), query, assetid, address[:], deleted) + require.NoError(t, err) +} + +func insertApp(t *testing.T, db *pgxpool.Pool, appid uint64, address basics.Address, deleted bool) { + query := `INSERT INTO app (index, creator, params, deleted, created_at) + VALUES ($1, $2, 'null'::jsonb, $3, 0)` + _, err := db.Exec(context.Background(), query, appid, address[:], deleted) + require.NoError(t, err) +} + +func insertAccountApp(t *testing.T, db *pgxpool.Pool, address basics.Address, appid uint64, deleted bool) { + query := `INSERT INTO account_app (addr, app, localstate, deleted, created_at) + VALUES ($1, $2, 'null'::jsonb, $3, 0)` + _, err := db.Exec(context.Background(), query, address[:], appid, deleted) + require.NoError(t, err) +} + +func TestBasic(t *testing.T) { + for _, i := range []int{1, 2, 3, 4} { + t.Run(fmt.Sprint(i), func(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + insertAccount(t, db, makeAddress(1), &basics.AccountData{VoteKeyDilution: 1}) + insertDeletedAccount(t, db, makeAddress(2)) + insertAccount(t, db, makeAddress(3), &basics.AccountData{VoteKeyDilution: 3}) + + f := func(tx pgx.Tx) error { + return cad.RunMigration(tx, 1) + } + err := pgutil.TxWithRetry(db, pgx.TxOptions{IsoLevel: pgx.Serializable}, f, nil) + require.NoError(t, err) + + checkAccount( + t, db, makeAddress(1), + &ledgercore.AccountData{VotingData: ledgercore.VotingData{VoteKeyDilution: 1}}) + checkDeletedAccount(t, db, makeAddress(2)) + checkAccount( + t, db, makeAddress(3), + &ledgercore.AccountData{VotingData: ledgercore.VotingData{VoteKeyDilution: 3}}) + }) + } +} + +func TestAccountAssetCount(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + insertAccount(t, db, makeAddress(1), &basics.AccountData{VoteKeyDilution: 1}) + for i := uint64(2); i < 10; i++ { + insertAccountAsset(t, db, makeAddress(1), i, i%2 == 0) + } + + f := func(tx pgx.Tx) error { + return cad.RunMigration(tx, 1) + } + err := pgutil.TxWithRetry(db, pgx.TxOptions{IsoLevel: pgx.Serializable}, f, nil) + require.NoError(t, err) + + expected := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + TotalAssets: 4, + }, + VotingData: ledgercore.VotingData{ + VoteKeyDilution: 1, + }, + } + checkAccount(t, db, makeAddress(1), &expected) +} + +func TestAssetCount(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + insertAccount(t, db, makeAddress(1), &basics.AccountData{VoteKeyDilution: 1}) + for i := uint64(2); i < 10; i++ { + insertAsset(t, db, i, makeAddress(1), i%2 == 0) + } + + f := func(tx pgx.Tx) error { + return cad.RunMigration(tx, 1) + } + err := pgutil.TxWithRetry(db, pgx.TxOptions{IsoLevel: pgx.Serializable}, f, nil) + require.NoError(t, err) + + expected := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + TotalAssetParams: 4, + }, + VotingData: ledgercore.VotingData{ + VoteKeyDilution: 1, + }, + } + checkAccount(t, db, makeAddress(1), &expected) +} + +func TestAppCount(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + insertAccount(t, db, makeAddress(1), &basics.AccountData{VoteKeyDilution: 1}) + for i := uint64(2); i < 10; i++ { + insertApp(t, db, i, makeAddress(1), i%2 == 0) + } + + f := func(tx pgx.Tx) error { + return cad.RunMigration(tx, 1) + } + err := pgutil.TxWithRetry(db, pgx.TxOptions{IsoLevel: pgx.Serializable}, f, nil) + require.NoError(t, err) + + expected := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + TotalAppParams: 4, + }, + VotingData: ledgercore.VotingData{ + VoteKeyDilution: 1, + }, + } + checkAccount(t, db, makeAddress(1), &expected) +} + +func TestAccountAppCount(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + insertAccount(t, db, makeAddress(1), &basics.AccountData{VoteKeyDilution: 1}) + for i := uint64(2); i < 10; i++ { + insertAccountApp(t, db, makeAddress(1), i, i%2 == 0) + } + + f := func(tx pgx.Tx) error { + return cad.RunMigration(tx, 1) + } + err := pgutil.TxWithRetry(db, pgx.TxOptions{IsoLevel: pgx.Serializable}, f, nil) + require.NoError(t, err) + + expected := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + TotalAppLocalStates: 4, + }, + VotingData: ledgercore.VotingData{ + VoteKeyDilution: 1, + }, + } + checkAccount(t, db, makeAddress(1), &expected) +} + +func TestAllResourcesMultipleAccounts(t *testing.T) { + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + numAccounts := 14 + + for i := 0; i < numAccounts; i++ { + insertAccount(t, db, makeAddress(i), &basics.AccountData{VoteKeyDilution: uint64(i)}) + for j := uint64(20); j < 30; j++ { + insertAccountAsset(t, db, makeAddress(i), j, j%2 == 0) + } + for j := uint64(30); j < 50; j++ { + insertAsset(t, db, uint64(i)*1000+j, makeAddress(i), j%2 == 0) + } + for j := uint64(50); j < 80; j++ { + insertApp(t, db, uint64(i)*1000+j, makeAddress(i), j%2 == 0) + } + for j := uint64(80); j < 120; j++ { + insertAccountApp(t, db, makeAddress(i), j, j%2 == 0) + } + } + + f := func(tx pgx.Tx) error { + return cad.RunMigration(tx, 5) + } + err := pgutil.TxWithRetry(db, pgx.TxOptions{IsoLevel: pgx.Serializable}, f, nil) + require.NoError(t, err) + + for i := 0; i < numAccounts; i++ { + expected := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + TotalAssets: 5, + TotalAssetParams: 10, + TotalAppParams: 15, + TotalAppLocalStates: 20, + }, + VotingData: ledgercore.VotingData{ + VoteKeyDilution: uint64(i), + }, + } + checkAccount(t, db, makeAddress(i), &expected) + } +} diff --git a/idb/postgres/internal/schema/setup_postgres.sql b/idb/postgres/internal/schema/setup_postgres.sql index fb3d63824..9337c0e10 100644 --- a/idb/postgres/internal/schema/setup_postgres.sql +++ b/idb/postgres/internal/schema/setup_postgres.sql @@ -49,7 +49,7 @@ CREATE TABLE IF NOT EXISTS account ( created_at bigint NOT NULL, -- round that the account is first used closed_at bigint, -- round that the account was last closed keytype varchar(8), -- "sig", "msig", "lsig", or NULL if unknown - account_data jsonb NOT NULL -- trimmed AccountData that excludes the fields above and the four creatable maps; SQL 'NOT NULL' is held though the json string will be "null" iff account is deleted + account_data jsonb NOT NULL -- trimmed ledgercore.AccountData that excludes the fields above; SQL 'NOT NULL' is held though the json string will be "null" iff account is deleted ); -- data.basics.AccountData Assets[asset id] AssetHolding{} diff --git a/idb/postgres/internal/schema/setup_postgres_sql.go b/idb/postgres/internal/schema/setup_postgres_sql.go index acad7851b..3d5fb0a2a 100644 --- a/idb/postgres/internal/schema/setup_postgres_sql.go +++ b/idb/postgres/internal/schema/setup_postgres_sql.go @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS account ( created_at bigint NOT NULL, -- round that the account is first used closed_at bigint, -- round that the account was last closed keytype varchar(8), -- "sig", "msig", "lsig", or NULL if unknown - account_data jsonb NOT NULL -- trimmed AccountData that excludes the fields above and the four creatable maps; SQL 'NOT NULL' is held though the json string will be "null" iff account is deleted + account_data jsonb NOT NULL -- trimmed ledgercore.AccountData that excludes the fields above; SQL 'NOT NULL' is held though the json string will be "null" iff account is deleted ); -- data.basics.AccountData Assets[asset id] AssetHolding{} diff --git a/idb/postgres/internal/testing/testing.go b/idb/postgres/internal/testing/testing.go index 433225353..34f049ad5 100644 --- a/idb/postgres/internal/testing/testing.go +++ b/idb/postgres/internal/testing/testing.go @@ -10,6 +10,8 @@ import ( "github.com/orlangure/gnomock" "github.com/orlangure/gnomock/preset/postgres" "github.com/stretchr/testify/require" + + "github.com/algorand/indexer/idb/postgres/internal/schema" ) var testpg = flag.String( @@ -61,3 +63,14 @@ func SetupPostgres(t *testing.T) (*pgxpool.Pool, string, func()) { return db, connStr, shutdownFunc } + +// SetupPostgresWithSchema is equivalent to SetupPostgres() but also creates the +// indexer schema. +func SetupPostgresWithSchema(t *testing.T) (*pgxpool.Pool, string, func()) { + db, connStr, shutdownFunc := SetupPostgres(t) + + _, err := db.Exec(context.Background(), schema.SetupPostgresSql) + require.NoError(t, err) + + return db, connStr, shutdownFunc +} diff --git a/idb/postgres/internal/writer/writer.go b/idb/postgres/internal/writer/writer.go index 0ba5e491f..9d61efae6 100644 --- a/idb/postgres/internal/writer/writer.go +++ b/idb/postgres/internal/writer/writer.go @@ -178,36 +178,7 @@ type optionalSigTypeDelta struct { value sigTypeDelta } -func writeAccount(round basics.Round, address basics.Address, accountData basics.AccountData, sigtypeDelta optionalSigTypeDelta, batch *pgx.Batch) { - // Update `asset` table. - for assetid, params := range accountData.AssetParams { - batch.Queue( - upsertAssetStmtName, - uint64(assetid), address[:], encoding.EncodeAssetParams(params), uint64(round)) - } - - // Update `account_asset` table. - for assetid, holding := range accountData.Assets { - batch.Queue( - upsertAccountAssetStmtName, - address[:], uint64(assetid), strconv.FormatUint(holding.Amount, 10), - holding.Frozen, uint64(round)) - } - - // Update `app` table. - for appid, params := range accountData.AppParams { - batch.Queue( - upsertAppStmtName, - uint64(appid), address[:], encoding.EncodeAppParams(params), uint64(round)) - } - - // Update `account_app` table. - for appid, state := range accountData.AppLocalStates { - batch.Queue( - upsertAccountAppStmtName, - address[:], uint64(appid), encoding.EncodeAppLocalState(state), uint64(round)) - } - +func writeAccount(round basics.Round, address basics.Address, accountData ledgercore.AccountData, sigtypeDelta optionalSigTypeDelta, batch *pgx.Batch) { sigtypeFunc := func(delta sigTypeDelta) *idb.SigType { if !delta.present { return nil @@ -218,7 +189,6 @@ func writeAccount(round basics.Round, address basics.Address, accountData basics return res } - // Update `account` table. if accountData.IsZero() { // Delete account. if sigtypeDelta.present { @@ -231,7 +201,7 @@ func writeAccount(round basics.Round, address basics.Address, accountData basics } else { // Update account. accountDataJSON := - encoding.EncodeTrimmedAccountData(encoding.TrimAccountData(accountData)) + encoding.EncodeTrimmedLcAccountData(encoding.TrimLcAccountData(accountData)) if sigtypeDelta.present { batch.Queue( @@ -249,53 +219,75 @@ func writeAccount(round basics.Round, address basics.Address, accountData basics } } -func writeAccounts(round basics.Round, accountDeltas ledgercore.AccountDeltas, sigtypeDeltas map[basics.Address]sigTypeDelta, batch *pgx.Batch) { - // Update `account` table. - for i := 0; i < accountDeltas.Len(); i++ { - address, accountData := accountDeltas.GetByIdx(i) - - var sigtypeDelta optionalSigTypeDelta - sigtypeDelta.value, sigtypeDelta.present = sigtypeDeltas[address] - - writeAccount(round, address, accountData, sigtypeDelta, batch) +func writeAssetResource(round basics.Round, resource *ledgercore.AssetResourceRecord, batch *pgx.Batch) { + if resource.Params.Deleted { + batch.Queue(deleteAssetStmtName, resource.Aidx, resource.Addr[:], round) + } else { + if resource.Params.Params != nil { + batch.Queue( + upsertAssetStmtName, resource.Aidx, resource.Addr[:], + encoding.EncodeAssetParams(*resource.Params.Params), round) + } } -} -func writeDeletedCreatables(round basics.Round, creatables map[basics.CreatableIndex]ledgercore.ModifiedCreatable, batch *pgx.Batch) { - for index, creatable := range creatables { - // If deleted. - if !creatable.Created { - creator := new(basics.Address) - *creator = creatable.Creator - - if creatable.Ctype == basics.AssetCreatable { - batch.Queue(deleteAssetStmtName, uint64(index), creator[:], uint64(round)) - } else { - batch.Queue(deleteAppStmtName, uint64(index), creator[:], uint64(round)) - } + if resource.Holding.Deleted { + batch.Queue(deleteAccountAssetStmtName, resource.Addr[:], resource.Aidx, round) + } else { + if resource.Holding.Holding != nil { + batch.Queue( + upsertAccountAssetStmtName, resource.Addr[:], resource.Aidx, + strconv.FormatUint(resource.Holding.Holding.Amount, 10), + resource.Holding.Holding.Frozen, round) } } } -func writeDeletedAssetHoldings(round basics.Round, modifiedAssetHoldings map[ledgercore.AccountAsset]bool, batch *pgx.Batch) { - for aa, created := range modifiedAssetHoldings { - if !created { - address := new(basics.Address) - *address = aa.Address +func writeAppResource(round basics.Round, resource *ledgercore.AppResourceRecord, batch *pgx.Batch) { + if resource.Params.Deleted { + batch.Queue(deleteAppStmtName, resource.Aidx, resource.Addr[:], round) + } else { + if resource.Params.Params != nil { + batch.Queue( + upsertAppStmtName, resource.Aidx, resource.Addr[:], + encoding.EncodeAppParams(*resource.Params.Params), round) + } + } + if resource.State.Deleted { + batch.Queue(deleteAccountAppStmtName, resource.Addr[:], resource.Aidx, round) + } else { + if resource.State.LocalState != nil { batch.Queue( - deleteAccountAssetStmtName, address[:], uint64(aa.Asset), uint64(round)) + upsertAccountAppStmtName, resource.Addr[:], resource.Aidx, + encoding.EncodeAppLocalState(*resource.State.LocalState), round) } } } -func writeDeletedAppLocalStates(round basics.Round, modifiedAppLocalStates map[ledgercore.AccountApp]bool, batch *pgx.Batch) { - for aa, created := range modifiedAppLocalStates { - if !created { - address := new(basics.Address) - *address = aa.Address +func writeAccountDeltas(round basics.Round, accountDeltas *ledgercore.AccountDeltas, sigtypeDeltas map[basics.Address]sigTypeDelta, batch *pgx.Batch) { + // Update `account` table. + for i := 0; i < accountDeltas.Len(); i++ { + address, accountData := accountDeltas.GetByIdx(i) - batch.Queue(deleteAccountAppStmtName, address[:], uint64(aa.App), uint64(round)) + var sigtypeDelta optionalSigTypeDelta + sigtypeDelta.value, sigtypeDelta.present = sigtypeDeltas[address] + + writeAccount(round, address, accountData, sigtypeDelta, batch) + } + + // Update `asset` and `account_asset` tables. + { + assetResources := accountDeltas.GetAllAssetResources() + for i := range assetResources { + writeAssetResource(round, &assetResources[i], batch) + } + } + + // Update `app` and `account_app` tables. + { + appResources := accountDeltas.GetAllAppResources() + for i := range appResources { + writeAppResource(round, &appResources[i], batch) } } } @@ -345,11 +337,8 @@ func (w *Writer) AddBlock(block *bookkeeping.Block, modifiedTxns []transactions. if err != nil { return fmt.Errorf("AddBlock() err: %w", err) } - writeAccounts(block.Round(), delta.Accts, sigTypeDeltas, &batch) + writeAccountDeltas(block.Round(), &delta.Accts, sigTypeDeltas, &batch) } - writeDeletedCreatables(block.Round(), delta.Creatables, &batch) - writeDeletedAssetHoldings(block.Round(), delta.ModifiedAssetHoldings, &batch) - writeDeletedAppLocalStates(block.Round(), delta.ModifiedAppLocalStates, &batch) batch.Queue(updateAccountTotalsStmtName, encoding.EncodeAccountTotals(&delta.Totals)) results := w.tx.SendBatch(context.Background(), &batch) diff --git a/idb/postgres/internal/writer/writer_test.go b/idb/postgres/internal/writer/writer_test.go index 70d57441e..3b0902c0e 100644 --- a/idb/postgres/internal/writer/writer_test.go +++ b/idb/postgres/internal/writer/writer_test.go @@ -29,15 +29,6 @@ import ( var serializable = pgx.TxOptions{IsoLevel: pgx.Serializable} -func setupPostgres(t *testing.T) (*pgxpool.Pool, func()) { - db, _, shutdownFunc := pgtest.SetupPostgres(t) - - _, err := db.Exec(context.Background(), schema.SetupPostgresSql) - require.NoError(t, err) - - return db, shutdownFunc -} - // makeTx is a helper to simplify calling TxWithRetry func makeTx(db *pgxpool.Pool, f func(tx pgx.Tx) error) error { return pgutil.TxWithRetry(db, serializable, f, nil) @@ -105,7 +96,7 @@ func txnParticipationQuery(db *pgxpool.Pool, query string) ([]txnParticipationRo } func TestWriterBlockHeaderTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block @@ -146,7 +137,7 @@ func TestWriterBlockHeaderTableBasic(t *testing.T) { } func TestWriterSpecialAccounts(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() block := test.MakeGenesisBlock() @@ -178,7 +169,7 @@ func TestWriterSpecialAccounts(t *testing.T) { } func TestWriterTxnTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() block := bookkeeping.Block{ @@ -267,7 +258,7 @@ func TestWriterTxnTableBasic(t *testing.T) { // Test that asset close amount is written even if it is missing in the apply data // in the block (it is present in the "modified transactions"). func TestWriterTxnTableAssetCloseAmount(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() block := bookkeeping.Block{ @@ -412,7 +403,7 @@ func TestWriterTxnParticipationTable(t *testing.T) { for _, testcase := range tests { t.Run(testcase.name, func(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() block := makeBlockFunc() @@ -436,7 +427,7 @@ func TestWriterTxnParticipationTable(t *testing.T) { // Create a new account and then delete it. func TestWriterAccountTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var voteID crypto.OneTimeSignatureVerifier @@ -452,17 +443,21 @@ func TestWriterAccountTableBasic(t *testing.T) { block.BlockHeader.Round = 4 var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, basics.AccountData{ - Status: basics.Online, - MicroAlgos: basics.MicroAlgos{Raw: 5}, - RewardsBase: 6, - RewardedMicroAlgos: basics.MicroAlgos{Raw: 7}, - VoteID: voteID, - SelectionID: selectionID, - VoteFirstValid: 7, - VoteLastValid: 8, - VoteKeyDilution: 9, - AuthAddr: authAddr, + delta.Accts.Upsert(test.AccountA, ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + Status: basics.Online, + MicroAlgos: basics.MicroAlgos{Raw: 5}, + RewardsBase: 6, + RewardedMicroAlgos: basics.MicroAlgos{Raw: 7}, + AuthAddr: authAddr, + }, + VotingData: ledgercore.VotingData{ + VoteID: voteID, + SelectionID: selectionID, + VoteFirstValid: 7, + VoteLastValid: 8, + VoteKeyDilution: 9, + }, }) f := func(tx pgx.Tx) error { @@ -510,16 +505,9 @@ func TestWriterAccountTableBasic(t *testing.T) { assert.Nil(t, closedAt) assert.Nil(t, keytype) { - accountDataRead, err := encoding.DecodeTrimmedAccountData(accountData) + accountDataRead, err := encoding.DecodeTrimmedLcAccountData(accountData) require.NoError(t, err) - - assert.Equal(t, expectedAccountData.Status, accountDataRead.Status) - assert.Equal(t, expectedAccountData.VoteID, accountDataRead.VoteID) - assert.Equal(t, expectedAccountData.SelectionID, accountDataRead.SelectionID) - assert.Equal(t, expectedAccountData.VoteFirstValid, accountDataRead.VoteFirstValid) - assert.Equal(t, expectedAccountData.VoteLastValid, accountDataRead.VoteLastValid) - assert.Equal(t, expectedAccountData.VoteKeyDilution, accountDataRead.VoteKeyDilution) - assert.Equal(t, expectedAccountData.AuthAddr, accountDataRead.AuthAddr) + assert.Equal(t, encoding.TrimLcAccountData(expectedAccountData), accountDataRead) } assert.False(t, rows.Next()) @@ -528,7 +516,7 @@ func TestWriterAccountTableBasic(t *testing.T) { // Now delete this account. block.BlockHeader.Round++ delta.Accts = ledgercore.AccountDeltas{} - delta.Accts.Upsert(test.AccountA, basics.AccountData{}) + delta.Accts.Upsert(test.AccountA, ledgercore.AccountData{}) err = pgutil.TxWithRetry(db, serializable, f, nil) require.NoError(t, err) @@ -554,9 +542,9 @@ func TestWriterAccountTableBasic(t *testing.T) { assert.Nil(t, keytype) assert.Equal(t, []byte("null"), accountData) { - accountData, err := encoding.DecodeTrimmedAccountData(accountData) + accountData, err := encoding.DecodeTrimmedLcAccountData(accountData) require.NoError(t, err) - assert.Equal(t, basics.AccountData{}, accountData) + assert.Equal(t, ledgercore.AccountData{}, accountData) } assert.False(t, rows.Next()) @@ -565,14 +553,14 @@ func TestWriterAccountTableBasic(t *testing.T) { // Simulate the scenario where an account is created and deleted in the same round. func TestWriterAccountTableCreateDeleteSameRound(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block block.BlockHeader.Round = 4 var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, basics.AccountData{}) + delta.Accts.Upsert(test.AccountA, ledgercore.AccountData{}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -617,9 +605,9 @@ func TestWriterAccountTableCreateDeleteSameRound(t *testing.T) { assert.Nil(t, keytype) assert.Equal(t, []byte("null"), accountData) { - accountData, err := encoding.DecodeTrimmedAccountData(accountData) + accountData, err := encoding.DecodeTrimmedLcAccountData(accountData) require.NoError(t, err) - assert.Equal(t, basics.AccountData{}, accountData) + assert.Equal(t, ledgercore.AccountData{}, accountData) } assert.False(t, rows.Next()) @@ -627,7 +615,7 @@ func TestWriterAccountTableCreateDeleteSameRound(t *testing.T) { } func TestWriterDeleteAccountDoesNotDeleteKeytype(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() block := bookkeeping.Block{ @@ -651,8 +639,10 @@ func TestWriterDeleteAccountDoesNotDeleteKeytype(t *testing.T) { require.NoError(t, err) var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 5}, + delta.Accts.Upsert(test.AccountA, ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 5}, + }, }) f := func(tx pgx.Tx) error { @@ -678,7 +668,7 @@ func TestWriterDeleteAccountDoesNotDeleteKeytype(t *testing.T) { // Now delete this account. block.BlockHeader.Round = basics.Round(5) delta.Accts = ledgercore.AccountDeltas{} - delta.Accts.Upsert(test.AccountA, basics.AccountData{}) + delta.Accts.Upsert(test.AccountA, ledgercore.AccountData{}) err = pgutil.TxWithRetry(db, serializable, f, nil) require.NoError(t, err) @@ -690,7 +680,7 @@ func TestWriterDeleteAccountDoesNotDeleteKeytype(t *testing.T) { } func TestWriterAccountAssetTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block @@ -701,14 +691,10 @@ func TestWriterAccountAssetTableBasic(t *testing.T) { Amount: 4, Frozen: true, } - accountData := basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 5}, - Assets: map[basics.AssetIndex]basics.AssetHolding{ - assetID: assetHolding, - }, - } var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAssetResource( + test.AccountA, assetID, ledgercore.AssetParamsDelta{}, + ledgercore.AssetHoldingDelta{Holding: &assetHolding}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -753,12 +739,10 @@ func TestWriterAccountAssetTableBasic(t *testing.T) { // Now delete the asset. block.BlockHeader.Round++ - delta.ModifiedAssetHoldings = map[ledgercore.AccountAsset]bool{ - {Address: test.AccountA, Asset: assetID}: false, - } - accountData.Assets = nil delta.Accts = ledgercore.AccountDeltas{} - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAssetResource( + test.AccountA, assetID, ledgercore.AssetParamsDelta{}, + ledgercore.AssetHoldingDelta{Deleted: true}) err = pgutil.TxWithRetry(db, serializable, f, nil) require.NoError(t, err) @@ -786,18 +770,17 @@ func TestWriterAccountAssetTableBasic(t *testing.T) { // Simulate a scenario where an asset holding is added and deleted in the same round. func TestWriterAccountAssetTableCreateDeleteSameRound(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block block.BlockHeader.Round = basics.Round(1) assetID := basics.AssetIndex(3) - delta := ledgercore.StateDelta{ - ModifiedAssetHoldings: map[ledgercore.AccountAsset]bool{ - {Address: test.AccountA, Asset: assetID}: false, - }, - } + var delta ledgercore.StateDelta + delta.Accts.UpsertAssetResource( + test.AccountA, assetID, ledgercore.AssetParamsDelta{}, + ledgercore.AssetHoldingDelta{Deleted: true}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -834,7 +817,7 @@ func TestWriterAccountAssetTableCreateDeleteSameRound(t *testing.T) { } func TestWriterAccountAssetTableLargeAmount(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block @@ -845,12 +828,9 @@ func TestWriterAccountAssetTableLargeAmount(t *testing.T) { Amount: math.MaxUint64, } var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 5}, - Assets: map[basics.AssetIndex]basics.AssetHolding{ - assetID: assetHolding, - }, - }) + delta.Accts.UpsertAssetResource( + test.AccountA, assetID, ledgercore.AssetParamsDelta{}, + ledgercore.AssetHoldingDelta{Holding: &assetHolding}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -874,7 +854,7 @@ func TestWriterAccountAssetTableLargeAmount(t *testing.T) { } func TestWriterAssetTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block @@ -885,14 +865,10 @@ func TestWriterAssetTableBasic(t *testing.T) { Total: 99999, Manager: test.AccountB, } - accountData := basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 5}, - AssetParams: map[basics.AssetIndex]basics.AssetParams{ - assetID: assetParams, - }, - } var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAssetResource( + test.AccountA, assetID, ledgercore.AssetParamsDelta{Params: &assetParams}, + ledgercore.AssetHoldingDelta{}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -939,16 +915,10 @@ func TestWriterAssetTableBasic(t *testing.T) { // Now delete the asset. block.BlockHeader.Round++ - delta.Creatables = map[basics.CreatableIndex]ledgercore.ModifiedCreatable{ - basics.CreatableIndex(assetID): { - Ctype: basics.AssetCreatable, - Created: false, - Creator: test.AccountA, - }, - } - accountData.AssetParams = nil delta.Accts = ledgercore.AccountDeltas{} - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAssetResource( + test.AccountA, assetID, ledgercore.AssetParamsDelta{Deleted: true}, + ledgercore.AssetHoldingDelta{}) err = pgutil.TxWithRetry(db, serializable, f, nil) require.NoError(t, err) @@ -980,22 +950,17 @@ func TestWriterAssetTableBasic(t *testing.T) { // Simulate a scenario where an asset is added and deleted in the same round. func TestWriterAssetTableCreateDeleteSameRound(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block block.BlockHeader.Round = basics.Round(1) assetID := basics.AssetIndex(3) - delta := ledgercore.StateDelta{ - Creatables: map[basics.CreatableIndex]ledgercore.ModifiedCreatable{ - basics.CreatableIndex(assetID): { - Ctype: basics.AssetCreatable, - Created: false, - Creator: test.AccountA, - }, - }, - } + var delta ledgercore.StateDelta + delta.Accts.UpsertAssetResource( + test.AccountA, assetID, ledgercore.AssetParamsDelta{Deleted: true}, + ledgercore.AssetHoldingDelta{}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -1035,7 +1000,7 @@ func TestWriterAssetTableCreateDeleteSameRound(t *testing.T) { } func TestWriterAppTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block @@ -1050,14 +1015,10 @@ func TestWriterAppTableBasic(t *testing.T) { }, }, } - accountData := basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 5}, - AppParams: map[basics.AppIndex]basics.AppParams{ - appID: appParams, - }, - } var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAppResource( + test.AccountA, appID, ledgercore.AppParamsDelta{Params: &appParams}, + ledgercore.AppLocalStateDelta{}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -1104,16 +1065,10 @@ func TestWriterAppTableBasic(t *testing.T) { // Now delete the app. block.BlockHeader.Round++ - delta.Creatables = map[basics.CreatableIndex]ledgercore.ModifiedCreatable{ - basics.CreatableIndex(appID): { - Ctype: basics.AppCreatable, - Created: false, - Creator: test.AccountA, - }, - } - accountData.AppParams = nil delta.Accts = ledgercore.AccountDeltas{} - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAppResource( + test.AccountA, appID, ledgercore.AppParamsDelta{Deleted: true}, + ledgercore.AppLocalStateDelta{}) err = pgutil.TxWithRetry(db, serializable, f, nil) require.NoError(t, err) @@ -1145,22 +1100,17 @@ func TestWriterAppTableBasic(t *testing.T) { // Simulate a scenario where an app is added and deleted in the same round. func TestWriterAppTableCreateDeleteSameRound(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block block.BlockHeader.Round = basics.Round(1) appID := basics.AppIndex(3) - delta := ledgercore.StateDelta{ - Creatables: map[basics.CreatableIndex]ledgercore.ModifiedCreatable{ - basics.CreatableIndex(appID): { - Ctype: basics.AppCreatable, - Created: false, - Creator: test.AccountA, - }, - }, - } + var delta ledgercore.StateDelta + delta.Accts.UpsertAppResource( + test.AccountA, appID, ledgercore.AppParamsDelta{Deleted: true}, + ledgercore.AppLocalStateDelta{}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -1201,7 +1151,7 @@ func TestWriterAppTableCreateDeleteSameRound(t *testing.T) { } func TestWriterAccountAppTableBasic(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block @@ -1215,14 +1165,10 @@ func TestWriterAccountAppTableBasic(t *testing.T) { }, }, } - accountData := basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: 5}, - AppLocalStates: map[basics.AppIndex]basics.AppLocalState{ - appID: appLocalState, - }, - } var delta ledgercore.StateDelta - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAppResource( + test.AccountA, appID, ledgercore.AppParamsDelta{}, + ledgercore.AppLocalStateDelta{LocalState: &appLocalState}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -1269,12 +1215,10 @@ func TestWriterAccountAppTableBasic(t *testing.T) { // Now delete the app. block.BlockHeader.Round++ - delta.ModifiedAppLocalStates = map[ledgercore.AccountApp]bool{ - {Address: test.AccountA, App: appID}: false, - } - accountData.AppLocalStates = nil delta.Accts = ledgercore.AccountDeltas{} - delta.Accts.Upsert(test.AccountA, accountData) + delta.Accts.UpsertAppResource( + test.AccountA, appID, ledgercore.AppParamsDelta{}, + ledgercore.AppLocalStateDelta{Deleted: true}) err = pgutil.TxWithRetry(db, serializable, f, nil) require.NoError(t, err) @@ -1306,18 +1250,17 @@ func TestWriterAccountAppTableBasic(t *testing.T) { // Simulate a scenario where an account app is added and deleted in the same round. func TestWriterAccountAppTableCreateDeleteSameRound(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() var block bookkeeping.Block block.BlockHeader.Round = basics.Round(1) appID := basics.AppIndex(3) - delta := ledgercore.StateDelta{ - ModifiedAppLocalStates: map[ledgercore.AccountApp]bool{ - {Address: test.AccountA, App: appID}: false, - }, - } + var delta ledgercore.StateDelta + delta.Accts.UpsertAppResource( + test.AccountA, appID, ledgercore.AppParamsDelta{}, + ledgercore.AppLocalStateDelta{Deleted: true}) f := func(tx pgx.Tx) error { w, err := writer.MakeWriter(tx) @@ -1357,7 +1300,7 @@ func TestWriterAccountAppTableCreateDeleteSameRound(t *testing.T) { } func TestAddBlockInvalidInnerAsset(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() callWithBadInner := test.MakeCreateAppTxn(test.AccountA) @@ -1391,7 +1334,7 @@ func TestAddBlockInvalidInnerAsset(t *testing.T) { } func TestWriterAddBlockInnerTxnsAssetCreate(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() // App call with inner txns, should be intra 0, 1, 2, 3, 4 @@ -1529,7 +1472,7 @@ func TestWriterAddBlockInnerTxnsAssetCreate(t *testing.T) { } func TestWriterAccountTotals(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() // Set empty account totals. @@ -1567,7 +1510,7 @@ func TestWriterAccountTotals(t *testing.T) { } func TestWriterAddBlock0(t *testing.T) { - db, shutdownFunc := setupPostgres(t) + db, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) defer shutdownFunc() block := test.MakeGenesisBlock() diff --git a/idb/postgres/postgres.go b/idb/postgres/postgres.go index 8a3d20784..6fc9b44d5 100644 --- a/idb/postgres/postgres.go +++ b/idb/postgres/postgres.go @@ -21,7 +21,6 @@ import ( "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/ledger" "github.com/algorand/go-algorand/ledger/ledgercore" - "github.com/algorand/go-algorand/protocol" "github.com/jackc/pgconn" "github.com/jackc/pgerrcode" @@ -179,75 +178,60 @@ func (db *IndexerDb) init(opts idb.IndexerDbOptions) (chan struct{}, error) { return db.runAvailableMigrations() } -// Returns all addresses referenced in `block`. -func getBlockAddresses(block *bookkeeping.Block) map[basics.Address]struct{} { - // Reserve a reasonable memory size for the map. - res := make(map[basics.Address]struct{}, len(block.Payset)+2) +// Preload asset and app creators. +func prepareCreators(l *ledger_for_evaluator.LedgerForEvaluator, payset transactions.Payset) (map[basics.AssetIndex]ledger.FoundAddress, map[basics.AppIndex]ledger.FoundAddress, error) { + assetsReq, appsReq := accounting.MakePreloadCreatorsRequest(payset) - res[block.FeeSink] = struct{}{} - res[block.RewardsPool] = struct{}{} - for _, stib := range block.Payset { - addFunc := func(address basics.Address) { - res[address] = struct{}{} - } - accounting.GetTransactionParticipants(&stib.SignedTxnWithAD, true, addFunc) + assets, err := l.GetAssetCreator(assetsReq) + if err != nil { + return nil, nil, fmt.Errorf("prepareCreators() err: %w", err) + } + apps, err := l.GetAppCreator(appsReq) + if err != nil { + return nil, nil, fmt.Errorf("prepareCreators() err: %w", err) } - return res + return assets, apps, nil } -func prepareEvalResources(l *ledger_for_evaluator.LedgerForEvaluator, block *bookkeeping.Block) (ledger.EvalForIndexerResources, error) { - addresses := getBlockAddresses(block) - assets := make(map[basics.AssetIndex]struct{}) - apps := make(map[basics.AppIndex]struct{}) +// Preload account data and account resources. +func prepareAccountsResources(l *ledger_for_evaluator.LedgerForEvaluator, payset transactions.Payset, assetCreators map[basics.AssetIndex]ledger.FoundAddress, appCreators map[basics.AppIndex]ledger.FoundAddress) (map[basics.Address]*ledgercore.AccountData, map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource, error) { + addressesReq, resourcesReq := + accounting.MakePreloadAccountsResourcesRequest(payset, assetCreators, appCreators) - for _, stib := range block.Payset { - switch stib.Txn.Type { - case protocol.AssetConfigTx: - if stib.Txn.ConfigAsset != 0 { - assets[stib.Txn.ConfigAsset] = struct{}{} - } - case protocol.AssetTransferTx: - if stib.Txn.XferAsset != 0 { - assets[stib.Txn.XferAsset] = struct{}{} - } - case protocol.AssetFreezeTx: - if stib.Txn.FreezeAsset != 0 { - assets[stib.Txn.FreezeAsset] = struct{}{} - } - case protocol.ApplicationCallTx: - if stib.Txn.ApplicationID != 0 { - apps[stib.Txn.ApplicationID] = struct{}{} - } - } + accounts, err := l.LookupWithoutRewards(addressesReq) + if err != nil { + return nil, nil, fmt.Errorf("prepareAccountsResources() err: %w", err) } - - res := ledger.EvalForIndexerResources{ - Accounts: nil, - Creators: make(map[ledger.Creatable]ledger.FoundAddress), + resources, err := l.LookupResources(resourcesReq) + if err != nil { + return nil, nil, fmt.Errorf("prepareAccountsResources() err: %w", err) } - assetCreators, err := l.GetAssetCreator(assets) + return accounts, resources, nil +} + +// Preload all resources (account data, account resources, asset/app creators) for the +// evaluator. +func prepareEvalResources(l *ledger_for_evaluator.LedgerForEvaluator, block *bookkeeping.Block) (ledger.EvalForIndexerResources, error) { + assetCreators, appCreators, err := prepareCreators(l, block.Payset) if err != nil { return ledger.EvalForIndexerResources{}, fmt.Errorf("prepareEvalResources() err: %w", err) } + + res := ledger.EvalForIndexerResources{ + Accounts: nil, + Resources: nil, + Creators: make(map[ledger.Creatable]ledger.FoundAddress), + } + for index, foundAddress := range assetCreators { creatable := ledger.Creatable{ Index: basics.CreatableIndex(index), Type: basics.AssetCreatable, } res.Creators[creatable] = foundAddress - - if foundAddress.Exists { - addresses[foundAddress.Address] = struct{}{} - } - } - - appCreators, err := l.GetAppCreator(apps) - if err != nil { - return ledger.EvalForIndexerResources{}, - fmt.Errorf("prepareEvalResources() err: %w", err) } for index, foundAddress := range appCreators { creatable := ledger.Creatable{ @@ -255,13 +239,9 @@ func prepareEvalResources(l *ledger_for_evaluator.LedgerForEvaluator, block *boo Type: basics.AppCreatable, } res.Creators[creatable] = foundAddress - - if foundAddress.Exists { - addresses[foundAddress.Address] = struct{}{} - } } - res.Accounts, err = l.LookupWithoutRewards(addresses) + res.Accounts, res.Resources, err = prepareAccountsResources(l, block.Payset, assetCreators, appCreators) if err != nil { return ledger.EvalForIndexerResources{}, fmt.Errorf("prepareEvalResources() err: %w", err) @@ -447,15 +427,16 @@ func (db *IndexerDb) LoadGenesis(genesis bookkeeping.Genesis) error { if len(alloc.State.AssetParams) > 0 || len(alloc.State.Assets) > 0 { return fmt.Errorf("LoadGenesis() genesis account[%d] has unhandled asset", ai) } + accountData := ledgercore.ToAccountData(alloc.State) _, err = tx.Exec( context.Background(), setAccountStatementName, addr[:], alloc.State.MicroAlgos.Raw, - encoding.EncodeTrimmedAccountData(encoding.TrimAccountData(alloc.State)), 0) + encoding.EncodeTrimmedLcAccountData(encoding.TrimLcAccountData(accountData)), 0) if err != nil { return fmt.Errorf("LoadGenesis() error setting genesis account[%d], %w", ai, err) } - totals.AddAccount(proto, alloc.State, &ot) + totals.AddAccount(proto, accountData, &ot) } err = db.setMetastate( @@ -1126,37 +1107,22 @@ func (db *IndexerDb) yieldAccountsThread(req *getAccountsRequest) { var localStateClosedBytes []byte var localStateDeletedBytes []byte - var err error - - if req.opts.IncludeAssetHoldings && req.opts.IncludeAssetParams { - err = req.rows.Scan( - &addr, µalgos, &rewardstotal, &createdat, &closedat, &deleted, &rewardsbase, &keytype, &accountDataJSONStr, - &holdingAssetids, &holdingAmount, &holdingFrozen, &holdingCreatedBytes, &holdingClosedBytes, &holdingDeletedBytes, - &assetParamsIds, &assetParamsStr, &assetParamsCreatedBytes, &assetParamsClosedBytes, &assetParamsDeletedBytes, - &appParamIndexes, &appParams, &appCreatedBytes, &appClosedBytes, &appDeletedBytes, &localStateAppIds, &localStates, - &localStateCreatedBytes, &localStateClosedBytes, &localStateDeletedBytes, - ) - } else if req.opts.IncludeAssetHoldings { - err = req.rows.Scan( - &addr, µalgos, &rewardstotal, &createdat, &closedat, &deleted, &rewardsbase, &keytype, &accountDataJSONStr, - &holdingAssetids, &holdingAmount, &holdingFrozen, &holdingCreatedBytes, &holdingClosedBytes, &holdingDeletedBytes, - &appParamIndexes, &appParams, &appCreatedBytes, &appClosedBytes, &appDeletedBytes, &localStateAppIds, &localStates, - &localStateCreatedBytes, &localStateClosedBytes, &localStateDeletedBytes, - ) - } else if req.opts.IncludeAssetParams { - err = req.rows.Scan( - &addr, µalgos, &rewardstotal, &createdat, &closedat, &deleted, &rewardsbase, &keytype, &accountDataJSONStr, - &assetParamsIds, &assetParamsStr, &assetParamsCreatedBytes, &assetParamsClosedBytes, &assetParamsDeletedBytes, - &appParamIndexes, &appParams, &appCreatedBytes, &appClosedBytes, &appDeletedBytes, &localStateAppIds, &localStates, - &localStateCreatedBytes, &localStateClosedBytes, &localStateDeletedBytes, - ) - } else { - err = req.rows.Scan( - &addr, µalgos, &rewardstotal, &createdat, &closedat, &deleted, &rewardsbase, &keytype, &accountDataJSONStr, - &appParamIndexes, &appParams, &appCreatedBytes, &appClosedBytes, &appDeletedBytes, &localStateAppIds, &localStates, - &localStateCreatedBytes, &localStateClosedBytes, &localStateDeletedBytes, - ) + // build list of columns to scan using include options like buildAccountQuery + cols := []interface{}{&addr, µalgos, &rewardstotal, &createdat, &closedat, &deleted, &rewardsbase, &keytype, &accountDataJSONStr} + if req.opts.IncludeAssetHoldings { + cols = append(cols, &holdingAssetids, &holdingAmount, &holdingFrozen, &holdingCreatedBytes, &holdingClosedBytes, &holdingDeletedBytes) + } + if req.opts.IncludeAssetParams { + cols = append(cols, &assetParamsIds, &assetParamsStr, &assetParamsCreatedBytes, &assetParamsClosedBytes, &assetParamsDeletedBytes) + } + if req.opts.IncludeAppParams { + cols = append(cols, &appParamIndexes, &appParams, &appCreatedBytes, &appClosedBytes, &appDeletedBytes) } + if req.opts.IncludeAppLocalState { + cols = append(cols, &localStateAppIds, &localStates, &localStateCreatedBytes, &localStateClosedBytes, &localStateDeletedBytes) + } + + err := req.rows.Scan(cols...) if err != nil { err = fmt.Errorf("account scan err %v", err) req.out <- idb.AccountRow{Error: err} @@ -1182,8 +1148,8 @@ func (db *IndexerDb) yieldAccountsThread(req *getAccountsRequest) { } { - var ad basics.AccountData - ad, err = encoding.DecodeTrimmedAccountData(accountDataJSONStr) + var ad ledgercore.AccountData + ad, err = encoding.DecodeTrimmedLcAccountData(accountDataJSONStr) if err != nil { err = fmt.Errorf("account decode err (%s) %v", accountDataJSONStr, err) req.out <- idb.AccountRow{Error: err} @@ -1229,6 +1195,11 @@ func (db *IndexerDb) yieldAccountsThread(req *getAccountsRequest) { if ad.TotalExtraAppPages != 0 { account.AppsTotalExtraPages = uint64Ptr(uint64(ad.TotalExtraAppPages)) } + + account.TotalAppsOptedIn = ad.TotalAppLocalStates + account.TotalCreatedApps = ad.TotalAppParams + account.TotalAssetsOptedIn = ad.TotalAssets + account.TotalCreatedAssets = ad.TotalAssetParams } if account.Status == "NotParticipating" { @@ -1324,7 +1295,7 @@ func (db *IndexerDb) yieldAccountsThread(req *getAccountsRequest) { OptedOutAtRound: holdingClosed[i], OptedInAtRound: holdingCreated[i], Deleted: holdingDeleted[i], - } // TODO: set Creator to asset creator addr string + } av = append(av, tah) } account.Assets = new([]models.AssetHolding) @@ -1713,8 +1684,19 @@ func (db *IndexerDb) GetAccounts(ctx context.Context, opts idb.AccountQueryOptio return out, round } + // Enforce max combined # of app & asset resources per account limit, if set + if opts.MaxResources != 0 { + err = db.checkAccountResourceLimit(ctx, tx, opts) + if err != nil { + out <- idb.AccountRow{Error: err} + close(out) + tx.Rollback(ctx) + return out, round + } + } + // Construct query for fetching accounts... - query, whereArgs := db.buildAccountQuery(opts) + query, whereArgs := db.buildAccountQuery(opts, false) req := &getAccountsRequest{ opts: opts, blockheader: blockheader, @@ -1738,9 +1720,114 @@ func (db *IndexerDb) GetAccounts(ctx context.Context, opts idb.AccountQueryOptio return out, round } -func (db *IndexerDb) buildAccountQuery(opts idb.AccountQueryOptions) (query string, whereArgs []interface{}) { +func (db *IndexerDb) checkAccountResourceLimit(ctx context.Context, tx pgx.Tx, opts idb.AccountQueryOptions) error { + // skip check if no resources are requested + if !opts.IncludeAssetHoldings && !opts.IncludeAssetParams && !opts.IncludeAppLocalState && !opts.IncludeAppParams { + return nil + } + + // make a copy of the filters requested + o := opts + var countOnly bool + + if opts.IncludeDeleted { + // if IncludeDeleted is set, need to construct a query (preserving filters) to count deleted values that would be returned from + // asset, app, account_asset, account_app + countOnly = true + } else { + // if IncludeDeleted is not set, query AccountData with no resources (preserving filters), to read ad.TotalX counts inside + o.IncludeAssetHoldings = false + o.IncludeAssetParams = false + o.IncludeAppLocalState = false + o.IncludeAppParams = false + } + + query, whereArgs := db.buildAccountQuery(o, countOnly) + rows, err := tx.Query(ctx, query, whereArgs...) + if err != nil { + return fmt.Errorf("account limit query %#v err %v", query, err) + } + defer rows.Close() + for rows.Next() { + var addr []byte + var microalgos uint64 + var rewardstotal uint64 + var createdat sql.NullInt64 + var closedat sql.NullInt64 + var deleted sql.NullBool + var rewardsbase uint64 + var keytype *string + var accountDataJSONStr []byte + var holdingCount, assetCount, appCount, lsCount uint64 + cols := []interface{}{&addr, µalgos, &rewardstotal, &createdat, &closedat, &deleted, &rewardsbase, &keytype, &accountDataJSONStr} + if countOnly { + if o.IncludeAssetHoldings { + cols = append(cols, &holdingCount) + } + if o.IncludeAssetParams { + cols = append(cols, &assetCount) + } + if o.IncludeAppParams { + cols = append(cols, &appCount) + } + if o.IncludeAppLocalState { + cols = append(cols, &lsCount) + } + } + err := rows.Scan(cols...) + if err != nil { + return fmt.Errorf("account limit scan err %v", err) + } + + var ad ledgercore.AccountData + ad, err = encoding.DecodeTrimmedLcAccountData(accountDataJSONStr) + if err != nil { + return fmt.Errorf("account limit decode err (%s) %v", accountDataJSONStr, err) + } + + // check limit against filters (only count what would be returned) + var resultCount, totalAssets, totalAssetParams, totalAppLocalStates, totalAppParams uint64 + if countOnly { + totalAssets = holdingCount + totalAssetParams = assetCount + totalAppLocalStates = lsCount + totalAppParams = appCount + } else { + totalAssets = ad.TotalAssets + totalAssetParams = ad.TotalAssetParams + totalAppLocalStates = ad.TotalAppLocalStates + totalAppParams = ad.TotalAppParams + } + if opts.IncludeAssetHoldings { + resultCount += totalAssets + } + if opts.IncludeAssetParams { + resultCount += totalAssetParams + } + if opts.IncludeAppLocalState { + resultCount += totalAppLocalStates + } + if opts.IncludeAppParams { + resultCount += totalAppParams + } + if resultCount > opts.MaxResources { + var aaddr basics.Address + copy(aaddr[:], addr) + return idb.MaxAPIResourcesPerAccountError{ + Address: aaddr, + TotalAppLocalStates: totalAppLocalStates, + TotalAppParams: totalAppParams, + TotalAssets: totalAssets, + TotalAssetParams: totalAssetParams, + } + } + } + return nil +} + +func (db *IndexerDb) buildAccountQuery(opts idb.AccountQueryOptions, countOnly bool) (query string, whereArgs []interface{}) { // Construct query for fetching accounts... - const maxWhereParts = 14 + const maxWhereParts = 9 whereParts := make([]string, 0, maxWhereParts) whereArgs = make([]interface{}, 0, maxWhereParts) partNumber := 1 @@ -1790,7 +1877,7 @@ func (db *IndexerDb) buildAccountQuery(opts idb.AccountQueryOptions) (query stri partNumber++ } if !opts.IncludeDeleted { - whereParts = append(whereParts, "coalesce(a.deleted, false) = false") + whereParts = append(whereParts, "NOT a.deleted") } if len(opts.EqualToAuthAddr) > 0 { whereParts = append(whereParts, fmt.Sprintf("a.account_data ->> 'spend' = $%d", partNumber)) @@ -1814,42 +1901,91 @@ func (db *IndexerDb) buildAccountQuery(opts idb.AccountQueryOptions) (query stri if opts.Limit != 0 { query += fmt.Sprintf(" LIMIT %d", opts.Limit) } - // TODO: asset holdings and asset params are optional, but practically always used. Either make them actually always on, or make app-global and app-local clauses also optional (they are currently always on). + withClauses = append(withClauses, "qaccounts AS ("+query+")") query = "WITH " + strings.Join(withClauses, ", ") - if opts.IncludeDeleted { - if opts.IncludeAssetHoldings { - query += `, qaa AS (SELECT xa.addr, json_agg(aa.assetid) as haid, json_agg(aa.amount) as hamt, json_agg(aa.frozen) as hf, json_agg(aa.created_at) as holding_created_at, json_agg(aa.closed_at) as holding_closed_at, json_agg(coalesce(aa.deleted, false)) as holding_deleted FROM account_asset aa JOIN qaccounts xa ON aa.addr = xa.addr GROUP BY 1)` + + // build nested selects for querying app/asset data associated with an address + if opts.IncludeAssetHoldings { + var where, selectCols string + if !opts.IncludeDeleted { + where = ` WHERE NOT aa.deleted` } - if opts.IncludeAssetParams { - query += `, qap AS (SELECT ya.addr, json_agg(ap.index) as paid, json_agg(ap.params) as pp, json_agg(ap.created_at) as asset_created_at, json_agg(ap.closed_at) as asset_closed_at, json_agg(ap.deleted) as asset_deleted FROM asset ap JOIN qaccounts ya ON ap.creator_addr = ya.addr GROUP BY 1)` + if countOnly { + selectCols = `count(*) as holding_count` + } else { + selectCols = `json_agg(aa.assetid) as haid, json_agg(aa.amount) as hamt, json_agg(aa.frozen) as hf, json_agg(aa.created_at) as holding_created_at, json_agg(aa.closed_at) as holding_closed_at, json_agg(aa.deleted) as holding_deleted` } - // app - query += `, qapp AS (SELECT app.creator as addr, json_agg(app.index) as papps, json_agg(app.params) as ppa, json_agg(app.created_at) as app_created_at, json_agg(app.closed_at) as app_closed_at, json_agg(app.deleted) as app_deleted FROM app JOIN qaccounts ON qaccounts.addr = app.creator GROUP BY 1)` - // app localstate - query += `, qls AS (SELECT la.addr, json_agg(la.app) as lsapps, json_agg(la.localstate) as lsls, json_agg(la.created_at) as ls_created_at, json_agg(la.closed_at) as ls_closed_at, json_agg(la.deleted) as ls_deleted FROM account_app la JOIN qaccounts ON qaccounts.addr = la.addr GROUP BY 1)` - } else { - if opts.IncludeAssetHoldings { - query += `, qaa AS (SELECT xa.addr, json_agg(aa.assetid) as haid, json_agg(aa.amount) as hamt, json_agg(aa.frozen) as hf, json_agg(aa.created_at) as holding_created_at, json_agg(aa.closed_at) as holding_closed_at, json_agg(coalesce(aa.deleted, false)) as holding_deleted FROM account_asset aa JOIN qaccounts xa ON aa.addr = xa.addr WHERE coalesce(aa.deleted, false) = false GROUP BY 1)` + query += `, qaa AS (SELECT xa.addr, ` + selectCols + ` FROM account_asset aa JOIN qaccounts xa ON aa.addr = xa.addr` + where + ` GROUP BY 1)` + } + if opts.IncludeAssetParams { + var where, selectCols string + if !opts.IncludeDeleted { + where = ` WHERE NOT ap.deleted` } - if opts.IncludeAssetParams { - query += `, qap AS (SELECT ya.addr, json_agg(ap.index) as paid, json_agg(ap.params) as pp, json_agg(ap.created_at) as asset_created_at, json_agg(ap.closed_at) as asset_closed_at, json_agg(ap.deleted) as asset_deleted FROM asset ap JOIN qaccounts ya ON ap.creator_addr = ya.addr WHERE coalesce(ap.deleted, false) = false GROUP BY 1)` + if countOnly { + selectCols = `count(*) as asset_count` + } else { + selectCols = `json_agg(ap.index) as paid, json_agg(ap.params) as pp, json_agg(ap.created_at) as asset_created_at, json_agg(ap.closed_at) as asset_closed_at, json_agg(ap.deleted) as asset_deleted` + } + query += `, qap AS (SELECT ya.addr, ` + selectCols + ` FROM asset ap JOIN qaccounts ya ON ap.creator_addr = ya.addr` + where + ` GROUP BY 1)` + } + if opts.IncludeAppParams { + var where, selectCols string + if !opts.IncludeDeleted { + where = ` WHERE NOT app.deleted` + } + if countOnly { + selectCols = `count(*) as app_count` + } else { + selectCols = `json_agg(app.index) as papps, json_agg(app.params) as ppa, json_agg(app.created_at) as app_created_at, json_agg(app.closed_at) as app_closed_at, json_agg(app.deleted) as app_deleted` } - // app - query += `, qapp AS (SELECT app.creator as addr, json_agg(app.index) as papps, json_agg(app.params) as ppa, json_agg(app.created_at) as app_created_at, json_agg(app.closed_at) as app_closed_at, json_agg(app.deleted) as app_deleted FROM app JOIN qaccounts ON qaccounts.addr = app.creator WHERE coalesce(app.deleted, false) = false GROUP BY 1)` - // app localstate - query += `, qls AS (SELECT la.addr, json_agg(la.app) as lsapps, json_agg(la.localstate) as lsls, json_agg(la.created_at) as ls_created_at, json_agg(la.closed_at) as ls_closed_at, json_agg(la.deleted) as ls_deleted FROM account_app la JOIN qaccounts ON qaccounts.addr = la.addr WHERE coalesce(la.deleted, false) = false GROUP BY 1)` + query += `, qapp AS (SELECT app.creator as addr, ` + selectCols + ` FROM app JOIN qaccounts ON qaccounts.addr = app.creator` + where + ` GROUP BY 1)` + } + if opts.IncludeAppLocalState { + var where, selectCols string + if !opts.IncludeDeleted { + where = ` WHERE NOT la.deleted` + } + if countOnly { + selectCols = `count(*) as app_count` + } else { + selectCols = `json_agg(la.app) as lsapps, json_agg(la.localstate) as lsls, json_agg(la.created_at) as ls_created_at, json_agg(la.closed_at) as ls_closed_at, json_agg(la.deleted) as ls_deleted` + } + query += `, qls AS (SELECT la.addr, ` + selectCols + ` FROM account_app la JOIN qaccounts ON qaccounts.addr = la.addr` + where + ` GROUP BY 1)` } // query results query += ` SELECT za.addr, za.microalgos, za.rewards_total, za.created_at, za.closed_at, za.deleted, za.rewardsbase, za.keytype, za.account_data` if opts.IncludeAssetHoldings { - query += `, qaa.haid, qaa.hamt, qaa.hf, qaa.holding_created_at, qaa.holding_closed_at, qaa.holding_deleted` + if countOnly { + query += `, qaa.holding_count` + } else { + query += `, qaa.haid, qaa.hamt, qaa.hf, qaa.holding_created_at, qaa.holding_closed_at, qaa.holding_deleted` + } } if opts.IncludeAssetParams { - query += `, qap.paid, qap.pp, qap.asset_created_at, qap.asset_closed_at, qap.asset_deleted` + if countOnly { + query += `, qap.asset_count` + } else { + query += `, qap.paid, qap.pp, qap.asset_created_at, qap.asset_closed_at, qap.asset_deleted` + } + } + if opts.IncludeAppParams { + if countOnly { + query += `, qapp.app_count` + } else { + query += `, qapp.papps, qapp.ppa, qapp.app_created_at, qapp.app_closed_at, qapp.app_deleted` + } + } + if opts.IncludeAppLocalState { + if countOnly { + query += `, qls.ls_count` + } else { + query += `, qls.lsapps, qls.lsls, qls.ls_created_at, qls.ls_closed_at, qls.ls_deleted` + } } - query += `, qapp.papps, qapp.ppa, qapp.app_created_at, qapp.app_closed_at, qapp.app_deleted, qls.lsapps, qls.lsls, qls.ls_created_at, qls.ls_closed_at, qls.ls_deleted FROM qaccounts za` + query += ` FROM qaccounts za` // join everything together if opts.IncludeAssetHoldings { @@ -1858,7 +1994,13 @@ func (db *IndexerDb) buildAccountQuery(opts idb.AccountQueryOptions) (query stri if opts.IncludeAssetParams { query += ` LEFT JOIN qap ON za.addr = qap.addr` } - query += " LEFT JOIN qapp ON za.addr = qapp.addr LEFT JOIN qls ON qls.addr = za.addr ORDER BY za.addr ASC;" + if opts.IncludeAppParams { + query += ` LEFT JOIN qapp ON za.addr = qapp.addr` + } + if opts.IncludeAppLocalState { + query += ` LEFT JOIN qls ON za.addr = qls.addr` + } + query += ` ORDER BY za.addr ASC;` return query, whereArgs } @@ -1901,7 +2043,7 @@ func (db *IndexerDb) Assets(ctx context.Context, filter idb.AssetsQuery) (<-chan partNumber++ } if !filter.IncludeDeleted { - whereParts = append(whereParts, "coalesce(a.deleted, false) = false") + whereParts = append(whereParts, "NOT a.deleted") } if len(whereParts) > 0 { whereStr := strings.Join(whereParts, " AND ") @@ -1993,6 +2135,16 @@ func (db *IndexerDb) AssetBalances(ctx context.Context, abq idb.AssetBalanceQuer whereArgs = append(whereArgs, abq.AssetID) partNumber++ } + if abq.AssetIDGT != 0 { + whereParts = append(whereParts, fmt.Sprintf("aa.assetid > $%d", partNumber)) + whereArgs = append(whereArgs, abq.AssetIDGT) + partNumber++ + } + if abq.Address != nil { + whereParts = append(whereParts, fmt.Sprintf("aa.addr = $%d", partNumber)) + whereArgs = append(whereArgs, abq.Address) + partNumber++ + } if abq.AmountGT != nil { whereParts = append(whereParts, fmt.Sprintf("aa.amount > $%d", partNumber)) whereArgs = append(whereArgs, *abq.AmountGT) @@ -2009,13 +2161,14 @@ func (db *IndexerDb) AssetBalances(ctx context.Context, abq idb.AssetBalanceQuer partNumber++ } if !abq.IncludeDeleted { - whereParts = append(whereParts, "coalesce(aa.deleted, false) = false") + whereParts = append(whereParts, "NOT aa.deleted") } query := `SELECT addr, assetid, amount, frozen, created_at, closed_at, deleted FROM account_asset aa` if len(whereParts) > 0 { query += " WHERE " + strings.Join(whereParts, " AND ") } - query += " ORDER BY addr ASC" + query += " ORDER BY addr, assetid ASC" + if abq.Limit > 0 { query += fmt.Sprintf(" LIMIT %d", abq.Limit) } @@ -2085,40 +2238,40 @@ func (db *IndexerDb) yieldAssetBalanceThread(rows pgx.Rows, out chan<- idb.Asset } // Applications is part of idb.IndexerDB -func (db *IndexerDb) Applications(ctx context.Context, filter *models.SearchForApplicationsParams) (<-chan idb.ApplicationRow, uint64) { +func (db *IndexerDb) Applications(ctx context.Context, filter idb.ApplicationQuery) (<-chan idb.ApplicationRow, uint64) { out := make(chan idb.ApplicationRow, 1) - if filter == nil { - out <- idb.ApplicationRow{Error: fmt.Errorf("no arguments provided to application search")} - close(out) - return out, 0 - } query := `SELECT index, creator, params, created_at, closed_at, deleted FROM app ` - const maxWhereParts = 30 + const maxWhereParts = 4 whereParts := make([]string, 0, maxWhereParts) whereArgs := make([]interface{}, 0, maxWhereParts) partNumber := 1 - if filter.ApplicationId != nil { + if filter.ApplicationID != 0 { whereParts = append(whereParts, fmt.Sprintf("index = $%d", partNumber)) - whereArgs = append(whereArgs, *filter.ApplicationId) + whereArgs = append(whereArgs, filter.ApplicationID) + partNumber++ + } + if filter.Address != nil { + whereParts = append(whereParts, fmt.Sprintf("creator = $%d", partNumber)) + whereArgs = append(whereArgs, filter.Address) partNumber++ } - if filter.Next != nil { + if filter.ApplicationIDGreaterThan != 0 { whereParts = append(whereParts, fmt.Sprintf("index > $%d", partNumber)) - whereArgs = append(whereArgs, *filter.Next) + whereArgs = append(whereArgs, filter.ApplicationIDGreaterThan) partNumber++ } - if filter.IncludeAll == nil || !(*filter.IncludeAll) { - whereParts = append(whereParts, "coalesce(deleted, false) = false") + if !filter.IncludeDeleted { + whereParts = append(whereParts, "NOT deleted") } if len(whereParts) > 0 { whereStr := strings.Join(whereParts, " AND ") query += " WHERE " + whereStr } query += " ORDER BY 1" - if filter.Limit != nil { - query += fmt.Sprintf(" LIMIT %d", *filter.Limit) + if filter.Limit != 0 { + query += fmt.Sprintf(" LIMIT %d", filter.Limit) } tx, err := db.db.BeginTx(ctx, readonlyRepeatableRead) @@ -2174,7 +2327,7 @@ func (db *IndexerDb) yieldApplicationsThread(rows pgx.Rows, out chan idb.Applica rec.Application.Deleted = deleted ap, err := encoding.DecodeAppParams(paramsjson) if err != nil { - rec.Error = fmt.Errorf("app=%d json err, %v", index, err) + rec.Error = fmt.Errorf("app=%d json err: %w", index, err) out <- rec break } @@ -2208,6 +2361,113 @@ func (db *IndexerDb) yieldApplicationsThread(rows pgx.Rows, out chan idb.Applica } } +// AppLocalState is part of idb.IndexerDB +func (db *IndexerDb) AppLocalState(ctx context.Context, filter idb.ApplicationQuery) (<-chan idb.AppLocalStateRow, uint64) { + out := make(chan idb.AppLocalStateRow, 1) + + query := `SELECT app, addr, localstate, created_at, closed_at, deleted FROM account_app ` + + const maxWhereParts = 4 + whereParts := make([]string, 0, maxWhereParts) + whereArgs := make([]interface{}, 0, maxWhereParts) + partNumber := 1 + if filter.ApplicationID != 0 { + whereParts = append(whereParts, fmt.Sprintf("app = $%d", partNumber)) + whereArgs = append(whereArgs, filter.ApplicationID) + partNumber++ + } + if filter.Address != nil { + whereParts = append(whereParts, fmt.Sprintf("addr = $%d", partNumber)) + whereArgs = append(whereArgs, filter.Address) + partNumber++ + } + if filter.ApplicationIDGreaterThan != 0 { + whereParts = append(whereParts, fmt.Sprintf("app > $%d", partNumber)) + whereArgs = append(whereArgs, filter.ApplicationIDGreaterThan) + partNumber++ + } + if !filter.IncludeDeleted { + whereParts = append(whereParts, "NOT deleted") + } + if len(whereParts) > 0 { + whereStr := strings.Join(whereParts, " AND ") + query += " WHERE " + whereStr + } + query += " ORDER BY 1" + if filter.Limit != 0 { + query += fmt.Sprintf(" LIMIT %d", filter.Limit) + } + + tx, err := db.db.BeginTx(ctx, readonlyRepeatableRead) + if err != nil { + out <- idb.AppLocalStateRow{Error: err} + close(out) + return out, 0 + } + + round, err := db.getMaxRoundAccounted(ctx, tx) + if err != nil { + out <- idb.AppLocalStateRow{Error: err} + close(out) + tx.Rollback(ctx) + return out, round + } + + rows, err := tx.Query(ctx, query, whereArgs...) + if err != nil { + out <- idb.AppLocalStateRow{Error: err} + close(out) + tx.Rollback(ctx) + return out, round + } + + go func() { + db.yieldAppLocalStateThread(rows, out) + close(out) + tx.Rollback(ctx) + }() + return out, round +} + +func (db *IndexerDb) yieldAppLocalStateThread(rows pgx.Rows, out chan idb.AppLocalStateRow) { + defer rows.Close() + + for rows.Next() { + var index uint64 + var address []byte + var statejson []byte + var created *uint64 + var closed *uint64 + var deleted *bool + err := rows.Scan(&index, &address, &statejson, &created, &closed, &deleted) + if err != nil { + out <- idb.AppLocalStateRow{Error: err} + break + } + var rec idb.AppLocalStateRow + rec.AppLocalState.Id = index + rec.AppLocalState.OptedInAtRound = created + rec.AppLocalState.ClosedOutAtRound = closed + rec.AppLocalState.Deleted = deleted + + ls, err := encoding.DecodeAppLocalState(statejson) + if err != nil { + rec.Error = fmt.Errorf("app=%d json err: %w", index, err) + out <- rec + break + } + rec.AppLocalState.Schema = models.ApplicationStateSchema{ + NumByteSlice: ls.Schema.NumByteSlice, + NumUint: ls.Schema.NumUint, + } + rec.AppLocalState.KeyValue = tealKeyValueToModel(ls.KeyValue) + out <- rec + } + if err := rows.Err(); err != nil { + out <- idb.AppLocalStateRow{Error: err} + } +} + // Health is part of idb.IndexerDB func (db *IndexerDb) Health(ctx context.Context) (idb.Health, error) { migrationRequired := false @@ -2279,41 +2539,32 @@ func (db *IndexerDb) GetSpecialAccounts(ctx context.Context) (transactions.Speci return accounts, nil } -// GetAccountData returns account data for the given addresses. For accounts that are -// not found, empty AccountData is returned. This function is only used for debugging. -func (db *IndexerDb) GetAccountData(addresses []basics.Address) (map[basics.Address]basics.AccountData, error) { +// GetAccountState returns account data and account resources for the given input. +// For accounts that are not found, empty AccountData is returned. +// This function is only used for debugging. +func (db *IndexerDb) GetAccountState(addressesReq map[basics.Address]struct{}, resourcesReq map[basics.Address]map[ledger.Creatable]struct{}) (map[basics.Address]*ledgercore.AccountData, map[basics.Address]map[ledger.Creatable]ledgercore.AccountResource, error) { tx, err := db.db.BeginTx(context.Background(), readonlyRepeatableRead) if err != nil { - return nil, fmt.Errorf("GetAccountData() begin tx err: %w", err) + return nil, nil, fmt.Errorf("GetAccountState() begin tx err: %w", err) } defer tx.Rollback(context.Background()) l, err := ledger_for_evaluator.MakeLedgerForEvaluator(tx, basics.Round(0)) if err != nil { - return nil, fmt.Errorf("GetAccountData() err: %w", err) + return nil, nil, fmt.Errorf("GetAccountState() err: %w", err) } defer l.Close() - addressesMap := make(map[basics.Address]struct{}, len(addresses)) - for _, address := range addresses { - addressesMap[address] = struct{}{} - } - - accountDataMap, err := l.LookupWithoutRewards(addressesMap) + accounts, err := l.LookupWithoutRewards(addressesReq) if err != nil { - return nil, fmt.Errorf("GetAccountData() err: %w", err) + return nil, nil, fmt.Errorf("GetAccountState() err: %w", err) } - - res := make(map[basics.Address]basics.AccountData, len(accountDataMap)) - for address, accountData := range accountDataMap { - if accountData == nil { - res[address] = basics.AccountData{} - } else { - res[address] = *accountData - } + resources, err := l.LookupResources(resourcesReq) + if err != nil { + return nil, nil, fmt.Errorf("GetAccountState() err: %w", err) } - return res, nil + return accounts, resources, nil } // GetNetworkState is part of idb.IndexerDB diff --git a/idb/postgres/postgres_integration_test.go b/idb/postgres/postgres_integration_test.go index 5fbf67877..deff7e28a 100644 --- a/idb/postgres/postgres_integration_test.go +++ b/idb/postgres/postgres_integration_test.go @@ -407,7 +407,7 @@ func TestRekeyBasic(t *testing.T) { err = row.Scan(&accountDataStr) assert.NoError(t, err, "querying account data") - ad, err := encoding.DecodeTrimmedAccountData(accountDataStr) + ad, err := encoding.DecodeTrimmedLcAccountData(accountDataStr) require.NoError(t, err, "failed to parse account data json") assert.Equal(t, test.AccountB, ad.AuthAddr) } @@ -444,7 +444,7 @@ func TestRekeyToItself(t *testing.T) { err = row.Scan(&accountDataStr) assert.NoError(t, err, "querying account data") - ad, err := encoding.DecodeTrimmedAccountData(accountDataStr) + ad, err := encoding.DecodeTrimmedLcAccountData(accountDataStr) require.NoError(t, err, "failed to parse account data json") assert.Equal(t, basics.Address{}, ad.AuthAddr) } @@ -479,7 +479,7 @@ func TestRekeyThreeTimesInSameRound(t *testing.T) { err = row.Scan(&accountDataStr) assert.NoError(t, err, "querying account data") - ad, err := encoding.DecodeTrimmedAccountData(accountDataStr) + ad, err := encoding.DecodeTrimmedLcAccountData(accountDataStr) require.NoError(t, err, "failed to parse account data json") assert.Equal(t, test.AccountC, ad.AuthAddr) } @@ -860,10 +860,10 @@ func TestAppExtraPages(t *testing.T) { require.NoError(t, err) require.Equal(t, uint32(1), ap.ExtraProgramPages) - var filter generated.SearchForApplicationsParams + var filter idb.ApplicationQuery var aidx uint64 = uint64(index) - filter.ApplicationId = &aidx - appRows, _ := db.Applications(context.Background(), &filter) + filter.ApplicationID = aidx + appRows, _ := db.Applications(context.Background(), filter) num := 0 for row := range appRows { require.NoError(t, row.Error) @@ -873,7 +873,7 @@ func TestAppExtraPages(t *testing.T) { } require.Equal(t, 1, num) - rows, _ := db.GetAccounts(context.Background(), idb.AccountQueryOptions{EqualToAddress: test.AccountA[:]}) + rows, _ := db.GetAccounts(context.Background(), idb.AccountQueryOptions{EqualToAddress: test.AccountA[:], IncludeAppParams: true}) num = 0 var createdApps *[]generated.Application for row := range rows { @@ -881,6 +881,7 @@ func TestAppExtraPages(t *testing.T) { num++ require.NotNil(t, row.Account.AppsTotalExtraPages, "we should have this field") require.Equal(t, uint64(1), *row.Account.AppsTotalExtraPages) + require.Equal(t, uint64(1), row.Account.TotalCreatedApps) createdApps = row.Account.CreatedApps } require.Equal(t, 1, num) @@ -978,6 +979,7 @@ func TestLargeAssetAmount(t *testing.T) { require.NoError(t, row.Error) require.NotNil(t, row.Account.Assets) require.Equal(t, 1, len(*row.Account.Assets)) + require.Equal(t, uint64(1), row.Account.TotalAssetsOptedIn) assert.Equal(t, uint64(math.MaxUint64), (*row.Account.Assets)[0].Amount) } } @@ -1143,6 +1145,7 @@ func TestNonDisplayableUTF8(t *testing.T) { require.NoError(t, acct.Error) require.NotNil(t, acct.Account.CreatedAssets) require.Len(t, *acct.Account.CreatedAssets, 1) + require.Equal(t, uint64(1), acct.Account.TotalCreatedAssets) asset := (*acct.Account.CreatedAssets)[0] if testcase.ExpectedAssetName == "" { @@ -1502,12 +1505,11 @@ func TestAddBlockCreateDeleteAppSameRound(t *testing.T) { err = db.AddBlock(&block) require.NoError(t, err) - yes := true - opts := generated.SearchForApplicationsParams{ - ApplicationId: &appid, - IncludeAll: &yes, + opts := idb.ApplicationQuery{ + ApplicationID: appid, + IncludeDeleted: true, } - rowsCh, _ := db.Applications(context.Background(), &opts) + rowsCh, _ := db.Applications(context.Background(), opts) row, ok := <-rowsCh require.True(t, ok) @@ -1538,8 +1540,9 @@ func TestAddBlockAppOptInOutSameRound(t *testing.T) { require.NoError(t, err) opts := idb.AccountQueryOptions{ - EqualToAddress: test.AccountB[:], - IncludeDeleted: true, + EqualToAddress: test.AccountB[:], + IncludeDeleted: true, + IncludeAppLocalState: true, } rowsCh, _ := db.GetAccounts(context.Background(), opts) @@ -1557,6 +1560,24 @@ func TestAddBlockAppOptInOutSameRound(t *testing.T) { assert.Equal(t, uint64(1), *localState.OptedInAtRound) require.NotNil(t, localState.ClosedOutAtRound) assert.Equal(t, uint64(1), *localState.ClosedOutAtRound) + require.Equal(t, uint64(0), row.Account.TotalAppsOptedIn) + + q := idb.ApplicationQuery{ + ApplicationID: appid, + IncludeDeleted: true, + } + lsRows, _ := db.AppLocalState(context.Background(), q) + lsRow, ok := <-lsRows + require.True(t, ok) + require.NoError(t, lsRow.Error) + ls := lsRow.AppLocalState + require.Equal(t, appid, ls.Id) + require.NotNil(t, ls.Deleted) + assert.True(t, *ls.Deleted) + require.NotNil(t, ls.OptedInAtRound) + assert.Equal(t, uint64(1), *ls.OptedInAtRound) + require.NotNil(t, ls.ClosedOutAtRound) + assert.Equal(t, uint64(1), *ls.ClosedOutAtRound) } // TestSearchForInnerTransactionReturnsRootTransaction checks that the parent diff --git a/idb/postgres/postgres_migrations.go b/idb/postgres/postgres_migrations.go index 2618f1698..53b162e3d 100644 --- a/idb/postgres/postgres_migrations.go +++ b/idb/postgres/postgres_migrations.go @@ -14,6 +14,7 @@ import ( "github.com/algorand/indexer/idb" "github.com/algorand/indexer/idb/migration" "github.com/algorand/indexer/idb/postgres/internal/encoding" + cad "github.com/algorand/indexer/idb/postgres/internal/migrations/convert_account_data" "github.com/algorand/indexer/idb/postgres/internal/schema" "github.com/algorand/indexer/idb/postgres/internal/types" ) @@ -48,6 +49,7 @@ func init() { {upgradeNotSupported, true, "change import state format"}, {upgradeNotSupported, true, "notify the user that upgrade is not supported"}, {dropTxnBytesColumn, true, "drop txnbytes column"}, + {convertAccountData, true, "convert account.account_data column"}, } } @@ -86,20 +88,6 @@ func needsMigration(state types.MigrationState) bool { return state.NextMigration < len(migrations) } -// upsertMigrationState updates the migration state, and optionally increments -// the next counter with an existing transaction. -// If `tx` is nil, use a normal query. -//lint:ignore U1000 this function might be used in a future migration -func upsertMigrationState(db *IndexerDb, tx pgx.Tx, state *types.MigrationState) error { - migrationStateJSON := encoding.EncodeMigrationState(state) - err := db.setMetastate(tx, schema.MigrationMetastateKey, string(migrationStateJSON)) - if err != nil { - return fmt.Errorf("upsertMigrationState() err: %w", err) - } - - return nil -} - // Returns an error object and a channel that gets closed when blocking migrations // finish running successfully. func (db *IndexerDb) runAvailableMigrations() (chan struct{}, error) { @@ -171,6 +159,17 @@ func (db *IndexerDb) getMigrationState(ctx context.Context, tx pgx.Tx) (types.Mi return state, nil } +// If `tx` is nil, use a normal query. +func (db *IndexerDb) setMigrationState(tx pgx.Tx, state *types.MigrationState) error { + err := db.setMetastate( + tx, schema.MigrationMetastateKey, string(encoding.EncodeMigrationState(state))) + if err != nil { + return fmt.Errorf("setMigrationState() err: %w", err) + } + + return nil +} + // sqlMigration executes a sql statements as the entire migration. //lint:ignore U1000 this function might be used in a future migration func sqlMigration(db *IndexerDb, state *types.MigrationState, sqlLines []string) error { @@ -224,3 +223,29 @@ func dropTxnBytesColumn(db *IndexerDb, migrationState *types.MigrationState) err return sqlMigration( db, migrationState, []string{"ALTER TABLE txn DROP COLUMN txnbytes"}) } + +func convertAccountData(db *IndexerDb, migrationState *types.MigrationState) error { + newMigrationState := *migrationState + newMigrationState.NextMigration++ + + f := func(tx pgx.Tx) error { + err := cad.RunMigration(tx, 10000) + if err != nil { + return fmt.Errorf("convertAccountData() err: %w", err) + } + + err = db.setMigrationState(tx, &newMigrationState) + if err != nil { + return fmt.Errorf("convertAccountData() err: %w", err) + } + + return nil + } + err := db.txWithRetry(serializable, f) + if err != nil { + return fmt.Errorf("convertAccountData() err: %w", err) + } + + *migrationState = newMigrationState + return nil +} diff --git a/idb/postgres/postgres_migrations_test.go b/idb/postgres/postgres_migrations_test.go new file mode 100644 index 000000000..3a0a98e79 --- /dev/null +++ b/idb/postgres/postgres_migrations_test.go @@ -0,0 +1,34 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pgtest "github.com/algorand/indexer/idb/postgres/internal/testing" + "github.com/algorand/indexer/idb/postgres/internal/types" +) + +func TestConvertAccountDataIncrementsMigrationNumber(t *testing.T) { + pdb, _, shutdownFunc := pgtest.SetupPostgresWithSchema(t) + defer shutdownFunc() + + db := IndexerDb{db: pdb} + defer db.Close() + + migrationState := types.MigrationState{ + NextMigration: 5, + } + err := db.setMigrationState(nil, &migrationState) + require.NoError(t, err) + + err = convertAccountData(&db, &migrationState) + require.NoError(t, err) + + migrationState, err = db.getMigrationState(context.Background(), nil) + require.NoError(t, err) + + assert.Equal(t, types.MigrationState{NextMigration: 6}, migrationState) +} diff --git a/idb/postgres/postgres_rand_test.go b/idb/postgres/postgres_rand_test.go index 078a5b631..e4fd44be8 100644 --- a/idb/postgres/postgres_rand_test.go +++ b/idb/postgres/postgres_rand_test.go @@ -8,26 +8,144 @@ import ( "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/ledger" "github.com/algorand/go-algorand/ledger/ledgercore" ledgerforevaluator "github.com/algorand/indexer/idb/postgres/internal/ledger_for_evaluator" "github.com/algorand/indexer/idb/postgres/internal/writer" "github.com/algorand/indexer/util/test" "github.com/jackc/pgx/v4" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func generateAddress(t *testing.T) basics.Address { + var res basics.Address + + _, err := rand.Read(res[:]) + require.NoError(t, err) + + return res +} + +func generateAccountData() ledgercore.AccountData { + // Return empty account data with probability 50%. + if rand.Uint32()%2 == 0 { + return ledgercore.AccountData{} + } + + const numCreatables = 20 + + res := ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: uint64(rand.Int63())}, + }, + } + + return res +} + +// Write random account data for many random accounts, then read it and compare. +// Tests in particular that batch writing and reading is done in the same order +// and that there are no problems around passing account address pointers to the postgres +// driver which could be the same pointer if we are not careful. +func TestWriteReadAccountData(t *testing.T) { + db, shutdownFunc := setupIdb(t, test.MakeGenesis(), test.MakeGenesisBlock()) + defer shutdownFunc() + + addresses := make(map[basics.Address]struct{}) + var delta ledgercore.StateDelta + for i := 0; i < 1000; i++ { + address := generateAddress(t) + + addresses[address] = struct{}{} + delta.Accts.Upsert(address, generateAccountData()) + } + + f := func(tx pgx.Tx) error { + w, err := writer.MakeWriter(tx) + require.NoError(t, err) + + err = w.AddBlock(&bookkeeping.Block{}, transactions.Payset{}, delta) + require.NoError(t, err) + + w.Close() + return nil + } + err := db.txWithRetry(serializable, f) + require.NoError(t, err) + + tx, err := db.db.BeginTx(context.Background(), serializable) + require.NoError(t, err) + defer tx.Rollback(context.Background()) + + l, err := ledgerforevaluator.MakeLedgerForEvaluator(tx, basics.Round(0)) + require.NoError(t, err) + defer l.Close() + + ret, err := l.LookupWithoutRewards(addresses) + require.NoError(t, err) + + for address := range addresses { + expected, ok := delta.Accts.GetData(address) + require.True(t, ok) + + ret, ok := ret[address] + require.True(t, ok) + + if ret == nil { + require.True(t, expected.IsZero()) + } else { + require.Equal(t, &expected, ret) + } + } +} + func generateAssetParams() basics.AssetParams { return basics.AssetParams{ Total: rand.Uint64(), } } +func generateAssetParamsDelta() ledgercore.AssetParamsDelta { + var res ledgercore.AssetParamsDelta + + r := rand.Uint32() % 3 + switch r { + case 0: + res.Deleted = true + case 1: + res.Params = new(basics.AssetParams) + *res.Params = generateAssetParams() + case 2: + // do nothing + } + + return res +} + func generateAssetHolding() basics.AssetHolding { return basics.AssetHolding{ Amount: rand.Uint64(), } } +func generateAssetHoldingDelta() ledgercore.AssetHoldingDelta { + var res ledgercore.AssetHoldingDelta + + r := rand.Uint32() % 3 + switch r { + case 0: + res.Deleted = true + case 1: + res.Holding = new(basics.AssetHolding) + *res.Holding = generateAssetHolding() + case 2: + // do nothing + } + + return res +} + func generateAppParams(t *testing.T) basics.AppParams { p := make([]byte, 100) _, err := rand.Read(p) @@ -38,6 +156,23 @@ func generateAppParams(t *testing.T) basics.AppParams { } } +func generateAppParamsDelta(t *testing.T) ledgercore.AppParamsDelta { + var res ledgercore.AppParamsDelta + + r := rand.Uint32() % 3 + switch r { + case 0: + res.Deleted = true + case 1: + res.Params = new(basics.AppParams) + *res.Params = generateAppParams(t) + case 2: + // do nothing + } + + return res +} + func generateAppLocalState(t *testing.T) basics.AppLocalState { k := make([]byte, 100) _, err := rand.Read(k) @@ -56,61 +191,61 @@ func generateAppLocalState(t *testing.T) basics.AppLocalState { } } -func generateAccountData(t *testing.T) basics.AccountData { - // Return empty account data with probability 50%. - if rand.Uint32()%2 == 0 { - return basics.AccountData{} - } - - const numCreatables = 20 - - res := basics.AccountData{ - MicroAlgos: basics.MicroAlgos{Raw: uint64(rand.Int63())}, - AssetParams: make(map[basics.AssetIndex]basics.AssetParams), - Assets: make(map[basics.AssetIndex]basics.AssetHolding), - AppLocalStates: make(map[basics.AppIndex]basics.AppLocalState), - AppParams: make(map[basics.AppIndex]basics.AppParams), - } +func generateAppLocalStateDelta(t *testing.T) ledgercore.AppLocalStateDelta { + var res ledgercore.AppLocalStateDelta - for i := 0; i < numCreatables; i++ { - { - index := basics.AssetIndex(rand.Int63()) - res.AssetParams[index] = generateAssetParams() - } - { - index := basics.AssetIndex(rand.Int63()) - res.Assets[index] = generateAssetHolding() - } - { - index := basics.AppIndex(rand.Int63()) - res.AppLocalStates[index] = generateAppLocalState(t) - } - { - index := basics.AppIndex(rand.Int63()) - res.AppParams[index] = generateAppParams(t) - } + r := rand.Uint32() % 3 + switch r { + case 0: + res.Deleted = true + case 1: + res.LocalState = new(basics.AppLocalState) + *res.LocalState = generateAppLocalState(t) + case 2: + // do nothing } return res } -// Write random account data for many random accounts, then read it and compare. +// Write random assets and apps, then read it and compare. // Tests in particular that batch writing and reading is done in the same order // and that there are no problems around passing account address pointers to the postgres // driver which could be the same pointer if we are not careful. -func TestWriteReadAccountData(t *testing.T) { +func TestWriteReadResources(t *testing.T) { db, shutdownFunc := setupIdb(t, test.MakeGenesis(), test.MakeGenesisBlock()) defer shutdownFunc() - addresses := make(map[basics.Address]struct{}) + resources := make(map[basics.Address]map[ledger.Creatable]struct{}) var delta ledgercore.StateDelta for i := 0; i < 1000; i++ { - var address basics.Address - _, err := rand.Read(address[:]) - require.NoError(t, err) + address := generateAddress(t) + assetIndex := basics.AssetIndex(rand.Int63()) + appIndex := basics.AppIndex(rand.Int63()) - addresses[address] = struct{}{} - delta.Accts.Upsert(address, generateAccountData(t)) + { + c := make(map[ledger.Creatable]struct{}) + resources[address] = c + + creatable := ledger.Creatable{ + Index: basics.CreatableIndex(assetIndex), + Type: basics.AssetCreatable, + } + c[creatable] = struct{}{} + + creatable = ledger.Creatable{ + Index: basics.CreatableIndex(appIndex), + Type: basics.AppCreatable, + } + c[creatable] = struct{}{} + } + + delta.Accts.UpsertAssetResource( + address, assetIndex, generateAssetParamsDelta(), + generateAssetHoldingDelta()) + delta.Accts.UpsertAppResource( + address, appIndex, generateAppParamsDelta(t), + generateAppLocalStateDelta(t)) } f := func(tx pgx.Tx) error { @@ -134,20 +269,35 @@ func TestWriteReadAccountData(t *testing.T) { require.NoError(t, err) defer l.Close() - ret, err := l.LookupWithoutRewards(addresses) + ret, err := l.LookupResources(resources) require.NoError(t, err) - for address := range addresses { - expected, ok := delta.Accts.Get(address) - require.True(t, ok) - + for address, creatables := range resources { ret, ok := ret[address] require.True(t, ok) - if ret == nil { - require.True(t, expected.IsZero()) - } else { - require.Equal(t, &expected, ret) + for creatable := range creatables { + ret, ok := ret[creatable] + require.True(t, ok) + + switch creatable.Type { + case basics.AssetCreatable: + assetParamsDelta, _ := + delta.Accts.GetAssetParams(address, basics.AssetIndex(creatable.Index)) + assert.Equal(t, assetParamsDelta.Params, ret.AssetParams) + + assetHoldingDelta, _ := + delta.Accts.GetAssetHolding(address, basics.AssetIndex(creatable.Index)) + assert.Equal(t, assetHoldingDelta.Holding, ret.AssetHolding) + case basics.AppCreatable: + appParamsDelta, _ := + delta.Accts.GetAppParams(address, basics.AppIndex(creatable.Index)) + assert.Equal(t, appParamsDelta.Params, ret.AppParams) + + appLocalStateDelta, _ := + delta.Accts.GetAppLocalState(address, basics.AppIndex(creatable.Index)) + assert.Equal(t, appLocalStateDelta.LocalState, ret.AppLocalState) + } } } } diff --git a/misc/Dockerfile b/misc/Dockerfile index bdff1f0c7..fff8a9985 100644 --- a/misc/Dockerfile +++ b/misc/Dockerfile @@ -6,7 +6,7 @@ RUN echo "Go image: $GO_IMAGE" # Misc dependencies ENV HOME /opt ENV DEBIAN_FRONTEND noninteractive -RUN apt-get update && apt-get install -y apt-utils curl git git-core bsdmainutils python3 python3-pip make bash libtool libboost-all-dev libffi-dev +RUN apt-get update && apt-get install -y apt-utils curl git git-core bsdmainutils python3 python3-pip make bash libtool libboost-math-dev libffi-dev # Install algod nightly binaries to the path RUN mkdir -p /opt/algorand/{bin,data} diff --git a/misc/parity/reports/algod2indexer_dropped.yml b/misc/parity/reports/algod2indexer_dropped.yml index aa395e102..3186ec820 100644 --- a/misc/parity/reports/algod2indexer_dropped.yml +++ b/misc/parity/reports/algod2indexer_dropped.yml @@ -26,6 +26,9 @@ DryrunTxnResult: ErrorResponse: - INDEXER: null - ALGOD: '{"description":"An error respo...' +AccountsErrorResponse: +- INDEXER: null +- ALGOD: '{"description":"An error respo...' ParticipationKey: - INDEXER: null - ALGOD: '{"description":"Represents a p...' diff --git a/misc/parity/reports/algod2indexer_full.yml b/misc/parity/reports/algod2indexer_full.yml index 8f7f3e03a..65b27c8b4 100644 --- a/misc/parity/reports/algod2indexer_full.yml +++ b/misc/parity/reports/algod2indexer_full.yml @@ -100,6 +100,9 @@ DryrunTxnResult: ErrorResponse: - INDEXER: null - ALGOD: '{"description":"An error respo...' +AccountsErrorResponse: +- INDEXER: null +- ALGOD: '{"description":"An error respo...' HealthCheck: - INDEXER: '{"description":"A health check...' - ALGOD: null diff --git a/test/common.sh b/test/common.sh index d72f09423..0e1a7c31a 100755 --- a/test/common.sh +++ b/test/common.sh @@ -492,7 +492,6 @@ function create_delete_tests() { '{ "amount": 0, "asset-id": 135, - "creator": "", "deleted": true, "is-frozen": false, "opted-in-at-round": 25, diff --git a/third_party/go-algorand b/third_party/go-algorand index b56953f44..09b6c38e1 160000 --- a/third_party/go-algorand +++ b/third_party/go-algorand @@ -1 +1 @@ -Subproject commit b56953f449670398ee3ff850ed847b6e17d4e6af +Subproject commit 09b6c38e12622e2bac3ed7f2013873e716f80003 From d83723113ea0ee555076b96bdf97675741646615 Mon Sep 17 00:00:00 2001 From: DevOps Service Date: Thu, 10 Mar 2022 19:50:20 +0000 Subject: [PATCH 25/29] Bump version to 2.10.0-rc1 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index c8e38b614..77cb44310 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.9.0 +2.10.0-rc1 From 4966aa3e4f86d496457686f6120d508c483ce733 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Tue, 15 Mar 2022 10:53:55 -0400 Subject: [PATCH 26/29] Fix validator threads variable. (#927) --- cmd/validator/core/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/validator/core/command.go b/cmd/validator/core/command.go index a5180f674..aa3531f05 100644 --- a/cmd/validator/core/command.go +++ b/cmd/validator/core/command.go @@ -42,7 +42,7 @@ func init() { ValidatorCmd.Flags().IntVarP(&config.RetryDelayMS, "retry-delay", "", 1000, "Time in milliseconds to sleep between retries.") ValidatorCmd.Flags().StringVar(&addr, "addr", "", "If provided validate a single address instead of reading Stdin.") ValidatorCmd.Flags().IntVar(&threads, "threads", 4, "Number of worker threads to initialize.") - ValidatorCmd.Flags().IntVar(&threads, "processor", 0, "Choose compare algorithm [0 = Struct, 1 = Reflection]") + ValidatorCmd.Flags().IntVar(&processorNum, "processor", 0, "Choose compare algorithm [0 = Struct, 1 = Reflection]") ValidatorCmd.Flags().BoolVar(&printCurl, "print-commands", false, "Print curl commands, including tokens, to query algod and indexer.") } From 44a75e814ba6633e0698a51553104f08b0769ae2 Mon Sep 17 00:00:00 2001 From: AlgoStephenAkiki <85183435+AlgoStephenAkiki@users.noreply.github.com> Date: Wed, 16 Mar 2022 11:03:24 -0400 Subject: [PATCH 27/29] Update indirect go module. (#932) --- go.mod | 2 +- go.sum | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index fd43de5d5..590488324 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,6 @@ require ( github.com/stretchr/testify v1.7.0 github.com/vektra/mockery v1.1.2 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect - golang.org/x/tools v0.1.9 // indirect + golang.org/x/tools v0.1.10 // indirect gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 ) diff --git a/go.sum b/go.sum index 1d3384e6d..5a73497b0 100644 --- a/go.sum +++ b/go.sum @@ -812,6 +812,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1013,6 +1015,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 21e8a98fd0e9204273ffeb0e53272a987765caf5 Mon Sep 17 00:00:00 2001 From: DevOps Service Date: Thu, 17 Mar 2022 21:43:31 +0000 Subject: [PATCH 28/29] Bump version to 2.10.0 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 77cb44310..10c2c0c3d 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.10.0-rc1 +2.10.0 From 46a10993bd5028e1564b8ae87e528e050782a1fa Mon Sep 17 00:00:00 2001 From: Will Winder Date: Fri, 18 Mar 2022 18:18:00 -0400 Subject: [PATCH 29/29] Set max account API resource limit to 1000. (#933) Co-authored-by: chris erway <51567+cce@users.noreply.github.com> --- api/handlers_e2e_test.go | 23 ++++++++++++++++++----- cmd/algorand-indexer/daemon.go | 2 +- idb/postgres/postgres.go | 12 ++++++------ test/README.md | 2 +- test/common.sh | 4 ++-- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/api/handlers_e2e_test.go b/api/handlers_e2e_test.go index 9d675dc06..596eabf65 100644 --- a/api/handlers_e2e_test.go +++ b/api/handlers_e2e_test.go @@ -32,6 +32,8 @@ import ( ) var defaultOpts = ExtraOptions{ + MaxAPIResourcesPerAccount: 1000, + MaxTransactionsLimit: 10000, DefaultTransactionsLimit: 1000, @@ -260,10 +262,11 @@ func TestAccountExcludeParameters(t *testing.T) { ////////// testCases := []struct { - address basics.Address - exclude []string - check func(*testing.T, generated.AccountResponse) - errStatus int + address basics.Address + exclude []string + check func(*testing.T, generated.AccountResponse) + errStatus int + includeDeleted bool }{{ address: test.AccountA, exclude: []string{"all"}, @@ -281,6 +284,15 @@ func TestAccountExcludeParameters(t *testing.T) { require.NotNil(t, r.Account.Assets) require.NotNil(t, r.Account.AppsLocalState) }}, { + address: test.AccountA, + exclude: []string{}, + includeDeleted: true, + check: func(t *testing.T, r generated.AccountResponse) { + require.NotNil(t, r.Account.CreatedAssets) + require.NotNil(t, r.Account.CreatedApps) + require.NotNil(t, r.Account.Assets) + require.NotNil(t, r.Account.AppsLocalState) + }}, { address: test.AccountA, check: func(t *testing.T, r generated.AccountResponse) { require.NotNil(t, r.Account.CreatedAssets) @@ -346,7 +358,7 @@ func TestAccountExcludeParameters(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("exclude %v", tc.exclude), func(t *testing.T) { c, api, rec := setupReq("/v2/accounts/:account-id", "account-id", tc.address.String()) - err := api.LookupAccountByID(c, tc.address.String(), generated.LookupAccountByIDParams{Exclude: &tc.exclude}) + err := api.LookupAccountByID(c, tc.address.String(), generated.LookupAccountByIDParams{IncludeAll: &tc.includeDeleted, Exclude: &tc.exclude}) require.NoError(t, err) if tc.errStatus != 0 { require.Equal(t, tc.errStatus, rec.Code) @@ -522,6 +534,7 @@ func TestAccountMaxResultsLimit(t *testing.T) { includeDeleted bool errStatus int }{ + {address: test.AccountA, exclude: []string{}, errStatus: http.StatusBadRequest}, {address: test.AccountA, exclude: []string{"all"}}, {address: test.AccountA, exclude: []string{"created-assets", "created-apps", "apps-local-state", "assets"}}, {address: test.AccountA, exclude: []string{"assets", "created-apps"}}, diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go index dba0b3ff5..1d9f86c03 100644 --- a/cmd/algorand-indexer/daemon.go +++ b/cmd/algorand-indexer/daemon.go @@ -179,7 +179,7 @@ func init() { daemonCmd.Flags().MarkHidden("enable-all-parameters") } - daemonCmd.Flags().Uint32VarP(&maxAPIResourcesPerAccount, "max-api-resources-per-account", "", 0, "set the maximum total number of resources (created assets, created apps, asset holdings, and application local state) per account that will be allowed in REST API lookupAccountByID and searchForAccounts responses before returning a 400 Bad Request. Set zero for no limit (default: unlimited)") + daemonCmd.Flags().Uint32VarP(&maxAPIResourcesPerAccount, "max-api-resources-per-account", "", 1000, "set the maximum total number of resources (created assets, created apps, asset holdings, and application local state) per account that will be allowed in REST API lookupAccountByID and searchForAccounts responses before returning a 400 Bad Request. Set zero for no limit") daemonCmd.Flags().Uint32VarP(&maxTransactionsLimit, "max-transactions-limit", "", 10000, "set the maximum allowed Limit parameter for querying transactions") daemonCmd.Flags().Uint32VarP(&defaultTransactionsLimit, "default-transactions-limit", "", 1000, "set the default Limit parameter for querying transactions, if none is provided") diff --git a/idb/postgres/postgres.go b/idb/postgres/postgres.go index 6fc9b44d5..9a8950e3a 100644 --- a/idb/postgres/postgres.go +++ b/idb/postgres/postgres.go @@ -1758,7 +1758,7 @@ func (db *IndexerDb) checkAccountResourceLimit(ctx context.Context, tx pgx.Tx, o var rewardsbase uint64 var keytype *string var accountDataJSONStr []byte - var holdingCount, assetCount, appCount, lsCount uint64 + var holdingCount, assetCount, appCount, lsCount sql.NullInt64 cols := []interface{}{&addr, µalgos, &rewardstotal, &createdat, &closedat, &deleted, &rewardsbase, &keytype, &accountDataJSONStr} if countOnly { if o.IncludeAssetHoldings { @@ -1788,10 +1788,10 @@ func (db *IndexerDb) checkAccountResourceLimit(ctx context.Context, tx pgx.Tx, o // check limit against filters (only count what would be returned) var resultCount, totalAssets, totalAssetParams, totalAppLocalStates, totalAppParams uint64 if countOnly { - totalAssets = holdingCount - totalAssetParams = assetCount - totalAppLocalStates = lsCount - totalAppParams = appCount + totalAssets = uint64(holdingCount.Int64) + totalAssetParams = uint64(assetCount.Int64) + totalAppLocalStates = uint64(lsCount.Int64) + totalAppParams = uint64(appCount.Int64) } else { totalAssets = ad.TotalAssets totalAssetParams = ad.TotalAssetParams @@ -1948,7 +1948,7 @@ func (db *IndexerDb) buildAccountQuery(opts idb.AccountQueryOptions, countOnly b where = ` WHERE NOT la.deleted` } if countOnly { - selectCols = `count(*) as app_count` + selectCols = `count(*) as ls_count` } else { selectCols = `json_agg(la.app) as lsapps, json_agg(la.localstate) as lsls, json_agg(la.created_at) as ls_created_at, json_agg(la.closed_at) as ls_closed_at, json_agg(la.deleted) as ls_deleted` } diff --git a/test/README.md b/test/README.md index f5944c4df..4301ef1cd 100644 --- a/test/README.md +++ b/test/README.md @@ -49,7 +49,7 @@ sql_test `` `` `` `` # Debugging -You'll need to break before the `algorand-indexer import` call and startup your debugger in import mode. +Add true to the end of the `algorand-indexer import` call, use the provided command to initialize indexer. It may be useful to edit one of the entry point scripts to make sure the dataset you're interested in is loaded first. diff --git a/test/common.sh b/test/common.sh index 0e1a7c31a..4a1e3bc73 100755 --- a/test/common.sh +++ b/test/common.sh @@ -441,12 +441,12 @@ function create_delete_tests() { '"created-at-round": 13' \ '"deleted-at-round": 37' - rest_test "[rest - account/application] account with a deleted application" \ + rest_test "[rest - account/application] account with a deleted application excluded" \ "/v2/accounts/XNMIHFHAZ2GE3XUKISNMOYKNFDOJXBJMVHRSXVVVIK3LNMT22ET2TA4N4I?pretty" \ 200 \ false \ '"id": 82' - rest_test "[rest - account/application] account with a deleted application" \ + rest_test "[rest - account/application] account with a deleted application included" \ "/v2/accounts/XNMIHFHAZ2GE3XUKISNMOYKNFDOJXBJMVHRSXVVVIK3LNMT22ET2TA4N4I?pretty&include-all=true" \ 200 \ true \