Skip to content

Add group-id parameter to GET /v2/transactions #1647

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
18 changes: 18 additions & 0 deletions api/converter_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ func decodeType(str *string, errorArr []string) (t idb.TxnTypeEnum, err []string
return 0, errorArr
}

// decodeGroupID decodes a transaction Group ID from base64 to its byte representation.
func decodeGroupID(str *string, field string, errorArr []string) ([]byte, []string) {
if str != nil {
data, err := base64.StdEncoding.DecodeString(*str)
if err != nil {
return nil, append(errorArr, fmt.Sprintf("%s: '%s'", errUnableToParseBase64, field))
}
if len(data) != len(sdk.Digest{}) {
return nil, append(errorArr, fmt.Sprintf("%s: '%s'", errBadGroupIDLen, field))
}
return data, errorArr
}
return nil, errorArr
}

////////////////////////////////////////////////////
// Helpers to convert to and from generated types //
////////////////////////////////////////////////////
Expand Down Expand Up @@ -826,6 +841,9 @@ func (si *ServerImplementation) transactionParamsToTransactionFilter(params gene
// Byte array
filter.NotePrefix, errorArr = decodeBase64Byte(params.NotePrefix, "note-prefix", errorArr)

// Group ID
filter.GroupID, errorArr = decodeGroupID(params.GroupId, "group-id", errorArr)

// Time
if params.AfterTime != nil {
filter.AfterTime = *params.AfterTime
Expand Down
2 changes: 1 addition & 1 deletion api/disabled_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ func GetDefaultDisabledMapConfigForPostgres() *DisabledMapConfig {
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{"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/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", "group-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
Expand Down
1 change: 1 addition & 0 deletions api/error_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
errUnableToParseAddress = "unable to parse address"
errInvalidCreatorAddress = "found an invalid creator address"
errUnableToParseBase64 = "unable to parse base64 data"
errBadGroupIDLen = "bad length for group ID"
errUnableToParseDigest = "unable to parse base32 digest data"
errUnableToParseNext = "unable to parse next token"
errUnableToDecodeTransaction = "unable to decode transaction bytes"
Expand Down
387 changes: 194 additions & 193 deletions api/generated/common/routes.go

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions api/generated/common/types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

484 changes: 247 additions & 237 deletions api/generated/v2/routes.go

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions api/generated/v2/types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 81 additions & 0 deletions api/handlers_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2724,3 +2724,84 @@ func TestPNAHeader(t *testing.T) {
require.Equal(t, resp.Header.Get("Access-Control-Allow-Private-Network"), "true")
defer resp.Body.Close()
}

// TestTxnSearchByGroupID exercises the `group-id` parameter in `GET /v2/transactions`.
func TestTxnSearchByGroupID(t *testing.T) {

db, shutdownFunc := setupIdb(t, test.MakeGenesis())
defer shutdownFunc()

// Ingest a block that contains 4 transactions:
// - Q7TE63GWNAD7SAYZ2M22RFY2JE5W3VJRGBKGFS2YS5SX5RACJJNQ
// (txn group A62qVigWtWo0laUzcE1iZY8+KXWzK1vSkgwN/eKgvjc=)
// - AXBJE3C5ZD6ZUW6ROJRJNMP6A6HQSE7TDKUXPEQ2GEFTXFR3AWLQ
// (txn group A62qVigWtWo0laUzcE1iZY8+KXWzK1vSkgwN/eKgvjc=)
// - 7ZLDLORXW5BH575FEAMHMPWOW4NYRRE3UI6UGSJEBL2WRMWQLXRA
// (txn group A62qVigWtWo0laUzcE1iZY8+KXWzK1vSkgwN/eKgvjc=)
// - CVZM3RVPWMEFKPEZH43U3PEXKGIPQY7WMHCTSBRM47WJTYRKOOIQ
// (does not belong to any group)
vb, err := test.ReadValidatedBlockFromFile("test_resources/validated_blocks/TxnSearchByGroupID.vb")
require.NoError(t, err)
err = db.AddBlock(&vb)
require.NoError(t, err)

// Define test cases in a table driven style
testcases := []struct {
InputFilter generated.SearchForTransactionsParams
ExpectedTXIDs []string
}{
// Searching for transactions without the `group-id` filter, should return all transactions in the database.
{
InputFilter: generated.SearchForTransactionsParams{},
ExpectedTXIDs: []string{
"Q7TE63GWNAD7SAYZ2M22RFY2JE5W3VJRGBKGFS2YS5SX5RACJJNQ",
"AXBJE3C5ZD6ZUW6ROJRJNMP6A6HQSE7TDKUXPEQ2GEFTXFR3AWLQ",
"7ZLDLORXW5BH575FEAMHMPWOW4NYRRE3UI6UGSJEBL2WRMWQLXRA",
"CVZM3RVPWMEFKPEZH43U3PEXKGIPQY7WMHCTSBRM47WJTYRKOOIQ",
},
},
// Searching for an existing group ID, should return exactly all transactions in the group.
{
InputFilter: generated.SearchForTransactionsParams{GroupId: strPtr("A62qVigWtWo0laUzcE1iZY8+KXWzK1vSkgwN/eKgvjc=")},
ExpectedTXIDs: []string{
"Q7TE63GWNAD7SAYZ2M22RFY2JE5W3VJRGBKGFS2YS5SX5RACJJNQ",
"AXBJE3C5ZD6ZUW6ROJRJNMP6A6HQSE7TDKUXPEQ2GEFTXFR3AWLQ",
"7ZLDLORXW5BH575FEAMHMPWOW4NYRRE3UI6UGSJEBL2WRMWQLXRA",
},
},
// Searching for a non-existing group ID, should return no transactions.
{
InputFilter: generated.SearchForTransactionsParams{GroupId: strPtr("GGtw1eCxEL5umVf584LH2dZzWG6Dk48BT5ThNZkhwoE=")},
ExpectedTXIDs: nil,
},
}

// Exercise every test case
for _, tc := range testcases {

// Build the HTTP request
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPath("/v2/transactions")

// Call the handler for `GET /v2/transactions`
api := testServerImplementation(db)
err = api.SearchForTransactions(c, tc.InputFilter)
require.NoError(t, err)

// Make assertions about the response
require.Equal(t, http.StatusOK, rec.Code)
var response generated.TransactionsResponse
err = json.Decode(rec.Body.Bytes(), &response)
require.NoError(t, err)

// Make sure the returned TXIDs match the expectations
require.Len(t, response.Transactions, len(tc.ExpectedTXIDs))
for i := range tc.ExpectedTXIDs {
require.NotNil(t, response.Transactions[i].Id)
require.Equal(t, tc.ExpectedTXIDs[i], *(response.Transactions[i].Id))
}
}
}
10 changes: 10 additions & 0 deletions api/indexer.oas2.json
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,9 @@
{
"$ref": "#/parameters/sig-type"
},
{
"$ref": "#/parameters/group-id"
},
{
"$ref": "#/parameters/txid"
},
Expand Down Expand Up @@ -3024,6 +3027,13 @@
"in": "query",
"x-algorand-format": "Address"
},
"group-id": {
"type": "string",
"description": "Lookup transactions by group ID. This field must be base64-encoded, and afterwards, base64 characters that are URL-unsafe (i.e. =, /, +) must be URL-encoded",
"name": "group-id",
"in": "query",
"x-algorand-format": "base64"
},
"txid": {
"type": "string",
"description": "Lookup the specific transaction by ID.",
Expand Down
20 changes: 20 additions & 0 deletions api/indexer.oas3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@
},
"style": "form"
},
"group-id": {
"description": "Lookup transactions by group ID. This field must be base64-encoded, and afterwards, base64 characters that are URL-unsafe (i.e. =, /, +) must be URL-encoded",
"in": "query",
"name": "group-id",
"schema": {
"type": "string",
"x-algorand-format": "base64"
},
"x-algorand-format": "base64"
},
"header-only": {
"description": "Header only flag. When this is set to true, returned block does not contain the transactions",
"in": "query",
Expand Down Expand Up @@ -5259,6 +5269,16 @@
"type": "string"
}
},
{
"description": "Lookup transactions by group ID. This field must be base64-encoded, and afterwards, base64 characters that are URL-unsafe (i.e. =, /, +) must be URL-encoded",
"in": "query",
"name": "group-id",
"schema": {
"type": "string",
"x-algorand-format": "base64"
},
"x-algorand-format": "base64"
},
{
"description": "Lookup the specific transaction by ID.",
"in": "query",
Expand Down
Binary file not shown.
1 change: 1 addition & 0 deletions idb/idb.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ type TransactionFilter struct {
OffsetGT *uint64 // nil for no filter
SigType SigType // ["", "sig", "msig", "lsig"]
NotePrefix []byte
GroupID []byte
AlgosGT *uint64 // implictly filters on "pay" txns for Algos > this. This will be a slightly faster query than EffectiveAmountGT.
AlgosLT *uint64
RekeyTo *bool // nil for no filter
Expand Down
33 changes: 31 additions & 2 deletions idb/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package postgres
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -623,6 +624,11 @@ func buildTransactionQuery(tf idb.TransactionFilter) (query string, whereArgs []
whereArgs = append(whereArgs, tf.Txid)
partNumber++
}
if len(tf.GroupID) != 0 {
whereParts = append(whereParts, fmt.Sprintf("t.txn #>> '{txn,grp}'::text[] = $%d AND t.txn #>> '{txn,grp}'::text[] IS NOT NULL", partNumber))
whereArgs = append(whereArgs, base64.StdEncoding.EncodeToString(tf.GroupID))
partNumber++
}
if tf.Round != nil {
whereParts = append(whereParts, fmt.Sprintf("t.round = $%d", partNumber))
whereArgs = append(whereArgs, *tf.Round)
Expand Down Expand Up @@ -708,9 +714,32 @@ func buildTransactionQuery(tf idb.TransactionFilter) (query string, whereArgs []
// this should explicitly match the primary key on txn (round,intra)
query += " ORDER BY t.round, t.intra"
}
if tf.Limit != 0 {
query += fmt.Sprintf(" LIMIT %d", tf.Limit)

// Determine the LIMIT clause
var limit string
if len(tf.GroupID) > 0 && (tf.Limit == 0 || tf.Limit >= sdk.MaxTxGroupSize) {
// This is an optimization for the case where a group ID is being used.
//
// If a group ID is being used, we know that the query will return at most 16 results
// (the maximum size of an atomic transaction group).
//
// Therefore, we could get rid of the LIMIT clause.
//
// Skipping the limit clause seems to make the query optimizer pick the right index:
//
// CREATE INDEX txn_grp
// ON public.txn
// USING btree (((txn #>> '{txn,grp}'::text[])))
// WHERE ((txn #>> '{txn,grp}'::text[]) IS NOT NULL);
//
// This index normally would not be used if we didn't skip the LIMIT clause,
// the query execution plan would normally result in a sequential scan over the txn table.
limit = ""
} else if tf.Limit != 0 {
limit = fmt.Sprintf(" LIMIT %d", tf.Limit)
}
query += limit

return
}

Expand Down